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:
2026-09-21 18:24:36 +03:00
co-authored by Claude Sonnet 5
parent e5d556fee1
commit 7c533b9245
12 changed files with 1456 additions and 55 deletions
+111
View File
@@ -27,6 +27,7 @@ class DeviceLogEntry(BaseModel):
level: str
message: str
device_timestamp: Optional[int] = None
source: str = "log"
received_at: str
@@ -39,6 +40,10 @@ class HeartbeatEntry(BaseModel):
gateway: Optional[str] = None
uptime_ms: Optional[int] = None
uptime_display: Optional[str] = None
rssi: Optional[int] = None
free_heap: Optional[int] = None
state: Optional[str] = None # "idle" | "playing" | "paused" | "error" | "booting" (v2 firmware only)
ok: Optional[bool] = None # overall device health, independent of playback state (v2 firmware only)
received_at: str
@@ -53,11 +58,21 @@ class CommandEntry(BaseModel):
responded_at: Optional[str] = None
class AlertEventEntry(BaseModel):
id: int
device_serial: str
subsystem: str
state: str
message: Optional[str] = None
occurred_at: str
class DeviceMqttStatus(BaseModel):
device_serial: str
online: bool
last_heartbeat: Optional[HeartbeatEntry] = None
seconds_since_heartbeat: Optional[int] = None
last_alert_event: Optional[AlertEventEntry] = None
class MqttStatusResponse(BaseModel):
@@ -96,3 +111,99 @@ class DeviceAlertEntry(BaseModel):
class DeviceAlertsResponse(BaseModel):
alerts: List[DeviceAlertEntry]
class AlertEventListResponse(BaseModel):
events: List[AlertEventEntry]
total: int
class BootEventEntry(BaseModel):
id: int
device_serial: str
boot_count: Optional[int] = None
reset_reason: Optional[str] = None
is_fault: bool = False
free_heap: Optional[int] = None
crash_task: Optional[str] = None
crash_pc: Optional[int] = None
crash_exc_cause: Optional[int] = None
crash_exc_vaddr: Optional[int] = None
occurred_at: str
class BootEventListResponse(BaseModel):
events: List[BootEventEntry]
total: int
class PingSampleEntry(BaseModel):
id: int
device_serial: str
rtt_ms: int
sampled_at: str
class PingSampleListResponse(BaseModel):
samples: List[PingSampleEntry]
total: int
class DiagnosticsReportEntry(BaseModel):
id: int
device_serial: str
cpu_temp_avg: Optional[float] = None
cpu_temp_min: Optional[float] = None
cpu_temp_max: Optional[float] = None
cpu_temp_samples: Optional[int] = None
wifi_reconnect_count: Optional[int] = None
wifi_last_disconnect_reason: Optional[str] = None
wifi_last_disconnect_uptime_ms: Optional[int] = None
ota_current_version: Optional[str] = None
ota_update_available: Optional[bool] = None
ota_available_version: Optional[str] = None
ota_last_check_uptime_ms: Optional[int] = None
ota_last_error: Optional[str] = None
stack_high_water: Optional[str] = None # JSON-encoded string — see insert_diagnostics_report
bell_strikes: Optional[str] = None # JSON-encoded string, {"0": count, ...} — v2 firmware only
bell_loads: Optional[str] = None # JSON-encoded string, {"0": load, ...} — v2 firmware only
cooling_active: Optional[bool] = None # v2 firmware only
received_at: str
class DiagnosticsReportListResponse(BaseModel):
reports: List[DiagnosticsReportEntry]
total: int
class DeviceReportEntry(BaseModel):
"""A control/reports event (currently only bell_overload) — see
project-vesper's vesper_mqtt_topic_spec_v2.md. Console-side storage is
intentionally light: history/audit only, not a real-time UI surface."""
id: int
device_serial: str
report_type: str
payload: Optional[str] = None # JSON-encoded string
occurred_at: str
class DeviceReportListResponse(BaseModel):
reports: List[DeviceReportEntry]
total: int
class LatestDiagnosticsEntry(BaseModel):
device_serial: str
cpu_temp_avg: Optional[float] = None
received_at: str
class LatestPingEntry(BaseModel):
device_serial: str
rtt_ms: int
sampled_at: str
class LatestMetricsResponse(BaseModel):
diagnostics: List[LatestDiagnosticsEntry]
pings: List[LatestPingEntry]