feat(mqtt): add device health telemetry and migrate to v2 topic spec
Two efforts that landed together because the v2 topic work extends tables the health-telemetry effort added days earlier in the same files/functions, making them impractical to separate cleanly: Health/diagnostics telemetry (schema, Jul 13-17): - New Postgres tables: device_alert_events, device_boot_events, device_ping_samples, device_diagnostics_reports, plus a `source` column on device_logs to distinguish log origins - Query/service layer in pg_mqtt.py and database/__init__.py for inserting and listing this history, plus a "latest metrics" endpoint combining most-recent diagnostics + ping RTT per device - mqtt/router.py gains list endpoints for alert/boot/ping/diagnostics history, consumed by the upcoming Health tab MQTT v2 topic migration (Sep 21): - Heartbeat payload flattened per vesper_mqtt_topic_spec_v2.md, adding rssi/free_heap/state/ok fields - Command replies move to control/ack, device-initiated events to control/reports; mqtt/client.py subscribes to the new topic set and runs a ping_loop (wired up in main.py) for RTT sampling - mqtt/logger.py and pg_mqtt.py updated to parse and persist the new payload shape alongside the legacy fields for backwards compatibility Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+111
-6
@@ -1,11 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from mqtt.models import (
|
||||
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
||||
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
|
||||
CommandListResponse, HeartbeatEntry,
|
||||
CommandListResponse, HeartbeatEntry, AlertEventEntry,
|
||||
AlertEventListResponse, BootEventListResponse, PingSampleListResponse,
|
||||
DiagnosticsReportListResponse, LatestMetricsResponse,
|
||||
LatestDiagnosticsEntry, LatestPingEntry, DeviceReportListResponse,
|
||||
)
|
||||
from mqtt.client import mqtt_manager
|
||||
import database as db
|
||||
@@ -19,6 +22,8 @@ async def get_all_device_status(
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
heartbeats = await db.get_latest_heartbeats()
|
||||
alert_events = await db.get_latest_alert_events()
|
||||
alert_by_serial = {a["device_serial"]: a for a in alert_events}
|
||||
now = datetime.now(timezone.utc)
|
||||
devices = []
|
||||
for hb in heartbeats:
|
||||
@@ -31,11 +36,14 @@ async def get_all_device_status(
|
||||
except (ValueError, TypeError):
|
||||
seconds_ago = 9999
|
||||
|
||||
alert_event = alert_by_serial.get(hb["device_serial"])
|
||||
|
||||
devices.append(DeviceMqttStatus(
|
||||
device_serial=hb["device_serial"],
|
||||
online=seconds_ago < 90,
|
||||
last_heartbeat=HeartbeatEntry(**hb),
|
||||
seconds_since_heartbeat=seconds_ago,
|
||||
last_alert_event=AlertEventEntry(**alert_event) if alert_event else None,
|
||||
))
|
||||
return MqttStatusResponse(
|
||||
devices=devices,
|
||||
@@ -43,6 +51,21 @@ async def get_all_device_status(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/latest-metrics", response_model=LatestMetricsResponse)
|
||||
async def get_latest_metrics(
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
# Fleet-wide "last known" CPU temp + ping RTT, one query each — used by
|
||||
# DeviceList to render optional columns without polling any device.
|
||||
# Uptime/firmware/RSSI don't need this: they're already in /mqtt/status.
|
||||
diag_reports = await db.get_latest_diagnostics_reports()
|
||||
ping_samples = await db.get_latest_ping_samples()
|
||||
return LatestMetricsResponse(
|
||||
diagnostics=[LatestDiagnosticsEntry(**d) for d in diag_reports],
|
||||
pings=[LatestPingEntry(**p) for p in ping_samples],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/command/{device_serial}", response_model=CommandSendResponse)
|
||||
async def send_command(
|
||||
device_serial: str,
|
||||
@@ -81,14 +104,18 @@ async def send_command(
|
||||
async def get_device_logs(
|
||||
device_serial: str,
|
||||
level: Optional[str] = Query(None, description="Filter: INFO, WARN, ERROR"),
|
||||
min_level: bool = Query(False, description="If true, level is a floor — also includes higher-severity levels"),
|
||||
search: Optional[str] = Query(None),
|
||||
source: Optional[List[str]] = Query(None, description="Filter by source: log, info. Repeat param to include several."),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
|
||||
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
logs, total = await db.get_logs(
|
||||
device_serial, level=level, search=search,
|
||||
limit=limit, offset=offset,
|
||||
device_serial, level=level, search=search, source=source,
|
||||
min_level=min_level, limit=limit, offset=offset, since=since, until=until,
|
||||
)
|
||||
return LogListResponse(logs=logs, total=total)
|
||||
|
||||
@@ -96,12 +123,14 @@ async def get_device_logs(
|
||||
@router.get("/heartbeats/{device_serial}", response_model=HeartbeatListResponse)
|
||||
async def get_device_heartbeats(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
limit: int = Query(100, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
|
||||
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
heartbeats, total = await db.get_heartbeats(
|
||||
device_serial, limit=limit, offset=offset,
|
||||
device_serial, limit=limit, offset=offset, since=since, until=until,
|
||||
)
|
||||
return HeartbeatListResponse(heartbeats=heartbeats, total=total)
|
||||
|
||||
@@ -119,6 +148,82 @@ async def get_device_commands(
|
||||
return CommandListResponse(commands=commands, total=total)
|
||||
|
||||
|
||||
@router.get("/alert-events/{device_serial}", response_model=AlertEventListResponse)
|
||||
async def get_device_alert_events(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
events, total = await db.get_alert_events(
|
||||
device_serial, limit=limit, offset=offset,
|
||||
)
|
||||
return AlertEventListResponse(events=events, total=total)
|
||||
|
||||
|
||||
@router.get("/boot-events/{device_serial}", response_model=BootEventListResponse)
|
||||
async def get_device_boot_events(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
|
||||
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
events, total = await db.get_boot_events(
|
||||
device_serial, limit=limit, offset=offset, since=since, until=until,
|
||||
)
|
||||
return BootEventListResponse(events=events, total=total)
|
||||
|
||||
|
||||
@router.get("/ping-samples/{device_serial}", response_model=PingSampleListResponse)
|
||||
async def get_device_ping_samples(
|
||||
device_serial: str,
|
||||
limit: int = Query(200, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
|
||||
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
samples, total = await db.get_ping_samples(
|
||||
device_serial, limit=limit, offset=offset, since=since, until=until,
|
||||
)
|
||||
return PingSampleListResponse(samples=samples, total=total)
|
||||
|
||||
|
||||
@router.get("/diagnostics-reports/{device_serial}", response_model=DiagnosticsReportListResponse)
|
||||
async def get_device_diagnostics_reports(
|
||||
device_serial: str,
|
||||
limit: int = Query(200, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
|
||||
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
reports, total = await db.get_diagnostics_reports(
|
||||
device_serial, limit=limit, offset=offset, since=since, until=until,
|
||||
)
|
||||
return DiagnosticsReportListResponse(reports=reports, total=total)
|
||||
|
||||
|
||||
@router.get("/reports/{device_serial}", response_model=DeviceReportListResponse)
|
||||
async def get_device_reports(
|
||||
device_serial: str,
|
||||
limit: int = Query(200, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
|
||||
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
"""Critical, unsolicited board-initiated events from control/reports
|
||||
(currently only bell_overload). History/audit only — the tablets are the
|
||||
real-time consumer of this data, not the console."""
|
||||
reports, total = await db.get_reports(
|
||||
device_serial, limit=limit, offset=offset, since=since, until=until,
|
||||
)
|
||||
return DeviceReportListResponse(reports=reports, total=total)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def mqtt_websocket(websocket: WebSocket):
|
||||
"""Live MQTT data stream. Auth via query param: ?token=JWT"""
|
||||
|
||||
Reference in New Issue
Block a user