RFC-0008: The MQTT-Native Status Pipeline #163
Closed
kn4oqw-clint
announced in
RFCs
Replies: 1 comment
|
This has already been implemented. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
internal/mqtt, MMDVM-Host's<name>/jsondata plane)Summary
Waypoint derives all live status from structured events — never from log
scraping. Today the hub carries a stream of events (voice start/end, link, mode)
fed only by the MQTT consumer, and the dashboard rebuilds its own view from that
stream in browser JS. This RFC adds the missing middle: a status aggregator
that folds the event stream into an authoritative, self-healing
Statusmodel;exposes it as
GET /api/statusand over a WebSocket; and republishes it,normalized, onto retained
waypoint/status/#MQTT topics for Home Assistantand other consumers. The dashboard becomes a consumer of that public API — there
are no private endpoints.
The design's load-bearing idea is self-healing by watchdog. Every "dashboard
lies" bug in the incumbents (TX timer counts forever — Pi-Star #117; stuck "M17
Listening" — #155; "Not Linked" while linked — #156/#171; green on a rejected DMR
login — #89) has the same root cause: a UI state that was entered by one log
line and needed a second log line to leave, and the second line never came (the
daemon died, the log rotated, the regex missed). The aggregator never depends on a
closing event arriving: a transmission that is not refreshed expires on a
timer, and a gateway's liveness is probed, not inferred from a message that
may never be sent. Truth is a function of time, not of a hoped-for event.
The acceptance (#5): kill or restart any gateway while watching the dashboard and
status reflects the truth within 2 s, from structured events only — provable
because there is no log-file reader anywhere in the pipeline to disable.
Motivation
Requirement #5 is a P0 and its "why" is a catalogue of incumbent
dashboard-liesbugs, all tracing to log parsing. The incumbents' status is aregex over a rolling text log: it is racy (a line can be read half-written),
lossy (rotation drops the closing line), and brittle (a daemon version bumps its
log format and the parser silently breaks). The failure mode is always a stuck
state — the dashboard latches "transmitting" or "linked" and never clears,
because clearing required a specific log line that never arrived.
Waypoint already made the first half of the fix: the status source is
MMDVM-Host's MQTT JSON data plane (
internal/mqtt), not a log. But an eventstream is not status: the dashboard currently folds the stream into state in
client JavaScript, which means (a) every browser recomputes it, (b) there is no
server-side truth to serve to a non-browser consumer (Home Assistant, #123/#141's
begged-for API), and (c) nothing expires a stuck state — a missed
endeventstrands the client's "transmitting" exactly like the incumbents' missed log line.
This RFC puts the fold on the server, makes it self-healing, and publishes the
result.
Design
The
StatusmodelA single
Statusvalue is the node's authoritative live state, held by theaggregator and served verbatim:
Everything in
Statusis derived; nothing is a secret (no passwords transit thestatus plane), so the whole value is safe to serve and to publish.
The aggregator: a pure fold + a watchdog
internal/statusprovides anAggregatorthat subscribes to the hub (the sameseam the SSE handler and the RFC-0004 persister use) and maintains the
Status:apply(status, event) statusis a pure function — every hubevent type maps to a state transition (a
rf_voice_startsetsTXandMode; a*_voice_endclears them; alinksets aNetworks[name]; amodeevent sets
Mode). Purity makes the whole state machine table-testable with noclock and no I/O.
TX.expiresAtis set on every start/refresh tonow + txTTL. A ticker (≈1 s) callsexpire(now): anyTXpast its deadlineis cleared to idle as if a
lostevent had arrived, and the cleared stateis emitted.
txTTLis the mode's transmit-timeout ceiling plus a margin (a realtransmission cannot outlive the modem's own timeout), so a stranded TX — the
daemon died mid-transmission, no
endwill ever come — self-clears within onetick of its deadline instead of counting forever (Fix the image build on a cold cache and on xz's ownership warning #117/RFC-0001: The Configuration Store #155 fixed by
construction, not by hoping for a closing event).
Status, theaggregator publishes a
statussnapshot (to the hub for the stream, and to therepublisher for MQTT). Unchanged events don't churn the topics.
The aggregator holds the only mutable copy; readers get a value copy under a
mutex, so
GET /api/statusand the WebSocket never race the fold.Gateway liveness: probed, not inferred
"Kill/restart any gateway → truth within 2 s" is about link/liveness, and the
honest, log-free source for is this gateway process alive is the supervisor that
already owns the systemd units (architecture.md), not a message the dying daemon
might not send. A liveness probe polls
systemctl is-activefor the gatewayunits on a sub-2 s cadence (the same
systemctlRunseam the apply path uses) andemits a
link-class event when a unit transitions active↔inactive. So a gatewaythat is killed shows not running within one poll (< 2 s), and one that is
restarted shows running again — both from structured supervisor state, zero log
reads. This is a distinct, truthful signal from network-link state: "gateway not
running" and "gateway running but not linked" are different rows, and the model
carries both. Per-reflector link truth (linked to which room) is filled by the
gateway/MMDVM-Host MQTT link topics as the May-2026 data plane exposes them — the
aggregator already folds
linkevents, so that is data, not new plumbing.The probe cadence is a flag (default 1 s) so the 2 s acceptance has margin and an
operator on constrained hardware can relax it.
waypoint/status/#— the normalized republishA republisher (a hub subscriber, live mode only — it needs the broker) publishes
each status change to retained MQTT topics under
waypoint/status/:Retained so a Home Assistant that (re)starts reads current state immediately with
zero YAML — the requirement's HA-friendliness, and the substrate the #9
MQTT-discovery follow-up publishes on top of (it adds discovery config topics;
this RFC owns the state topics). The topic prefix is a flag. Republishing is
best-effort: a broker hiccup never blocks the aggregator (it is downstream of the
hub, like the persister).
API: snapshot + WebSocket
GET /api/statusreturns the currentStatusas JSON — the server-sidetruth any consumer polls or renders. Behind the session wall like every route;
no secret is ever in
Status.GET /api/wsis a WebSocket (gorilla/websocket, already a transitive dep)that, on connect, sends the current
Statusthen streams every subsequent hubevent and
statussnapshot as JSON frames — the bidirectional-capable transportthe architecture names, so a client gets both the live event tail and the
derived status over one socket. The existing SSE
/api/eventsstays forbackward compatibility (the dashboard migrates incrementally); both are pure
hub subscribers, so neither is privileged.
No log scraping — structurally, not by policy
The acceptance says "verified by disabling all log file reads." Waypoint passes
this trivially: there is no log-file reader in the status path. Status flows
MQTT consumer → hub → aggregator → API/republishandsupervisor probe → hub.No component opens
/var/log. A CI grep-guard asserts the status packages importno file-log reader, so the property can't regress silently.
The status contract (test harness)
CI enforces these as release-blocking properties:
Statustransition (start sets TX+mode; end clears to idle; link toggles a network;
mode sets mode). Pure, no clock.
rf_voice_startwith no matching end,advanced past
txTTL, expires to idle on the nextexpire(now)— asserted withan injected clock, so "the timer never counts forever" is a test, not a hope.
txTTLis not expired; anormal start→end never trips the watchdog.
systemctlflipping a unit to inactive, the probe emits gateway-down and
Statusreflectsit within one poll interval; flipping back emits gateway-up. (Faked systemctl,
like the existing apply tests.)
GET /api/statusreturns the aggregator's valuebyte-stably; a WebSocket client receives an initial
Statusframe then liveframes; no secret field exists in the serialized form.
topics with the specified payloads (asserted against a fake publisher), and an
idle→idle no-op publishes nothing.
(grep-guard test), so the "structured events only" property is enforced.
Alternatives considered
bug's four consequences (every browser recomputes, no server truth for HA/API,
no expiry so a missed event strands the client). Status belongs on the server,
once, self-healing.
fragile: a hard-killed daemon may never send a will, and not every gateway
publishes one. The supervisor already knows the unit state authoritatively; the
probe is the honest source. (An MQTT last-will, when a daemon sends one, is just
another
linkevent the fold consumes — additive, not the foundation.)incumbent bug. The watchdog must be time-bounded independent of any closing
event;
txTTL= the modem's own timeout ceiling makes a stranded TXindistinguishable-in-outcome from a clean one.
and architecture.md both name WebSocket, and it is a zero-new-dependency add
(gorilla/websocket is already transitive), so the socket ships now and the
bidirectional door is open.
RFC-0004: status is a small in-memory value republished to MQTT; no second
daemon.
Open questions
linkevents today; therichness of per-network "linked to room X" depends on the gateway/MMDVM-Host
MQTT link topics the May-2026 data plane exposes. As those land they are data
the fold already accepts — this RFC does not block on them, and the liveness
probe covers the kill/restart acceptance in the meantime.
txTTLper mode. A single ceiling (the max modem timeout + margin) issimplest and safe. A per-mode TTL (DMR slot timeout vs FM timeout) is a refinement
if a mode's ceiling proves too loose; deferred.
wall today (cookie). A bearer-token path for headless API clients is the Let a rejected peer finish its request before closing on it #123/Explain every setting in place, instead of sending operators to the docs #141
"real API" follow-up, tracked with the token-auth work (RFC-0002 lineage).
HA-over-flaky-link case wants QoS 1, it is a publisher flag; not needed for the
local broker the hotspot runs.
Migrated from
docs/rfcs/0008-status-pipeline.md; the drafting history is in the git log.All reactions