Skip to content

feat(mqtt): report slot connection health so an outage is visible - #61

Merged
agessaman merged 7 commits into
observer-firmware-devfrom
feat/mqtt-connection-health
Sep 26, 2026
Merged

agessaman merged 7 commits into
observer-firmware-devfrom
feat/mqtt-connection-health

Conversation

@agessaman

@agessaman agessaman commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

A slot that cannot reach its broker cannot report its own outage, and the existing publish counters cannot show one either: a slot that never connects never publishes, so its ok freezes and its err stays 0 for the whole outage. This carries connection health on every healthy slot's status message and in the CLI.

Rebased from the soak-rig branch (closed 2026-09-26) onto current observer-firmware-dev, adapted to the lazy ensureSlotClient() / ClientState rewrite and the shared JSON scratch buffer.

CLI

  • get mqtt.stats: down=<n> (ready slots not connected, omitted while 0; slots in wait are excluded) and a trailing ! on each disconnected sN=ok/err entry.
  • mqttN.diag: cf:<n> (after the error tail, before filter:) — connect attempts that never reached onConnect, plus connect()/reconnect() calls that failed outright.

Status payload stats (new keys)

  • heap_largest — largest free internal block; the leading indicator for whether the next TLS handshake (one contiguous 16 KiB block) can succeed. A trend, sampled at publish time.
  • mqtt_slots_up, mqtt_slots_total — up < total is the signal to alert on. total counts enabled slots that are ready (not waiting on a token/IATA/credential), so an unconfigured slot is not a standing alert.
  • mqtt_outage_secs — longest current outage of known duration; only emitted while something is down.
  • mqtt_connect_failures (summed over all slots, monotonic within a boot), mqtt_slots_breaker — only emitted when non-zero, so a healthy board's payload does not grow.

Buffer: STATUS_JSON_BUFFER_SIZE 768 → 1024. At real field maxima the status doc measured 762/768 before these fields and 851 after; overflow is silent (status stops entirely). Status serializes into the shared 2048 B scratch buffer, so no extra RAM except +256 B of bridge-task stack in the PSRAM-allocation-failed fallback.

Review

Reviewed by Codex and opencode (space-bunny-free) after the rebase; fixes are in the last two commits:

  • cf gate reworked: a per-slot _slot_attempt_pending flag is raised before connect()/reconnect() (the event task can deliver a fast failure before the call returns), cleared by onConnect, an ignored late CONNECTED, a failed start, or stopSlotClient(). A disconnect counts only if the flag is set and the slot is not connected, so a renewal/corrected-clock bounce whose softDisconnect() times out does not charge the old session's late DISCONNECTED to the new attempt.
  • A connect() on a stopped client that fails outright (buffer/task allocation) is counted via a bridge-task-only start_failures. A reconnect() rejected with ESP_FAIL because an attempt is already in progress is not counted — hardware testing caught that miscount (last commit).
  • Zero-valued counters no longer create an empty "stats":{}.

Second opencode pass (fixes in 7d0a602b):

  • Slots waiting on config were counted in mqtt_slots_total/down=, a permanent false alert on a node with an unfinished MeshRank slot. Now filtered with isSlotReady(). (opencode proposed isSlotEnabledAndAttempted(); that would hide a slot whose first connect() fails on low heap, the case this PR exists for.)
  • mqtt_connect_failures dropped when a failing slot was disabled; it now sums all slots.
  • cf: moved after the error tail so a clamped 160 B diag reply loses the count, not the TLS/refusal detail.
  • A failed start clears the attempt flag instead of restoring a possibly stale one.
  • Outage comments corrected: the clock starts at a slot's first DISCONNECTED, so never-connected failing slots do get mqtt_outage_secs.
  • Rejected: the claim that a 31-control-char node name overflows the buffer. ArduinoJson writes control bytes raw, so that case measures 878 B vs 909 B for 31 quotes (the tested worst case).

Known and accepted:

  • get mqtt.stats is bounded at 160 bytes. With 5+ active slots and 8-digit counters during an outage, the added down=/! bytes can clip the last sN= entry — the per-slot list was already what gets clamped first (same as filt=). Complete entries still match a search-style s(\d)=(\d+)/(\d+); a fully anchored per-token parser would not.
  • collectConnHealth() reads callback-written scalars without synchronization — same pattern as existing connected/disconnect_count; a snapshot can be transiently inconsistent by one event.
  • stats is now present on every bridge status publish (health keys are always supplied). Additive only.
  • On PSRAM builds the 1024 B fallback array is part of the status functions' stack frame whether or not it is used (measured entry a1, 0x640 = 1600 B on V4, 8192 B task stack).

