Compare commits
4 Commits
v0.4.0
...
e16f61aca7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e16f61aca7 | ||
|
|
dba4ad168c | ||
|
|
e78d252cf3 | ||
|
|
ab9c650d93 |
@@ -1,19 +1,41 @@
|
|||||||
|
# Python cache / compiled
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
.Python
|
.Python
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
*.so
|
*.so
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info
|
*.egg-info
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# VCS
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
|
# Databases (must live in volumes)
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
|
# Environment / virtualenv
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
||||||
|
# Runtime data (mounted volumes)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Editors / OS junk
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.claude
|
||||||
|
sfm.code-workspace
|
||||||
|
|
||||||
|
# Tests (optional)
|
||||||
|
tests/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import List, Dict
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
|
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -68,6 +68,8 @@ app.include_router(roster_edit.router)
|
|||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(dashboard_tabs.router)
|
app.include_router(dashboard_tabs.router)
|
||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
|
app.include_router(slmm.router)
|
||||||
|
app.include_router(slm_ui.router)
|
||||||
|
|
||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
|||||||
78
backend/migrate_add_slm_fields.py
Normal file
78
backend/migrate_add_slm_fields.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add sound level meter fields to roster table.
|
||||||
|
|
||||||
|
Adds columns for sound_level_meter device type support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add SLM fields to roster table if they don't exist."""
|
||||||
|
|
||||||
|
# Try multiple possible database locations
|
||||||
|
possible_paths = [
|
||||||
|
Path("data/seismo_fleet.db"),
|
||||||
|
Path("data/sfm.db"),
|
||||||
|
Path("data/seismo.db"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db_path = None
|
||||||
|
for path in possible_paths:
|
||||||
|
if path.exists():
|
||||||
|
db_path = path
|
||||||
|
break
|
||||||
|
|
||||||
|
if db_path is None:
|
||||||
|
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||||
|
print("Creating database with models.py will include new fields automatically.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if columns already exist
|
||||||
|
cursor.execute("PRAGMA table_info(roster)")
|
||||||
|
existing_columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
new_columns = {
|
||||||
|
"slm_host": "TEXT",
|
||||||
|
"slm_tcp_port": "INTEGER",
|
||||||
|
"slm_model": "TEXT",
|
||||||
|
"slm_serial_number": "TEXT",
|
||||||
|
"slm_frequency_weighting": "TEXT",
|
||||||
|
"slm_time_weighting": "TEXT",
|
||||||
|
"slm_measurement_range": "TEXT",
|
||||||
|
"slm_last_check": "DATETIME",
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations_applied = []
|
||||||
|
|
||||||
|
for column_name, column_type in new_columns.items():
|
||||||
|
if column_name not in existing_columns:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}")
|
||||||
|
migrations_applied.append(column_name)
|
||||||
|
print(f"✓ Added column: {column_name} ({column_type})")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to add column {column_name}: {e}")
|
||||||
|
else:
|
||||||
|
print(f"○ Column already exists: {column_name}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if migrations_applied:
|
||||||
|
print(f"\n✓ Migration complete! Added {len(migrations_applied)} new columns.")
|
||||||
|
else:
|
||||||
|
print("\n○ No migration needed - all columns already exist.")
|
||||||
|
|
||||||
|
print("\nSound level meter fields are now available in the roster table.")
|
||||||
|
print("You can now set device_type='sound_level_meter' for SLM devices.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -19,14 +19,14 @@ class RosterUnit(Base):
|
|||||||
Roster table: represents our *intended assignment* of a unit.
|
Roster table: represents our *intended assignment* of a unit.
|
||||||
This is editable from the GUI.
|
This is editable from the GUI.
|
||||||
|
|
||||||
Supports multiple device types (seismograph, modem) with type-specific fields.
|
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "roster"
|
__tablename__ = "roster"
|
||||||
|
|
||||||
# Core fields (all device types)
|
# Core fields (all device types)
|
||||||
id = Column(String, primary_key=True, index=True)
|
id = Column(String, primary_key=True, index=True)
|
||||||
unit_type = Column(String, default="series3") # Backward compatibility
|
unit_type = Column(String, default="series3") # Backward compatibility
|
||||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem"
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
note = Column(String, nullable=True)
|
note = Column(String, nullable=True)
|
||||||
@@ -36,16 +36,26 @@ class RosterUnit(Base):
|
|||||||
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Seismograph-specific fields (nullable for modems)
|
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||||
last_calibrated = Column(Date, nullable=True)
|
last_calibrated = Column(Date, nullable=True)
|
||||||
next_calibration_due = Column(Date, nullable=True)
|
next_calibration_due = Column(Date, nullable=True)
|
||||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
||||||
|
|
||||||
# Modem-specific fields (nullable for seismographs)
|
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||||
ip_address = Column(String, nullable=True)
|
ip_address = Column(String, nullable=True)
|
||||||
phone_number = Column(String, nullable=True)
|
phone_number = Column(String, nullable=True)
|
||||||
hardware_model = Column(String, nullable=True)
|
hardware_model = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
|
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||||
|
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||||
|
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||||
|
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||||
|
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||||
|
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||||
|
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||||
|
|
||||||
|
|
||||||
class IgnoredUnit(Base):
|
class IgnoredUnit(Base):
|
||||||
"""
|
"""
|
||||||
@@ -94,4 +104,4 @@ class UserPreferences(Base):
|
|||||||
calibration_warning_days = Column(Integer, default=30)
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
|||||||
"ip_address": unit.ip_address or "",
|
"ip_address": unit.ip_address or "",
|
||||||
"phone_number": unit.phone_number or "",
|
"phone_number": unit.phone_number or "",
|
||||||
"hardware_model": unit.hardware_model or "",
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"slm_host": unit.slm_host or "",
|
||||||
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
|
"slm_model": unit.slm_model or "",
|
||||||
|
"slm_serial_number": unit.slm_serial_number or "",
|
||||||
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||||
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||||
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||||
|
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||||
} for unit in units]
|
} for unit in units]
|
||||||
|
|
||||||
|
|||||||
123
backend/routers/slm_ui.py
Normal file
123
backend/routers/slm_ui.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Sound Level Meter UI Router
|
||||||
|
|
||||||
|
Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Sound level meter detail page with controls."""
|
||||||
|
|
||||||
|
# Get roster unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit or unit.device_type != "sound_level_meter":
|
||||||
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("slm_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{unit_id}/summary")
|
||||||
|
async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get SLM summary data for dashboard card."""
|
||||||
|
|
||||||
|
# Get roster unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit or unit.device_type != "sound_level_meter":
|
||||||
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
|
# Try to get live status from SLMM
|
||||||
|
status_data = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||||
|
if response.status_code == 200:
|
||||||
|
status_data = response.json().get("data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"device_type": "sound_level_meter",
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"model": unit.slm_model or "NL-43",
|
||||||
|
"location": unit.address or unit.location,
|
||||||
|
"coordinates": unit.coordinates,
|
||||||
|
"note": unit.note,
|
||||||
|
"status": status_data,
|
||||||
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/partials/{unit_id}/card", response_class=HTMLResponse)
|
||||||
|
async def slm_dashboard_card(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Render SLM dashboard card partial."""
|
||||||
|
|
||||||
|
summary = await get_slm_summary(unit_id, db)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_card.html", {
|
||||||
|
"request": request,
|
||||||
|
"slm": summary
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/partials/{unit_id}/controls", response_class=HTMLResponse)
|
||||||
|
async def slm_controls_partial(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Render SLM control panel partial."""
|
||||||
|
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit or unit.device_type != "sound_level_meter":
|
||||||
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
|
# Get current status from SLMM
|
||||||
|
measurement_state = None
|
||||||
|
battery_level = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
# Get measurement state
|
||||||
|
state_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||||
|
)
|
||||||
|
if state_response.status_code == 200:
|
||||||
|
measurement_state = state_response.json().get("measurement_state")
|
||||||
|
|
||||||
|
# Get battery level
|
||||||
|
battery_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/battery"
|
||||||
|
)
|
||||||
|
if battery_response.status_code == 200:
|
||||||
|
battery_level = battery_response.json().get("battery_level")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get SLM control data for {unit_id}: {e}")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_controls.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"unit": unit,
|
||||||
|
"measurement_state": measurement_state,
|
||||||
|
"battery_level": battery_level,
|
||||||
|
"is_measuring": measurement_state == "Start"
|
||||||
|
})
|
||||||
130
backend/routers/slmm.py
Normal file
130
backend/routers/slmm.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
SLMM (Sound Level Meter Manager) Proxy Router
|
||||||
|
|
||||||
|
Proxies requests from SFM to the standalone SLMM backend service.
|
||||||
|
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/slmm", tags=["slmm"])
|
||||||
|
|
||||||
|
# SLMM backend URL - configurable via environment variable
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def check_slmm_health():
|
||||||
|
"""
|
||||||
|
Check if the SLMM backend service is reachable and healthy.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(f"{SLMM_BASE_URL}/health")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"slmm_status": "connected",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"slmm_version": data.get("version", "unknown"),
|
||||||
|
"slmm_response": data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "degraded",
|
||||||
|
"slmm_status": "error",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"detail": f"SLMM returned status {response.status_code}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"slmm_status": "unreachable",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"detail": "Cannot connect to SLMM backend. Is it running?"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"slmm_status": "error",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"detail": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
async def proxy_to_slmm(path: str, request: Request):
|
||||||
|
"""
|
||||||
|
Proxy all requests to the SLMM backend service.
|
||||||
|
|
||||||
|
This allows SFM to act as a unified frontend for all device types,
|
||||||
|
while SLMM remains a standalone backend service.
|
||||||
|
"""
|
||||||
|
# Build target URL
|
||||||
|
target_url = f"{SLMM_BASE_URL}/api/nl43/{path}"
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
query_params = dict(request.query_params)
|
||||||
|
|
||||||
|
# Get request body if present
|
||||||
|
body = None
|
||||||
|
if request.method in ["POST", "PUT", "PATCH"]:
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read request body: {e}")
|
||||||
|
body = None
|
||||||
|
|
||||||
|
# Get headers (exclude host and other proxy-specific headers)
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||||
|
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||||
|
|
||||||
|
logger.info(f"Proxying {request.method} request to SLMM: {target_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
# Forward the request to SLMM
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
params=query_params,
|
||||||
|
headers=proxy_headers,
|
||||||
|
content=body
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the response from SLMM
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
media_type=response.headers.get("content-type")
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?"
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail="SLMM backend timeout"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error proxying to SLMM: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to proxy request to SLMM: {str(e)}"
|
||||||
|
)
|
||||||
@@ -40,7 +40,6 @@ def emit_status_snapshot():
|
|||||||
# --- Merge roster entries first ---
|
# --- Merge roster entries first ---
|
||||||
for unit_id, r in roster.items():
|
for unit_id, r in roster.items():
|
||||||
e = emitters.get(unit_id)
|
e = emitters.get(unit_id)
|
||||||
|
|
||||||
if r.retired:
|
if r.retired:
|
||||||
# Retired units get separated later
|
# Retired units get separated later
|
||||||
status = "Retired"
|
status = "Retired"
|
||||||
@@ -60,12 +59,12 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
fname = ""
|
fname = ""
|
||||||
|
|
||||||
units[unit_id] = {
|
units[unit_id] = {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
"age": age,
|
"age": age,
|
||||||
"last": last_seen.isoformat() if last_seen else None,
|
"last": last_seen.isoformat() if last_seen else None,
|
||||||
"fname": fname,
|
"fname": fname,
|
||||||
"deployed": r.deployed,
|
"deployed": r.deployed,
|
||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
@@ -76,12 +75,12 @@ def emit_status_snapshot():
|
|||||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping
|
# Location for mapping
|
||||||
"location": r.location or "",
|
"location": r.location or "",
|
||||||
"address": r.address or "",
|
"address": r.address or "",
|
||||||
"coordinates": r.coordinates or "",
|
"coordinates": r.coordinates or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
for unit_id, e in emitters.items():
|
for unit_id, e in emitters.items():
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
|
- SLMM_BASE_URL=http://172.19.0.1:8100
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ python-multipart==0.0.6
|
|||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
|
httpx==0.25.2
|
||||||
|
|||||||
105
templates/partials/slm_controls.html
Normal file
105
templates/partials/slm_controls.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="mb-6 p-4 rounded-lg {% if is_measuring %}bg-green-50 dark:bg-green-900/20{% else %}bg-gray-50 dark:bg-gray-900{% endif %}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Measurement Status</div>
|
||||||
|
<div class="text-2xl font-bold {% if is_measuring %}text-green-600 dark:text-green-400{% else %}text-gray-600 dark:text-gray-400{% endif %}">
|
||||||
|
{% if measurement_state %}
|
||||||
|
{{ measurement_state }}
|
||||||
|
{% if is_measuring %}
|
||||||
|
<span class="inline-block w-3 h-3 bg-green-500 rounded-full ml-2 animate-pulse"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Battery</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ battery_level or '--' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/start"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2
|
||||||
|
{% if is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if is_measuring %}disabled{% endif %}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/stop"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2
|
||||||
|
{% if not is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if not is_measuring %}disabled{% endif %}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/pause"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/reset"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-confirm="Are you sure you want to reset the measurement data?"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button hx-get="/api/slmm/{{ unit_id }}/live"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#live-spinner"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg id="live-spinner" class="htmx-indicator w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Get Live Data
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/store"
|
||||||
|
hx-swap="none"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||||
|
</svg>
|
||||||
|
Store Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slm-controls" hx-get="/slm/partials/{{ unit_id }}/controls" hx-trigger="refresh" hx-swap="outerHTML"></div>
|
||||||
@@ -124,6 +124,7 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
<option value="seismograph">Seismograph</option>
|
<option value="seismograph">Seismograph</option>
|
||||||
<option value="modem">Modem</option>
|
<option value="modem">Modem</option>
|
||||||
|
<option value="sound_level_meter">Sound Level Meter</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -186,6 +187,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sound Level Meter-specific fields -->
|
||||||
|
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
|
||||||
|
<input type="text" name="slm_model" placeholder="NL-43"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
|
||||||
|
<input type="text" name="slm_host" placeholder="192.168.1.100"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" name="slm_tcp_port" placeholder="2255"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" name="slm_serial_number" placeholder="SN123456"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select name="slm_frequency_weighting"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="A">A-weighting</option>
|
||||||
|
<option value="C">C-weighting</option>
|
||||||
|
<option value="Z">Z-weighting (Flat)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select name="slm_time_weighting"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="F">F (Fast)</option>
|
||||||
|
<option value="S">S (Slow)</option>
|
||||||
|
<option value="I">I (Impulse)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
|
<input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
|
||||||
@@ -388,14 +432,21 @@
|
|||||||
const deviceType = document.getElementById('deviceTypeSelect').value;
|
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||||
const seismoFields = document.getElementById('seismographFields');
|
const seismoFields = document.getElementById('seismographFields');
|
||||||
const modemFields = document.getElementById('modemFields');
|
const modemFields = document.getElementById('modemFields');
|
||||||
|
const slmFields = document.getElementById('slmFields');
|
||||||
|
|
||||||
if (deviceType === 'seismograph') {
|
if (deviceType === 'seismograph') {
|
||||||
seismoFields.classList.remove('hidden');
|
seismoFields.classList.remove('hidden');
|
||||||
modemFields.classList.add('hidden');
|
modemFields.classList.add('hidden');
|
||||||
|
slmFields.classList.add('hidden');
|
||||||
toggleModemPairing(); // Check if modem pairing should be shown
|
toggleModemPairing(); // Check if modem pairing should be shown
|
||||||
} else {
|
} else if (deviceType === 'modem') {
|
||||||
seismoFields.classList.add('hidden');
|
seismoFields.classList.add('hidden');
|
||||||
modemFields.classList.remove('hidden');
|
modemFields.classList.remove('hidden');
|
||||||
|
slmFields.classList.add('hidden');
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
seismoFields.classList.add('hidden');
|
||||||
|
modemFields.classList.add('hidden');
|
||||||
|
slmFields.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
195
templates/slm_detail.html
Normal file
195
templates/slm_detail.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Back to Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
|
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||||
|
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-time Data Stream -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div id="slm-stream-container">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<button onclick="startStream()"
|
||||||
|
id="stream-start-btn"
|
||||||
|
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
||||||
|
Start Real-time Stream
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
||||||
|
</div>
|
||||||
|
<div id="stream-data" class="hidden">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
||||||
|
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
||||||
|
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
||||||
|
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
||||||
|
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Streaming
|
||||||
|
</div>
|
||||||
|
<button onclick="stopStream()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Stop Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('stream-start-btn').classList.add('hidden');
|
||||||
|
document.getElementById('stream-data').classList.remove('hidden');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Stream error:', data.error);
|
||||||
|
stopStream();
|
||||||
|
alert('Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update values
|
||||||
|
document.getElementById('stream-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('stream-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
stopStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
document.getElementById('stream-start-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('stream-data').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user