Skip to content

Feature: Add live channel-health metrics to device displays - #3332

Draft
usrflo wants to merge 14 commits into
meshcore-dev:devfrom
usrflo:feature/channel-stats
Draft

Feature: Add live channel-health metrics to device displays#3332
usrflo wants to merge 14 commits into
meshcore-dev:devfrom
usrflo:feature/channel-stats

Conversation

@usrflo

@usrflo usrflo commented Aug 31, 2026

Copy link
Copy Markdown

Motivation

The statistics a MeshCore device reports to the app are cumulative lifetime totals - they say nothing about the current situation. Channel stats over a short time window let a user quickly check the health of the mesh at their location.

This adds three small, windowed channel-health metrics rendered directly on the device display (companion ui-new/ui-tiny and simple_repeater) and exposed via the stats-radio JSON.

channel-stats

The three metrics

Line Question it answers Window What feeds it
CH free How much airtime is free? ~5 s own TX + mid-receive + RSSI above the quiet-channel reference (see below) with a fixed +15 dB margin, sampled at least every 50 ms
RX ready How much of the time was the radio actually in RX mode? ~5 s isInRecvMode() sampling in the radio loop - catches duty-cycle deafness, blocking code, sleep misconfig
RX quality Of the packets that should decode, how many did? ~10 min good vs total decodes; CRC failures only counted when SNR ≥ per-SF threshold + 3 dB guard, and reported as 0 % while reception is jammed dead (see below)

All three render as uniform health bars. A radio that cannot measure them (e.g. ESP-NOW builds) reports no data, and the UI shows "-- %" with a dimmed bar instead of a false "all healthy".

Sustained interference

The channel-busy verdict needs a reference for "quiet". Using the adapted noise floor directly has a failure mode: a floor that tracks a sustained interferer hides that interferer behind its own margin, so a jammed channel reads as free. CH free therefore measures against a quiet-channel reference: the 10th percentile of the last 64 published noise-floor values (covering several minutes), absolutely capped at −100 dBm. A sustained broadband interferer keeps CH free near 0 % for as long as it is present — even for hours — and the reference recovers on its own within minutes once the channel is quiet again. During warm-up (first 8 blocks after boot) the current noise floor is used as reference.

RX quality cannot drop on its own when reception dies: a jammed channel produces no decode attempts at all, so the windowed ratio would freeze at its last healthy value. It therefore reports 0 % when ambient energy stayed above the quiet reference for ≥ 80 % of the last 5 s and no decode attempt occurred for 2 minutes — meaning reception is dead, not merely "no traffic". While packets still decode through the interference, the honest good/total ratio is shown; genuine quiet stays "-- %".

This is display-only: LBT (interference_threshold, CAD, resend deferral) keeps using the adapted noise floor. The verdicts reuse the existing ≥ 50 ms RSSI poll — no extra SPI traffic; the percentile is recomputed once per ~3.2 s calibration block.

Field observation (site with an intermittent broadband interferer ~37 dB above the −113 dBm ambient): while present, CH free reads 0 % and RX quality falls to 0 % within ~2 min, with the noise-floor row showing −76; once it disappears, all three rows recover within minutes.

stats-radio JSON

Short keys util / deaf / good / tot alongside the existing chan_util_pct / rx_deaf_pct / rx_good / rx_total (the reply buffer is 160 bytes).

Testing

  • mcsim end-to-end: utilization/deafness rise with traffic, good == tot in clean runs, JSON reply ~106 B
  • Builds: Heltec_v3_repeater, RAK_4631_repeater, LilyGo_T-Echo_Card, Heltec_t1 companion, WioTrackerL1 companion
  • Hardware field testing on a WioTrackerL1 companion (observation above) and Heltec V3

usrflo and others added 14 commits August 28, 2026 06:32
…or rate)

Three ~5s windowed metrics sampled in RadioLibWrapper::loop():
- channel utilization: own TX, in-progress reception, or RSSI above
  noise floor + fixed 15dB margin (rate-limited to one poll / 50ms)
- RX deafness: fraction of time the radio is not in RX mode
- RX error rate: share of reception attempts with CRC errors

Exposed via mesh::Radio virtuals (default 0 = good/unavailable),
appended to the stats-radio JSON, and approximated in the simulator
by SimRadio (airtime-counter deltas + recv_mode_ wall time).

