""" 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}") async def get_unit_config(unit_id: str, db: Session = Depends(get_slm_db)): """Get configuration for a specific unit.""" config = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not config: raise HTTPException(status_code=404, detail="Unit configuration not found") return { "unit_id": config.unit_id, "host": config.host, "tcp_port": config.tcp_port, "tcp_enabled": config.tcp_enabled, "ftp_enabled": config.ftp_enabled, "ftp_username": config.ftp_username, "ftp_password": config.ftp_password, "web_enabled": config.web_enabled, } @router.post("/config/{unit_id}") async def update_unit_config(unit_id: str, config_data: Dict[str, Any], db: Session = Depends(get_slm_db)): """Update configuration for a specific unit.""" config = db.query(NL43Config).filter_by(unit_id=unit_id).first() if not config: # Create new config config = NL43Config(unit_id=unit_id) db.add(config) # Update fields if "host" in config_data: config.host = config_data["host"] if "tcp_port" in config_data: config.tcp_port = config_data["tcp_port"] if "tcp_enabled" in config_data: config.tcp_enabled = config_data["tcp_enabled"] if "ftp_enabled" in config_data: config.ftp_enabled = config_data["ftp_enabled"] if "ftp_username" in config_data: config.ftp_username = config_data["ftp_username"] if "ftp_password" in config_data: config.ftp_password = config_data["ftp_password"] if "web_enabled" in config_data: config.web_enabled = config_data["web_enabled"] db.commit() 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) }