""" Dashboard API endpoints for SLM/NL43 devices. This layer aggregates and transforms data from the device API for UI consumption. """ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from sqlalchemy import func from typing import List, Dict, Any import logging from app.slm.database import get_db as get_slm_db from app.slm.models import NL43Config, NL43Status from app.slm.services import NL43Client # Import seismo database for roster data from app.seismo.database import get_db as get_seismo_db from app.seismo.models import RosterUnit logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) templates = Jinja2Templates(directory="app/ui/templates") @router.get("/stats", response_class=HTMLResponse) async def get_dashboard_stats(request: Request, db: Session = Depends(get_seismo_db)): """Get aggregate statistics for the SLM dashboard from roster (returns HTML).""" # Query SLMs from the roster slms = db.query(RosterUnit).filter_by( device_type="sound_level_meter", retired=False ).all() total_units = len(slms) deployed = sum(1 for s in slms if s.deployed) benched = sum(1 for s in slms if not s.deployed) # For "active", count SLMs with recent check-ins (within last hour) from datetime import datetime, timedelta, timezone one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1) active = sum(1 for s in slms if s.slm_last_check and s.slm_last_check >= one_hour_ago) # Map to template variable names # total_count, deployed_count, active_count, benched_count return templates.TemplateResponse( "partials/slm_stats.html", { "request": request, "total_count": total_units, "deployed_count": deployed, "active_count": active, "benched_count": benched } ) @router.get("/units", response_class=HTMLResponse) async def get_units_list(request: Request, db: Session = Depends(get_seismo_db)): """Get list of all SLM units from roster (returns HTML).""" # Query SLMs from the roster (not retired) slms = db.query(RosterUnit).filter_by( device_type="sound_level_meter", retired=False ).order_by(RosterUnit.id).all() units = [] for slm in slms: # Map to template field names unit_data = { "id": slm.id, "slm_host": slm.slm_host, "slm_tcp_port": slm.slm_tcp_port, "slm_last_check": slm.slm_last_check, "slm_model": slm.slm_model or "NL-43", "address": slm.address, "deployed_with_modem_id": slm.deployed_with_modem_id, } units.append(unit_data) 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(unit_id: str, request: Request, slm_db: Session = Depends(get_slm_db), roster_db: Session = Depends(get_seismo_db)): """Get live measurement data for a specific unit (returns HTML).""" # Get unit from roster unit = roster_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 in roster" } ) # Get status from monitoring database (may not exist yet) status = slm_db.query(NL43Status).filter_by(unit_id=unit_id).first() # Get modem info if available modem = None modem_ip = None if unit.deployed_with_modem_id: modem = roster_db.query(RosterUnit).filter_by( id=unit.deployed_with_modem_id, device_type="modem" ).first() if modem: modem_ip = modem.ip_address elif unit.slm_host: modem_ip = unit.slm_host # Determine if measuring is_measuring = False if status and status.measurement_state: is_measuring = status.measurement_state.lower() == 'start' return templates.TemplateResponse( "partials/slm_live_view.html", { "request": request, "unit": unit, "modem": modem, "modem_ip": modem_ip, "current_status": status, "is_measuring": is_measuring } ) @router.get("/config/{unit_id}", response_class=HTMLResponse) async def get_unit_config(unit_id: str, request: Request, roster_db: Session = Depends(get_seismo_db)): """Return the HTML config form for a specific unit.""" unit = roster_db.query(RosterUnit).filter_by( id=unit_id, device_type="sound_level_meter" ).first() if not unit: raise HTTPException(status_code=404, detail="Unit configuration not found") return templates.TemplateResponse( "partials/slm_config_form.html", { "request": request, "unit": unit } ) @router.post("/config/{unit_id}") async def update_unit_config( unit_id: str, request: Request, roster_db: Session = Depends(get_seismo_db), slm_db: Session = Depends(get_slm_db) ): """Update configuration for a specific unit from the form submission.""" unit = roster_db.query(RosterUnit).filter_by( id=unit_id, device_type="sound_level_meter" ).first() if not unit: raise HTTPException(status_code=404, detail="Unit configuration not found") form = await request.form() def get_int(value, default=None): try: return int(value) if value not in (None, "") else default except (TypeError, ValueError): return default # Update roster fields unit.slm_model = form.get("slm_model") or unit.slm_model unit.slm_serial_number = form.get("slm_serial_number") or unit.slm_serial_number unit.slm_frequency_weighting = form.get("slm_frequency_weighting") or unit.slm_frequency_weighting unit.slm_time_weighting = form.get("slm_time_weighting") or unit.slm_time_weighting unit.slm_measurement_range = form.get("slm_measurement_range") or unit.slm_measurement_range unit.slm_host = form.get("slm_host") or None unit.slm_tcp_port = get_int(form.get("slm_tcp_port"), unit.slm_tcp_port or 2255) unit.slm_ftp_port = get_int(form.get("slm_ftp_port"), unit.slm_ftp_port or 21) deployed_with_modem_id = form.get("deployed_with_modem_id") or None unit.deployed_with_modem_id = deployed_with_modem_id roster_db.commit() roster_db.refresh(unit) # Update or create NL43 config so SLMM can reach the device config = slm_db.query(NL43Config).filter_by(unit_id=unit_id).first() if not config: config = NL43Config(unit_id=unit_id) slm_db.add(config) # Resolve host from modem if present, otherwise fall back to direct IP or existing config host_for_config = None if deployed_with_modem_id: modem = roster_db.query(RosterUnit).filter_by( id=deployed_with_modem_id, device_type="modem" ).first() if modem and modem.ip_address: host_for_config = modem.ip_address if not host_for_config: host_for_config = unit.slm_host or config.host or "127.0.0.1" config.host = host_for_config config.tcp_port = get_int(form.get("slm_tcp_port"), config.tcp_port or 2255) config.tcp_enabled = True config.ftp_enabled = bool(config.ftp_username and config.ftp_password) slm_db.commit() slm_db.refresh(config) return {"success": True, "unit_id": unit_id} @router.post("/control/{unit_id}/{action}") async def control_unit(unit_id: str, action: str, db: Session = Depends(get_slm_db)): """Send control command to a unit (start, stop, pause, resume, etc.).""" config = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not config: raise HTTPException(status_code=404, detail="Unit configuration not found") if not config.tcp_enabled: raise HTTPException(status_code=400, detail="TCP control not enabled for this unit") # Create NL43Client client = NL43Client( host=config.host, port=config.tcp_port, timeout=5.0, ftp_username=config.ftp_username, ftp_password=config.ftp_password ) # Map action to command action_map = { "start": "start_measurement", "stop": "stop_measurement", "pause": "pause_measurement", "resume": "resume_measurement", "reset": "reset_measurement", "sleep": "sleep_mode", "wake": "wake_from_sleep", } if action not in action_map: raise HTTPException(status_code=400, detail=f"Unknown action: {action}") method_name = action_map[action] method = getattr(client, method_name, None) if not method: raise HTTPException(status_code=500, detail=f"Method {method_name} not implemented") try: result = await method() return {"success": True, "action": action, "result": result} except Exception as e: logger.error(f"Error executing {action} on {unit_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/test-modem/{unit_id}") async def test_modem(unit_id: str, db: Session = Depends(get_slm_db)): """Test connectivity to a unit's modem/device.""" config = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not config: raise HTTPException(status_code=404, detail="Unit configuration not found") if not config.tcp_enabled: raise HTTPException(status_code=400, detail="TCP control not enabled for this unit") client = NL43Client( host=config.host, port=config.tcp_port, timeout=5.0, ftp_username=config.ftp_username, ftp_password=config.ftp_password ) try: # Try to get measurement state as a connectivity test state = await client.get_measurement_state() return { "success": True, "unit_id": unit_id, "host": config.host, "port": config.tcp_port, "reachable": True, "measurement_state": state } except Exception as e: logger.warning(f"Modem test failed for {unit_id}: {e}") return { "success": False, "unit_id": unit_id, "host": config.host, "port": config.tcp_port, "reachable": False, "error": str(e) }