Test plan

  • pio test -e native — 549/549 (re-run after 7d0a602b)
  • Heltec_v3_repeater_observer_mqtt (non-PSRAM) builds
  • heltec_v4_repeater_observer_mqtt (PSRAM) builds (re-run after 7d0a602b)
  • Heltec V4 (PSRAM, 5 slots), 25-min outage: slot 4 pointed at a closed port, slot 5 at a local mosquitto to capture status. down=1 and s4=0/0! throughout; cf tracked every refused attempt exactly (2 → 4 → 8, equal to dc); breaker tripped at the 3rd failure at max backoff and the status published that second carried mqtt_slots_breaker: 1. Captured status: mqtt_slots_up 4 / total 5, mqtt_outage_secs 284 → 584 → 1484, mqtt_connect_failures 5 → 8, heap_largest present. Healthy baseline before and after: no down=, no !, no cf, no outage/failure/breaker keys.
  • Heltec V3 (non-PSRAM, 2 slots): same outage on slot 2 — down=1, s2=0/0!, cf:5 after 5 refused attempts (including the initial start). A capped-off slot 3 is correctly not counted as down.
  • Live reconfigure (V3): three back-to-back reconfigures of a connected slot; rejected reconnect() calls whose in-flight attempt then connected are not counted, and the one genuine failure (WebSocket upgrade error to the broker, retried 13 s later) is.
  • Hardware re-verify of 7d0a602b (both boards flashed with v1.17-connhealth-7d0a602b):
    • V4: slot 5 set to meshrank with no token → 5: meshrank (wait), get mqtt.stats shows no down= (previously would have read down=1).
    • V4: slot 5 pointed at a closed port → down=1, s5=0/0!; diag mqtt5: disc, dc:3, first_disc:101s, connect failed (0x8004), sock:104, 54s ago, cf:3 — cf now after the error tail and equal to dc.
    • V3: slot 2 set to tokenless meshrank → slot 2 wait, capped slot 3 activates and connects, no down=.
    • Both boards restored to their original presets and confirmed all-ok with no down=/!/cf.
    • Status-payload capture was not repeated for this commit; mqtt_slots_total uses the same readiness filter as down=.
  • Low-heap board: confirm cf moves while a slot cannot get its TLS buffers (not reproduced; TLS buffers are allocated on the esp-mqtt task, so that failure arrives as a DISCONNECTED event, which the event path counts)

A slot that cannot establish TLS reported nothing. The per-slot counters
track publish successes and failures, and a slot that never connects never
attempts a publish -- so its ok count freezes and its error count stays at
zero for the entire outage. Observed on hardware: a slot sat dead for over
five hours while `sN=` showed 0 errors and the published status payload
carried no indication at all.

Two surfaces gain the missing signal:

- `get mqtt.stats` gains `down=<n>` (omitted when zero, like `filt=`) and
  marks each disconnected slot with a trailing `!`. The marker goes after
  the counts so existing "s(\d)=(\d+)/(\d+)" parsers keep matching.
- The published status payload gains `mqtt_slots_up`, `mqtt_slots_total`,
  `heap_largest`, and `mqtt_outage_secs`. These ride on every healthy slot's
  status message, because the slot that is down cannot report itself.

`mqtt_slots_up < mqtt_slots_total` is the condition to alert on.
`mqtt_outage_secs` is detail: it is present only when the outage has a known
start, so its absence does not imply health -- a slot that has never
connected since boot has no outage start time.

`heap_largest` is included because free heap alone does not predict a failed
connection. A slot needs one contiguous 16,384-byte block for its TLS record
buffers; measured immediately before an attempt this value predicted the
outcome in every observed case, while total free heap was 85 KB throughout a
multi-hour outage. A low value with every slot up is normal rather than a
fault -- established sessions already hold their buffers.

Publish-error semantics are unchanged: connect failures are not folded into
them.

Adds host coverage for the new fields, for the outage key's presence rule,
and for worst-case payload size. That last one matters because
serializeComplete() returns 0 and clears the buffer on overflow and the
bridge publishes only a positive length, so exceeding the 768-byte status
buffer would silently stop status publication -- the same blindness this
change exists to remove.
The worst-case test added with the connection-health fields did not model
production maxima -- it used a 28-byte model and a 24-byte firmware string,
while MQTTBridge permits _origin[32], _device_id[65], _board_model[64],
_firmware_version[64] and derives a 63-byte client version. isValidName()
rejects [ ] / \ : , ? * but permits '"', so a 31-character node name of
quotes is legal and every quote escapes to two bytes.

Measured at those real maxima:

  without connection-health fields   762 bytes
  with connection-health fields      851 bytes
  buffer                             768 bytes

So the 768-byte buffer already had only six bytes of headroom before this
work, and the four new fields overflow it. Overflow is silent:
serializeComplete() returns 0 and clears the buffer, and the bridge
publishes only a positive length, so a device would simply stop publishing
status with no error anywhere -- the same blindness the connection-health
fields exist to remove.

