From e16f61aca71eae665732f964efeb1a1ace4a4bb8 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Fri, 2 Jan 2026 20:27:09 +0000 Subject: [PATCH] slm integration added --- backend/main.py | 4 +- backend/migrate_add_slm_fields.py | 78 +++++++++++ backend/models.py | 18 ++- backend/routers/settings.py | 8 ++ backend/routers/slm_ui.py | 123 +++++++++++++++++ backend/routers/slmm.py | 130 ++++++++++++++++++ docker-compose.yml | 1 + requirements.txt | 1 + templates/partials/slm_controls.html | 105 +++++++++++++++ templates/roster.html | 53 +++++++- templates/slm_detail.html | 195 +++++++++++++++++++++++++++ 11 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 backend/migrate_add_slm_fields.py create mode 100644 backend/routers/slm_ui.py create mode 100644 backend/routers/slmm.py create mode 100644 templates/partials/slm_controls.html create mode 100644 templates/slm_detail.html diff --git a/backend/main.py b/backend/main.py index 575f7dc..ed55a49 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ from typing import List, Dict from pydantic import BaseModel 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.models import IgnoredUnit @@ -68,6 +68,8 @@ app.include_router(roster_edit.router) app.include_router(dashboard.router) app.include_router(dashboard_tabs.router) app.include_router(activity.router) +app.include_router(slmm.router) +app.include_router(slm_ui.router) from backend.routers import settings app.include_router(settings.router) diff --git a/backend/migrate_add_slm_fields.py b/backend/migrate_add_slm_fields.py new file mode 100644 index 0000000..1c1b50e --- /dev/null +++ b/backend/migrate_add_slm_fields.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 0b75631..e80c49c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,14 +19,14 @@ class RosterUnit(Base): Roster table: represents our *intended assignment* of a unit. 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" # Core fields (all device types) id = Column(String, primary_key=True, index=True) 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) retired = Column(Boolean, default=False) note = Column(String, nullable=True) @@ -36,16 +36,26 @@ class RosterUnit(Base): coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437" 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) next_calibration_due = Column(Date, nullable=True) 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) phone_number = 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): """ diff --git a/backend/routers/settings.py b/backend/routers/settings.py index 4cd0fb0..bb14357 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -100,6 +100,14 @@ def get_all_roster_units(db: Session = Depends(get_db)): "ip_address": unit.ip_address or "", "phone_number": unit.phone_number 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 } for unit in units] diff --git a/backend/routers/slm_ui.py b/backend/routers/slm_ui.py new file mode 100644 index 0000000..d0945f6 --- /dev/null +++ b/backend/routers/slm_ui.py @@ -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" + }) diff --git a/backend/routers/slmm.py b/backend/routers/slmm.py new file mode 100644 index 0000000..b075637 --- /dev/null +++ b/backend/routers/slmm.py @@ -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)}" + ) diff --git a/docker-compose.yml b/docker-compose.yml index b0c0ccf..cb16f59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=production + - SLMM_BASE_URL=http://172.19.0.1:8100 restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/health"] diff --git a/requirements.txt b/requirements.txt index 86b1adc..9c7ba93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ python-multipart==0.0.6 jinja2==3.1.2 aiofiles==23.2.1 Pillow==10.1.0 +httpx==0.25.2 diff --git a/templates/partials/slm_controls.html b/templates/partials/slm_controls.html new file mode 100644 index 0000000..2a5cbdd --- /dev/null +++ b/templates/partials/slm_controls.html @@ -0,0 +1,105 @@ +
+ +
+
+
+
Measurement Status
+
+ {% if measurement_state %} + {{ measurement_state }} + {% if is_measuring %} + + {% endif %} + {% else %} + Unknown + {% endif %} +
+
+
+
Battery
+
+ {{ battery_level or '--' }} +
+
+
+
+ + +
+ + + + + + + +
+ + +
+
+ + + +
+
+
+ +
diff --git a/templates/roster.html b/templates/roster.html index b870668..d0f8752 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -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"> +
@@ -186,6 +187,49 @@
+ + +
+ +
+
+
+

+ + + + + {{ unit_id }} +

+

+ {{ unit.slm_model or 'NL-43' }} Sound Level Meter +

+
+
+ + {% if unit.deployed %}Deployed{% else %}Benched{% endif %} + +
+
+
+ + +
+

Control Panel

+
+
Loading controls...
+
+
+ + +
+

Real-time Measurements

+
+
+
+ +

Click to begin streaming live measurement data

+
+ +
+
+
+ + +
+

Device Information

+
+
+
+
Model
+
{{ unit.slm_model or 'NL-43' }}
+
+
+
Serial Number
+
{{ unit.slm_serial_number or 'N/A' }}
+
+
+
Host
+
{{ unit.slm_host or 'Not configured' }}
+
+
+
TCP Port
+
{{ unit.slm_tcp_port or 'N/A' }}
+
+
+
Frequency Weighting
+
{{ unit.slm_frequency_weighting or 'A' }}
+
+
+
Time Weighting
+
{{ unit.slm_time_weighting or 'F (Fast)' }}
+
+
+
Location
+
{{ unit.address or unit.location or 'Not specified' }}
+
+ {% if unit.note %} +
+
Notes
+
{{ unit.note }}
+
+ {% endif %} +
+
+
+ + +{% endblock %}