""" SLM Dashboard Router Provides API endpoints for the Sound Level Meters dashboard page. """ from fastapi import APIRouter, Request, Depends, Query from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from sqlalchemy import func from datetime import datetime, timedelta import asyncio import httpx import logging import os from backend.database import get_db from backend.models import RosterUnit from backend.routers.roster_edit import sync_slm_to_slmm_cache logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) templates = Jinja2Templates(directory="templates") # SLMM backend URL - configurable via environment variable SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") @router.get("/stats", response_class=HTMLResponse) async def get_slm_stats(request: Request, db: Session = Depends(get_db)): """ Get summary statistics for SLM dashboard. Returns HTML partial with stat cards. """ # Query all SLMs all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all() # Count deployed vs benched deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired) benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired) retired_count = sum(1 for slm in all_slms if slm.retired) # Count recently active (checked in last hour) one_hour_ago = datetime.utcnow() - timedelta(hours=1) active_count = sum(1 for slm in all_slms if slm.slm_last_check and slm.slm_last_check > one_hour_ago) return templates.TemplateResponse("partials/slm_stats.html", { "request": request, "total_count": len(all_slms), "deployed_count": deployed_count, "benched_count": benched_count, "active_count": active_count, "retired_count": retired_count }) @router.get("/units", response_class=HTMLResponse) async def get_slm_units( request: Request, db: Session = Depends(get_db), search: str = Query(None), project: str = Query(None), include_measurement: bool = Query(False), ): """ Get list of SLM units for the sidebar. Returns HTML partial with unit cards. """ query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") # Filter by project if provided if project: query = query.filter(RosterUnit.project_id == project) # Filter by search term if provided if search: search_term = f"%{search}%" query = query.filter( (RosterUnit.id.like(search_term)) | (RosterUnit.slm_model.like(search_term)) | (RosterUnit.address.like(search_term)) ) units = query.order_by( RosterUnit.retired.asc(), RosterUnit.deployed.desc(), RosterUnit.id.asc() ).all() one_hour_ago = datetime.utcnow() - timedelta(hours=1) for unit in units: unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) if include_measurement: async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None: try: response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state") if response.status_code == 200: return response.json().get("measurement_state") except Exception: return None return None deployed_units = [unit for unit in units if unit.deployed and not unit.retired] if deployed_units: async with httpx.AsyncClient(timeout=3.0) as client: tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units] results = await asyncio.gather(*tasks, return_exceptions=True) for unit, state in zip(deployed_units, results): if isinstance(state, Exception): unit.measurement_state = None else: unit.measurement_state = state return templates.TemplateResponse("partials/slm_device_list.html", { "request": request, "units": units }) @router.get("/live-view/{unit_id}", response_class=HTMLResponse) async def get_live_view(request: Request, unit_id: str, db: Session = Depends(get_db)): """ Get live view panel for a specific SLM unit. Returns HTML partial with live metrics and chart. """ # Get unit from database unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() if not unit: return templates.TemplateResponse("partials/slm_live_view_error.html", { "request": request, "error": f"Unit {unit_id} not found" }) # Get modem information if assigned modem = None modem_ip = None if unit.deployed_with_modem_id: modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first() if modem: modem_ip = modem.ip_address else: logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found") # Fallback to direct slm_host if no modem assigned (backward compatibility) if not modem_ip and unit.slm_host: modem_ip = unit.slm_host logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}") # Try to get current status from SLMM current_status = None measurement_state = None is_measuring = False try: async with httpx.AsyncClient(timeout=10.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: state_data = state_response.json() measurement_state = state_data.get("measurement_state", "Unknown") is_measuring = state_data.get("is_measuring", False) # If measuring, sync start time from FTP to database (fixes wrong timestamps) if is_measuring: try: sync_response = await client.post( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time", timeout=10.0 ) if sync_response.status_code == 200: sync_data = sync_response.json() logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}") else: logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}") except Exception as e: # Don't fail the whole request if sync fails logger.warning(f"Could not sync start time for {unit_id}: {e}") # Get live status (now with corrected start time) status_response = await client.get( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" ) if status_response.status_code == 200: status_data = status_response.json() current_status = status_data.get("data", {}) except Exception as e: logger.error(f"Failed to get status for {unit_id}: {e}") return templates.TemplateResponse("partials/slm_live_view.html", { "request": request, "unit": unit, "modem": modem, "modem_ip": modem_ip, "current_status": current_status, "measurement_state": measurement_state, "is_measuring": is_measuring }) @router.post("/control/{unit_id}/{action}") async def control_slm(unit_id: str, action: str): """ Send control commands to SLM (start, stop, pause, resume, reset). Proxies to SLMM backend. """ valid_actions = ["start", "stop", "pause", "resume", "reset"] if action not in valid_actions: return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"} try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}" ) if response.status_code == 200: return response.json() else: return { "status": "error", "detail": f"SLMM returned status {response.status_code}" } except Exception as e: logger.error(f"Failed to control {unit_id}: {e}") return { "status": "error", "detail": str(e) } @router.get("/config/{unit_id}", response_class=HTMLResponse) async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): """ Get configuration form for a specific SLM unit. Returns HTML partial with configuration form. """ unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() if not unit: return HTMLResponse( content=f'
Unit {unit_id} not found
', status_code=404 ) return templates.TemplateResponse("partials/slm_config_form.html", { "request": request, "unit": unit }) @router.post("/config/{unit_id}") async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)): """ Save SLM configuration. Updates unit parameters in the database. """ unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first() if not unit: return {"status": "error", "detail": f"Unit {unit_id} not found"} try: # Get form data form_data = await request.form() # Update SLM-specific fields unit.slm_model = form_data.get("slm_model") or None unit.slm_serial_number = form_data.get("slm_serial_number") or None unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None unit.slm_time_weighting = form_data.get("slm_time_weighting") or None unit.slm_measurement_range = form_data.get("slm_measurement_range") or None # Update network configuration modem_id = form_data.get("deployed_with_modem_id") unit.deployed_with_modem_id = modem_id if modem_id else None # Always update TCP and FTP ports (used regardless of modem assignment) unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None # Only update direct IP if no modem is assigned if not modem_id: unit.slm_host = form_data.get("slm_host") or None else: # Clear legacy direct IP field when modem is assigned unit.slm_host = None db.commit() logger.info(f"Updated configuration for SLM {unit_id}") # Sync updated configuration to SLMM cache logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...") result = await sync_slm_to_slmm_cache( unit_id=unit_id, host=unit.slm_host, # Use the updated host from Terra-View tcp_port=unit.slm_tcp_port, ftp_port=unit.slm_ftp_port, deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned db=db ) if not result["success"]: logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}") # Config still saved in Terra-View (source of truth) return {"status": "success", "unit_id": unit_id} except Exception as e: db.rollback() logger.error(f"Failed to save config for {unit_id}: {e}") return {"status": "error", "detail": str(e)} @router.get("/test-modem/{modem_id}") async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)): """ Test modem connectivity with a simple ping/health check. Returns response time and connection status. """ import subprocess import time # Get modem from database modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() if not modem: return {"status": "error", "detail": f"Modem {modem_id} not found"} if not modem.ip_address: return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"} try: # Ping the modem (1 packet, 2 second timeout) start_time = time.time() result = subprocess.run( ["ping", "-c", "1", "-W", "2", modem.ip_address], capture_output=True, text=True, timeout=3 ) response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds if result.returncode == 0: return { "status": "success", "modem_id": modem_id, "ip_address": modem.ip_address, "response_time": response_time, "message": "Modem is responding to ping" } else: return { "status": "error", "modem_id": modem_id, "ip_address": modem.ip_address, "detail": "Modem not responding to ping" } except subprocess.TimeoutExpired: return { "status": "error", "modem_id": modem_id, "ip_address": modem.ip_address, "detail": "Ping timeout (> 2 seconds)" } except Exception as e: logger.error(f"Failed to ping modem {modem_id}: {e}") return { "status": "error", "modem_id": modem_id, "detail": str(e) }