Co-Authored-By: Claude <noreply@anthropic.com>
RADIO/status pages gain three windowed indicators in positive framing
(full bar = good, warning colour below threshold): 'CH frei'
(100 - utilization, warn <50%), 'RX-bereit' (100 - deafness, warn <80%)
and 'RX-Guete' (100 - RX error rate, warn <90%), drawn with the
battery-indicator bar pattern. On the 128x64 companion RADIO page the
static TX-dBm label makes room; noise floor moves beside BW/CR.

Co-Authored-By: Claude <noreply@anthropic.com>
…label

- 'CH frei'/'RX-bereit'/'RX-Guete' -> 'CH free'/'RX ready'/'RX quality'
  (rest of the UI is English; 'coverage' avoided - it means network
  reach in mesh terms, not RX availability)
- pin the bar to the display's right edge and right-align the percent
  value just before it, so neither shifts when the value width changes
  (100% vs 9%)
- rename the cryptic 'Q:' companion label to 'RXQ'

Co-Authored-By: Claude <noreply@anthropic.com>
The old percent divided CRC failures by good decodes only, so a
window with at least one good packet and as many failures showed a
permanent 0% (and wrapped arbitrarily beyond 100). Feed the window
with ALL attempts (decodes + failures) instead and surface counts:

- displays now show 'RX quality good/total' (e.g. 23/24) - the
  corruption share AND the traffic heard in the ~5s window (0/0 = quiet)
- new mesh::Radio::getRxQualityCounts(good,total) replaces
  getRxErrorRatePct(); stats-radio JSON derives rx_err_pct from the
  counts and adds rx_good/rx_total
- initialize n_recv_errors in the RadioLibWrapper ctor (was read as a
  garbage snapshot baseline in begin(), injecting one bogus delta)

Co-Authored-By: Claude <noreply@anthropic.com>
advance() only rolled a bucket when a single inter-call gap reached 1000ms.
Both callers tick far faster (RadioLibWrapper::loop / SimRadio::loop, every
main-loop pass), so elapsed sub-second time was silently dropped, cur_ev
accumulated forever and counts() reported a lifetime total instead of the
~5s window (HW: RX quality counts never reset; JSON rx_err_pct denominator
grew unbounded). Accumulate elapsed ms into cur_ms and roll per completed
second, like WindowedPercent's cur_total. WindowedPercent itself was correct.

Co-Authored-By: Claude <noreply@anthropic.com>
WindowedCountedRatio moves from 5x1s to 60x10s buckets (~10 min): packet
counts on a quiet mesh need minutes to become statistically meaningful
(5 s often showed only 0/0 or 1/2). The time-based utilization/deafness
metrics stay at ~5 s.

- counts() extrapolates to the full window while it fills after
  construction/clear (events per observed time x window length), so the
  number has 10-min scale immediately and converges as the window fills.
- dt cap raised to the full window: long stalls/deep sleep age the
  window in wall time; a stall spanning the whole window drops the stale
  partial bucket instead of baking it into the oldest surviving bucket.
- clear() added; resetStats() now re-stamps the delta bases and clears
  the window (zeroing counters alone underflowed the next loop() delta
  and injected a garbage spike into one bucket - 5 s visible before,
  up to 10 min now).

Co-Authored-By: Claude <noreply@anthropic.com>
…tions)

A node with a well-placed antenna always hears distant stations whose
signals cannot decode - those CRC failures are physics, not channel
health. A failure now enters the RX-quality window only if its SNR was
at/above the per-SF decode threshold + 3 dB guard (snr_threshold table,
hoisted above recvRaw): 'should have decoded, but didn't' = collision/
interference verdict on this channel. Weak failures drop out of both
numerator and denominator of the window.

The packet-status SNR stays latched after a failed readData (readData
clears IRQ/FIFO state, not packet status - verified across SX126x/
SX127x/LR11x0/LR2021); gating is limited to CRC/header errors (-7/-24),
whose SNR is meaningful. Raw n_recv_errors keeps counting every failure
(JSON recv_errors, binary stats); a new n_recv_errors_strong feeds the
window deltas in loop(). Optional per-failure decision logging behind
MESH_DEBUG_RXQ.

Co-Authored-By: Claude <noreply@anthropic.com>
…ood/total")

The percentage scans like the bar rows above it; the counts keep showing
sample size and traffic level. Quiet stays plain "0/0" (no data, no
verdict); if very large counts would collide with the label on a narrow
display the percentage is dropped, keeping the counts. Value turns
warning-coloured below 80%, like the RX-ready row.

