fix(mqtt): treat broker-replayed retained messages as stale state
The firmware publishes status/heartbeat, system/alerts, system/info and
status/playback with retain=true. On every backend (re)connect - every
restart and every uvicorn --reload - the broker replays the last message on
each of those topics for every device that ever connected. We handled those
replays as if they had just happened:
- heartbeats: a row with received_at=now() for every device, so devices
that have been dead for months showed ONLINE for 90s after each restart
and got pinged. ~770k such rows exist locally.
- boot_report: the last boot logged again as a new reboot (the phantom
PANIC entries on the Health tab).
- alerts / other info events: logged again as new occurrences.
MQTT delivers retain=1 only for replays caused by a new subscription; live
publishes always arrive with retain=0. The flag is now passed through to the
handlers and the WS broadcast:
- heartbeat: replays are not stored. A live heartbeat is.
- {"state":"offline"} heartbeat (LWT / graceful disconnect) is no longer
stored as a sign of life. It marks the device offline immediately in
a small in-memory set (mqtt/presence.py) used by /mqtt/status and the ping
loop; a later live heartbeat clears it. Replayed offline markers also mark
offline, since a retained message is the device's last word.
- boot_report: live -> always a new boot. Replay -> stored only if it
differs from the device's latest boot row (i.e. we missed it while down).
- alerts: replay still syncs the current-alert row; history gets a row on a
live alert (even an identical repeat - faults recur) or on a replay that
changes state. Replaces the 98dd16b rule that dropped identical live alerts.
- other info events: replays are not logged.
- Frontend (DeviceList, DeviceDetail, LogsTab) ignores retained WS messages
for live updates, and flips a device offline on the offline marker instead
of marking it online.
Verified locally after a backend restart: only the 7 actually-live devices
got new heartbeat rows (none from the replays), and no boot/alert/info rows
were created.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -194,7 +194,15 @@ export default function DeviceDetail() {
|
||||
// v2 heartbeat payload is FLAT (no nested .payload wrapper) — see
|
||||
// vesper_mqtt_topic_spec_v2.md. field names: fw_version, uptime_human.
|
||||
if (msg.type !== 'status/heartbeat') return
|
||||
// Retained replay = the device's LAST heartbeat, re-sent by the broker when
|
||||
// the backend reconnects — not proof the device is alive now.
|
||||
if (msg.retained) return
|
||||
const hb = msg.payload || {}
|
||||
// LWT / graceful-disconnect marker: the device just went away.
|
||||
if (hb.state === 'offline') {
|
||||
setMqttStatus(prev => prev ? { ...prev, online: false } : prev)
|
||||
return
|
||||
}
|
||||
setMqttStatus(prev => ({
|
||||
device_serial: deviceSerial,
|
||||
online: true,
|
||||
|
||||
@@ -436,9 +436,17 @@ export default function DeviceList() {
|
||||
if (msg?.type !== 'status/heartbeat') return
|
||||
// v2 heartbeat payload is FLAT (no nested .payload wrapper) — see
|
||||
// vesper_mqtt_topic_spec_v2.md. field names: fw_version, uptime_human.
|
||||
// Retained replay = the device's LAST heartbeat, re-sent by the broker
|
||||
// when the backend reconnects — not proof the device is alive now.
|
||||
if (msg.retained) return
|
||||
const hb = msg.payload || {}
|
||||
const serial = msg.device_serial
|
||||
if (!serial) return
|
||||
// LWT / graceful-disconnect marker: the device just went away.
|
||||
if (hb.state === 'offline') {
|
||||
setMqttStatusMap(prev => prev[serial] ? { ...prev, [serial]: { ...prev[serial], online: false } } : prev)
|
||||
return
|
||||
}
|
||||
setMqttStatusMap(prev => ({
|
||||
...prev,
|
||||
[serial]: {
|
||||
|
||||
@@ -523,6 +523,9 @@ function UnifiedPanel({ sn, channel, onChannelChange, range }) {
|
||||
|
||||
const handleWsMessage = useCallback((data) => {
|
||||
if (data.device_serial !== sn) return
|
||||
// Retained replays (broker re-sending the last message after the backend
|
||||
// reconnects) are old events, not live ones.
|
||||
if (data.retained) return
|
||||
if (data.type === 'system/logs' || data.type === 'system/info') {
|
||||
const entry = {
|
||||
_kind: 'log',
|
||||
|
||||
Reference in New Issue
Block a user