""" 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 httpx import logging from backend.database import get_db from backend.models import RosterUnit logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) templates = Jinja2Templates(directory="templates") @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) ): """ 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 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)) ) # Only show deployed units by default units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all() return templates.TemplateResponse("partials/slm_unit_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=5.0) as client: # Get measurement state state_response = await client.get( f"http://localhost:8100/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) # Get live status status_response = await client.get( f"http://localhost:8100/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"http://localhost:8100/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}") 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) }