Co-Authored-By: Claude <noreply@anthropic.com>
All three channel-health lines now follow the same pattern: label, "NN%"
value, progress bar (warn colour below 50%/80%/80%). The good/total counts
leave the display - they remain available via the stats-radio JSON
(rx_good/rx_total). A quiet window (no decodes yet) renders "--%" with an
empty, dimmed bar instead of a verdict; drawHealthBar gains a no_data flag
for that (default off, so the other rows are unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
…safety

Correctness:
- stats-radio JSON overflowed the fixed 160-byte CLI reply buffers once
  counters grew (worst case ~198 bytes). New keys shortened (util/deaf/
  good/tot), legacy fields untouched; worst case now ~151 bytes. The
  derivable err-pct is no longer printed separately.
- loop()'s per-iteration isReceivingPacket() call erased HEADER_ERR
  (CustomSX1262::isReceiving hdrErr branch) before readData() classified
  the packet, so header-damaged receptions were counted as good decodes
  and garbage entered the mesh parse path. The busy verdict is now
  sampled on the 50 ms tick and never while STATE_INT_READY is set.
- RXQ relevance test trusted getLastSNR() before any packet status was
  latched (reads the 0 dB reset value, which passes every threshold):
  gated on _rx_snr_latched.
- _cur_busy no longer holds a stale verdict while the radio is out of RX.
- CAD dwells (blocking, radio in standby) are attributed to the deafness
  window in isChannelActive(), where they happen, instead of vanishing.
- resetStats() now clears all three windows (WindowedPercent::clear()
  added), so a stats reset yields a consistent snapshot.
- New metric members get in-class initializers (ctor/begin never set
  them; only .bss zero-init of globals saved them); dead _last_err_cnt
  removed (written twice, never read).
- ESPNOW companions inherited the 0-defaults as full "all healthy" bars:
  hasChannelHealth() + getRxQualityPct() added to mesh::Radio; UIs render
  real no-data bars on radios that measure nothing.
- ui-new RADIO page shows TX power again (folded into the FQ/SF row).

Efficiency: busy sampling costs 2 SPI transactions per 50 ms tick
(~0.1% CPU) instead of one per kHz main-loop iteration; warm-up
extrapolation (two 64-bit divides per render, ratio-invariant) dropped.

Simplification: drawHealthBar unified into DisplayDriver next to the
other shared draw helpers (3 drifted copies removed); RX-quality
percentage math lives in getRxQualityPct() instead of 4 call sites;
WindowedCountedRatio no longer restates its default template args.

Co-Authored-By: Claude <noreply@anthropic.com>
drawHealthBar(..., rxq_pct, ..., !radio->getRxQualityPct(rxq_pct)) read
rxq_pct by value (3rd argument) while getRxQualityPct() wrote it through
its reference (5th argument) in the same call. The two accesses are
unsequenced - undefined behavior - and with -O2 the compiler copied the
by-value argument before running the virtual call, so the bar rendered
the pre-call 0 whenever the window held data ('--%' only while empty).

HW symptom matched exactly: RX quality stuck at 0% while packets decoded
normally; the pre-bar version (4111e11), which computed the percentage
before the draw call, showed ~90%. Sequence the fetch explicitly in all
three UIs.

Co-Authored-By: Claude <noreply@anthropic.com>
HW testing at a site with a real broadband interferer (~-76 dBm ambient
vs -113 quiet, zero decodes while it is active) showed both health
metrics lying exactly when reception was dead: CH free stayed 100%
(energy measured against the adapted floor, which rises with the jam)
and RX quality froze at its last healthy ratio (a jammed channel
produces zero header-valid IRQs = zero window events).

- CH free now measures against busyRefFloor(): the P10 of recently
  published noise-floor values (ring of 64 blocks), absolutely capped
  at -100 dBm. An interferer drives utilization toward 100% for as long
  as it lasts - multi-hour jammers included (the cap) - and the
  reference recovers on its own once quiet blocks return.
- RX quality reports 0% instead of a stale ratio while ambient energy
  sits far above the quiet floor (>=80% of the last ~5 s) AND no decode
  attempt happened for 2 min.
- Display-only: LBT / interference_threshold semantics unchanged (they
  keep using the adapted _noise_floor).

Co-Authored-By: Claude <noreply@anthropic.com>
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