Files
terra-view/app/slm/dashboard.py

318 lines
11 KiB
Python

"""
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)
}