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
+56 -6
View File
@@ -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()
+145 -17
View File
@@ -1,4 +1,5 @@
import logging
import time
import database as db
logger = logging.getLogger("mqtt.logger")
@@ -16,16 +17,23 @@ LEVEL_MAP = {
async def handle_message(serial: str, topic_type: str, payload: dict):
try:
# v2 topic set — see project-vesper's vesper_mqtt_topic_spec_v2.md.
if topic_type == "status/heartbeat":
await _handle_heartbeat(serial, payload)
elif topic_type == "status/alerts":
elif topic_type == "system/alerts":
await _handle_alerts(serial, payload)
elif topic_type == "status/info":
elif topic_type == "system/info":
await _handle_info(serial, payload)
elif topic_type == "logs":
elif topic_type == "system/logs":
await _handle_log(serial, payload)
elif topic_type == "data":
await _handle_data_response(serial, payload)
elif topic_type == "system/metrics":
await _handle_metrics(serial, payload)
elif topic_type == "control/ack":
await _handle_ack(serial, payload)
elif topic_type == "control/reports":
await _handle_report(serial, payload)
elif topic_type == "status/playback":
pass # no console-side storage needed — WS broadcast already carries it live
else:
logger.debug(f"Unhandled topic type: {topic_type} for {serial}")
except Exception as e:
@@ -35,15 +43,21 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
async def _handle_heartbeat(serial: str, payload: dict):
# Store silently — do not log as a visible event.
# The console surfaces an alert only when the device goes silent (no heartbeat for 90s).
inner = payload.get("payload", {})
# v2 heartbeat payload is FLAT — no {"status","type","payload"} wrapper,
# and field names changed: firmware_version -> fw_version, timestamp -> uptime_human.
# See vesper_mqtt_topic_spec_v2.md and mqtt-events.md for the full shape.
await db.insert_heartbeat(
device_serial=serial,
device_id=inner.get("device_id", ""),
firmware_version=inner.get("firmware_version", ""),
ip_address=inner.get("ip_address", ""),
gateway=inner.get("gateway", ""),
uptime_ms=inner.get("uptime_ms", 0),
uptime_display=inner.get("timestamp", ""),
device_id=payload.get("device_id", ""),
firmware_version=payload.get("fw_version", ""),
ip_address=payload.get("ip_address", ""),
gateway=payload.get("gateway", ""),
uptime_ms=payload.get("uptime_ms", 0),
uptime_display=payload.get("uptime_human", ""),
rssi=payload.get("rssi"),
free_heap=payload.get("free_heap"),
state=payload.get("state"),
ok=payload.get("ok"),
)
@@ -58,6 +72,7 @@ async def _handle_log(serial: str, payload: dict):
level=level,
message=message,
device_timestamp=device_timestamp,
source="log",
)
@@ -72,23 +87,136 @@ async def _handle_alerts(serial: str, payload: dict):
await db.delete_alert(serial, subsystem)
else:
await db.upsert_alert(serial, subsystem, state, payload.get("msg"))
# Append-only history — survives past the alert being resolved, used to
# answer "when was the most recent issue" even once it's cleared.
await db.insert_alert_event(serial, subsystem, state, payload.get("msg"))
async def _handle_info(serial: str, payload: dict):
event_type = payload.get("type", "")
# boot_report carries structured fields (boot_count, crash detail, etc.) —
# parsed into device_boot_events instead of flattened into a text log line,
# so the Health tab can query/chart it. Not routed through insert_log at
# all: a text summary here would just duplicate the structured row.
#
# Note: diagnostics_report used to also arrive here (F-056) but moved to
# its own system/metrics topic in the v2 spec — see _handle_metrics below.
if event_type == "boot_report":
await _handle_boot_report(serial, payload)
return
data = payload.get("payload", {})
if event_type == "playback_started":
logger.debug(f"{serial}: playback started — melody_uid={data.get('melody_uid')}")
message = f"Playback started — melody_uid={data.get('melody_uid')}"
elif event_type == "playback_stopped":
logger.debug(f"{serial}: playback stopped")
message = "Playback stopped"
else:
logger.debug(f"{serial}: info event '{event_type}'")
message = f"Info event '{event_type}'" + (f" — {data}" if data else "")
await db.insert_log(
device_serial=serial,
level="INFO",
message=message,
source="info",
)
async def _handle_data_response(serial: str, payload: dict):
async def _handle_boot_report(serial: str, payload: dict):
"""Parses the firmware's consolidated boot_report event (see
CommunicationRouter::reportBootOnce() / publishInfo()) into
device_boot_events. Like all status/info events, the report fields are
nested under "payload" — CommunicationRouter::publishInfo() wraps every
info event as { "type": ..., "payload": {...} }.
"""
data = payload.get("payload", {})
crash = data.get("crash") or {}
await db.insert_boot_event(
device_serial=serial,
boot_count=data.get("boot_count"),
reset_reason=data.get("reset_reason"),
is_fault=bool(data.get("is_fault", False)),
free_heap=data.get("free_heap"),
crash_task=crash.get("task"),
crash_pc=crash.get("pc"),
crash_exc_cause=crash.get("exc_cause"),
crash_exc_vaddr=crash.get("exc_vaddr"),
)
async def _handle_metrics(serial: str, payload: dict):
"""Parses the firmware's periodic metrics payload (see
CommunicationRouter::reportDiagnosticsIfDue() in project-vesper) into
device_diagnostics_reports. Unlike status/info events, system/metrics has
NO {"type","payload"} wrapper — the payload IS the metrics object,
flat at the top level. See vesper_mqtt_topic_spec_v2.md / mqtt-events.md.
cpu_temp is entirely absent from the firmware payload (not present as a
key at all, not present-with-nulls) when no samples were taken yet this
window — .get() on a missing "cpu_temp" key correctly falls back to {}
below, leaving every cpu_temp_* column null for that row.
"""
cpu_temp = payload.get("cpu_temp") or {}
wifi = payload.get("wifi_reconnects") or {}
ota = payload.get("ota") or {}
stack = payload.get("stack_high_water") or {}
bell_strikes = payload.get("bell_strikes") or {}
bell_loads = payload.get("bell_loads") or {}
await db.insert_diagnostics_report(
device_serial=serial,
cpu_temp_avg=cpu_temp.get("avg"),
cpu_temp_min=cpu_temp.get("min"),
cpu_temp_max=cpu_temp.get("max"),
cpu_temp_samples=cpu_temp.get("samples"),
wifi_reconnect_count=wifi.get("lifetime_count"),
wifi_last_disconnect_reason=wifi.get("last_reason"),
wifi_last_disconnect_uptime_ms=wifi.get("last_at_uptime_ms"),
ota_current_version=ota.get("current_version"),
ota_update_available=ota.get("update_available"),
ota_available_version=ota.get("available_version"),
ota_last_check_uptime_ms=ota.get("last_check_uptime_ms"),
ota_last_error=ota.get("last_error"),
stack_high_water=stack if stack else None,
bell_strikes=bell_strikes if bell_strikes else None,
bell_loads=bell_loads if bell_loads else None,
cooling_active=payload.get("cooling_active"),
)
async def _handle_report(serial: str, payload: dict):
"""Parses control/reports — critical, unsolicited board-initiated events
(currently only bell_overload). Console-side storage is intentionally
light: history/audit only. The tablets are the real-time consumer."""
report_type = payload.get("type", "")
if not report_type:
logger.warning(f"Malformed report payload from {serial}: {payload}")
return
await db.insert_report(
device_serial=serial,
report_type=report_type,
payload=payload.get("payload"),
)
logger.warning(f"Report '{report_type}' received from {serial}: {payload.get('payload')}")
async def _handle_ack(serial: str, payload: dict):
status = payload.get("status", "")
# Health-check pings (see MqttManager.ping_loop) are published directly,
# bypassing db.insert_command, so scheduled liveness checks don't clutter
# the user-facing command history. The device echoes the caller's own
# send-time ("ts", epoch-ms) back unchanged, so RTT is computed here
# without needing to correlate against any pending-command bookkeeping.
if payload.get("type") == "pong":
ts = (payload.get("data") or {}).get("ts")
if isinstance(ts, (int, float)):
rtt_ms = max(0, int(time.time() * 1000) - int(ts))
await db.insert_ping_sample(device_serial=serial, rtt_ms=rtt_ms)
return
pending = await db.get_pending_command(serial)
if pending:
cmd_status = "success" if status == "SUCCESS" else "error"
@@ -98,4 +226,4 @@ async def _handle_data_response(serial: str, payload: dict):
response_payload=payload,
)
else:
logger.debug(f"Received data response for {serial} with no pending command")
logger.debug(f"Received control/ack for {serial} with no pending command")
+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]
+111 -6
View File
@@ -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"""