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:
+56
-6
@@ -1,12 +1,19 @@
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Set
|
||||
import paho.mqtt.client as paho_mqtt
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger("mqtt.client")
|
||||
|
||||
PING_INTERVAL_SECONDS = 60
|
||||
# Only devices heard from within this window get pinged — no point spending
|
||||
# broker traffic/RTT samples on a device that's already known offline; its
|
||||
# heartbeat-derived "online" state will already reflect that on the console.
|
||||
PING_ONLINE_WINDOW_SECONDS = 90
|
||||
|
||||
|
||||
class MqttManager:
|
||||
"""Singleton MQTT client manager."""
|
||||
@@ -61,12 +68,17 @@ class MqttManager:
|
||||
if reason_code == 0:
|
||||
self._connected = True
|
||||
logger.info("MQTT connected, subscribing to topics")
|
||||
# v2 topic set — see vesper_mqtt_topic_spec_v2.md in the firmware repo.
|
||||
# control/command is inbound-to-device only; the console never subscribes to it.
|
||||
client.subscribe([
|
||||
("vesper/+/data", 1),
|
||||
("vesper/+/control/ack", 1),
|
||||
("vesper/+/control/reports", 1),
|
||||
("vesper/+/status/heartbeat", 1),
|
||||
("vesper/+/status/alerts", 1),
|
||||
("vesper/+/status/info", 0),
|
||||
("vesper/+/logs", 1),
|
||||
("vesper/+/status/playback", 1),
|
||||
("vesper/+/system/alerts", 1),
|
||||
("vesper/+/system/info", 1),
|
||||
("vesper/+/system/logs", 0),
|
||||
("vesper/+/system/metrics", 0),
|
||||
])
|
||||
else:
|
||||
logger.error(f"MQTT connection failed: {reason_code}")
|
||||
@@ -132,10 +144,48 @@ class MqttManager:
|
||||
if not self._client or not self._connected:
|
||||
return False
|
||||
|
||||
topic = f"vesper/{device_serial}/control"
|
||||
payload = json.dumps({"cmd": cmd, "contents": contents})
|
||||
topic = f"vesper/{device_serial}/control/command"
|
||||
payload = json.dumps({"v": 2, "cmd": cmd, "contents": contents})
|
||||
result = self._client.publish(topic, payload, qos=1)
|
||||
return result.rc == paho_mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
async def ping_loop(self):
|
||||
"""Periodically pings every recently-online device with a client
|
||||
timestamp so mqtt/logger.py::_handle_data_response can compute RTT
|
||||
from the echoed pong. Deliberately bypasses db.insert_command — this
|
||||
is a background health check, not a user-initiated command, and
|
||||
shouldn't clutter the Control tab's command history.
|
||||
|
||||
Only available on RTC-equipped firmware builds (see API Reference —
|
||||
ping is compiled out on agnus/agnus-mini). A pong simply never
|
||||
arrives for those devices, so no ping-latency samples accumulate for
|
||||
them; the Health tab handles an empty series as "unsupported".
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(PING_INTERVAL_SECONDS)
|
||||
try:
|
||||
await self._ping_online_devices()
|
||||
except Exception as e:
|
||||
logger.error(f"Ping loop error: {e}")
|
||||
|
||||
async def _ping_online_devices(self):
|
||||
import database as db
|
||||
heartbeats = await db.get_latest_heartbeats()
|
||||
now = time.time()
|
||||
for hb in heartbeats:
|
||||
try:
|
||||
from datetime import datetime
|
||||
received = datetime.fromisoformat(hb["received_at"])
|
||||
age = now - received.timestamp()
|
||||
except (ValueError, TypeError, KeyError):
|
||||
continue
|
||||
if age > PING_ONLINE_WINDOW_SECONDS:
|
||||
continue
|
||||
self.publish_command(
|
||||
device_serial=hb["device_serial"],
|
||||
cmd="ping",
|
||||
contents={"ts": int(now * 1000)},
|
||||
)
|
||||
|
||||
|
||||
mqtt_manager = MqttManager()
|
||||
|
||||
Reference in New Issue
Block a user