Raises STATUS_JSON_BUFFER_SIZE to 1024 and corrects the test to use the
actual maxima. Status serializes into the shared 2048-byte publish scratch
buffer, so this costs no extra RAM; only the PSRAM-allocation-failed stack
fallback grows by 256 bytes. A static_assert keeps the status ceiling within
the shared buffer.

Found by adversarial review of the preceding commit.
Two gaps remained after the first connection-health commit.

A slot retrying into a heap that cannot satisfy its TLS allocation moved no
counter at all. disconnect_count rose, but it also rises for dropped live
sessions, so it could not separate "actively failing to connect" from "was
connected, dropped once". Adds a per-slot connect_failures, incremented in
onDisconnect when the event arrives while the client is Starting -- meaning
it ended an attempt that never reached onConnect. Late events from a stopped
or quarantined client, and soft-disconnects of a live session, are in other
states and are not miscounted.

A slot parked by the circuit breaker also looked identical to one still
retrying, despite being materially worse: the breaker only probes every 30
minutes, so recovery is far slower and an operator may need to intervene.

Surfaces both:
- `mqttN.diag` gains `cf:<n>` beside the existing `dc:<n>`.
- the status payload gains `mqtt_connect_failures` (summed across slots) and
  `mqtt_slots_breaker` (count of tripped slots), each emitted only when
  non-zero so a healthy board's payload does not grow.

The counter is what distinguishes a retrying slot from a quiet one: while a
slot is down its publish counters cannot move by definition, so this is the
only value that shows the device is still trying.

Both suggested by adversarial review.
The Starting-state gate raced the event task: client_state becomes Starting
only after connect()/reconnect() returns, so a fast failure could deliver
DISCONNECTED first and go uncounted, while a deliberate stop of an attempt in
flight was counted as a failure. A per-slot flag is now raised before the call
and cleared by onConnect, a failed start, or stopSlotClient().

Also stops zero-valued connect_failures/slots_breaker from creating an empty
stats object.
connect()/reconnect() returning an error started no attempt, so no event
arrived and the failure went uncounted -- the low-heap case the counter exists
for. Counted now in a bridge-task-only start_failures, summed into cf: and
mqtt_connect_failures.

A renewal or corrected-clock bounce whose softDisconnect() times out gets its
old session's DISCONNECTED after the new attempt raised the flag. That session
is still marked connected until the handler runs, so the gate now also
requires !connected. An ignored late CONNECTED clears the flag too.

cf: no longer depends on dc being non-zero. Comments corrected for the
PSRAM-fallback stack cost and heap_largest's sampling point; the worst-case
test now models 6 slots.
reconnect() on a started client returns ESP_FAIL while an attempt is already
in progress. On a Heltec V3, a live reconfigure hit exactly that and the
attempt then connected, yet cf read 1. Count start_failures only when
connect() on a stopped client fails, and restore the attempt flag to its
prior value on any failed call so the in-progress attempt is still judged by
its own outcome.
A slot waiting on a token/IATA/credential stays enabled but never attempts a
connection, so it held mqtt_slots_up below mqtt_slots_total (and down= at 1)
permanently -- a standing false alert on a node that is merely unconfigured.
slots_total and down= now count only ready slots, matching the CLI's "wait".

mqtt_connect_failures summed only enabled slots, so disabling a failing slot
made it drop. It now sums every slot, monotonic within a boot.

cf: moves after the error tail in mqttN.diag so a clamped 160-byte reply
loses the count rather than the refusal/TLS detail. A failed start now clears
the attempt flag instead of restoring a possibly stale one. Outage comments
corrected: the clock starts at a slot's first DISCONNECTED, not first connect.
@agessaman
agessaman marked this pull request as ready for review September 26, 2026 20:55
@agessaman
agessaman merged commit 1e23d7c into observer-firmware-dev Sep 26, 2026
1 check passed
@agessaman

Copy link
Copy Markdown
Owner Author

Post-merge status-payload capture on the Heltec V4 (`v1.17-connhealth-7d0a602b`, local mosquitto, 1-min status interval for the test):

Scenario `stats` health fields
All 5 slots healthy `heap_largest`, `mqtt_slots_up:5`, `mqtt_slots_total:5`, no outage/failure/breaker keys
Slot 5 = meshrank with no token (`wait`) `mqtt_slots_up:4`, `mqtt_slots_total:4`, no `down=` in `get mqtt.stats`
Slot 5 → closed port `mqtt_slots_up:4`, `mqtt_slots_total:5`, `mqtt_outage_secs` 5 → 39 → 99 → 159, `mqtt_connect_failures` 1 → 2 → 3 → 4; diag `…sock:104, 58s ago, cf:4`

Board restored to its original presets and 5-minute interval afterwards.

@agessaman
agessaman deleted the feat/mqtt-connection-health branch September 26, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant