diff --git a/backend/routers/projects.py b/backend/routers/projects.py index b1407de..4c94f2b 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1103,6 +1103,100 @@ async def get_project_dashboard( }) +@router.get("/{project_id}/live-stats") +async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)): + """Live SLM readings for each sound NRL in the project. + + Reads SLMM's cached per-unit status snapshots (the same source the client + portal uses) and returns one entry per active sound location. Powers the + Overview tab's live monitoring section. Internal-only, so it includes + device-health fields (battery, power source, reachability) the portal hides. + """ + import os + import asyncio + import httpx + + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + now = datetime.utcnow() + locations = ( + db.query(MonitoringLocation) + .filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.location_type == "sound", + MonitoringLocation.removed_at.is_(None), + ) + .order_by(MonitoringLocation.sort_order, MonitoringLocation.name) + .all() + ) + + # Active SLM unit per location (mirrors portal.active_unit_for_location). + def _active_unit(loc_id: str): + asg = ( + db.query(UnitAssignment) + .filter( + UnitAssignment.location_id == loc_id, + UnitAssignment.status == "active", + UnitAssignment.device_type == "slm", + or_( + UnitAssignment.assigned_until.is_(None), + UnitAssignment.assigned_until > now, + ), + ) + .order_by(UnitAssignment.assigned_at.desc()) + .first() + ) + return asg.unit_id if asg else None + + loc_units = [(loc, _active_unit(loc.id)) for loc in locations] + + slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + + async def _fetch(unit_id): + if not unit_id: + return None, "no_device" + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{slmm_base}/api/nl43/{unit_id}/status") + except Exception: + return None, "unreachable" + if r.status_code != 200: + return None, "no_data" + return (r.json() or {}).get("data") or {}, None + + results = await asyncio.gather(*[_fetch(u) for (_, u) in loc_units]) + + out = [] + for (loc, unit_id), (data, reason) in zip(loc_units, results): + entry = { + "id": loc.id, + "name": loc.name, + "unit_id": unit_id, + } + if data is None: + entry["reason"] = reason + entry["measurement_state"] = None + else: + entry.update( + { + "measurement_state": data.get("measurement_state"), + "leq": data.get("leq"), + "lp": data.get("lp"), + "lmax": data.get("lmax"), + "last_seen": data.get("last_seen"), + "battery_level": data.get("battery_level"), + "power_source": data.get("power_source"), + "is_reachable": data.get("is_reachable"), + "connection_state": data.get("connection_state"), + } + ) + out.append(entry) + + return {"status": "ok", "locations": out} + + # ============================================================================ # Project Types # ============================================================================ diff --git a/templates/projects/detail.html b/templates/projects/detail.html index b7a35b6..2f214fe 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -85,6 +85,36 @@