From 08f8be384efeda080b12a7db906983d557db766b Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Thu, 27 Aug 2026 21:35:01 +0000 Subject: [PATCH 01/14] Add windowed channel-health metrics (utilization, RX deafness, RX error 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 --- src/Dispatcher.h | 9 +++ src/helpers/StatsFormatHelper.h | 10 ++- src/helpers/WindowedPercent.h | 89 +++++++++++++++++++++++ src/helpers/radiolib/RadioLibWrappers.cpp | 41 +++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 13 ++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src/helpers/WindowedPercent.h diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec..d4f3564ad4 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -63,6 +63,15 @@ class Radio { virtual int getNoiseFloor() const { return 0; } + /** + * \brief windowed channel-health metrics over the last ~5 observed seconds. + * All three use "0 = good" semantics; default 0 so radios that do + * not implement them (e.g. ESPNOW) degrade gracefully. + */ + virtual uint8_t getChannelUtilizationPct() { return 0; } // % of time the channel was busy + virtual uint8_t getRxDeafnessPct() { return 0; } // % of time the radio was NOT in RX + virtual uint8_t getRxErrorRatePct() { return 0; } // % of reception attempts with CRC errors + virtual void triggerNoiseFloorCalibrate(int threshold) { } virtual void setCADEnabled(bool enable) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133e9..5a6f5eb5c7 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,13 +24,17 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { - sprintf(reply, - "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}", + sprintf(reply, + "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," + "\"chan_util_pct\":%u,\"rx_deaf_pct\":%u,\"rx_err_pct\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), driver.getLastSNR(), total_air_time_ms / 1000, - total_rx_air_time_ms / 1000 + total_rx_air_time_ms / 1000, + radio->getChannelUtilizationPct(), + radio->getRxDeafnessPct(), + radio->getRxErrorRatePct() ); } diff --git a/src/helpers/WindowedPercent.h b/src/helpers/WindowedPercent.h new file mode 100644 index 0000000000..c20a46550c --- /dev/null +++ b/src/helpers/WindowedPercent.h @@ -0,0 +1,89 @@ +#pragma once + +#include + +/** + * \brief Windowed percentage of "active" time over the last ~5 observed + * seconds, kept as a ring of five 1-second buckets of active + * milliseconds. Integer-only and millis-delta driven, so it is + * independent of the loop() call rate. ~32 bytes RAM. + */ +class WindowedPercent { + uint32_t buckets[5]; // completed 1s buckets: active ms in each (each observed exactly 1000 ms) + uint32_t cur_active; // current (partial) second: active ms + uint16_t cur_total; // current second: observed ms (0..1000) + uint8_t oldest; // index of oldest bucket (next to overwrite) + uint8_t filled; // number of completed buckets (grows to 5) + uint32_t last_ms; // stamp of previous add() +public: + WindowedPercent() : cur_active(0), cur_total(0), oldest(0), filled(0), last_ms(0) { + for (int i = 0; i < 5; i++) buckets[i] = 0; + } + + // Attribute 'active_ms' of the time elapsed since the previous call. + void add(uint32_t now, uint32_t active_ms) { + uint32_t dt = now - last_ms; last_ms = now; + if (dt > 1000) dt = 1000; // long stall: count at most 1s of the last state + if (active_ms > dt) active_ms = dt; + while (dt > 0) { // split across the 1s bucket boundary + uint32_t space = 1000 - cur_total; + uint32_t take = (dt < space) ? dt : space; + cur_total += take; dt -= take; + uint32_t a = (active_ms < take) ? active_ms : take; + cur_active += a; active_ms -= a; + if (cur_total >= 1000) { // roll into the ring + buckets[oldest] = cur_active; + oldest = (oldest + 1) % 5; + if (filled < 5) filled++; + cur_active = 0; cur_total = 0; + } + } + } + + // Percent 0..100 across the observed window. The denominator is the + // *observed* time: completed buckets observed exactly 1000 ms each. + uint8_t pct() const { + uint32_t num = cur_active, den = cur_total + 1000UL * filled; + for (int i = 0; i < 5; i++) num += buckets[i]; + return (den == 0) ? 0 : (uint8_t)((num * 100) / den); + } +}; + +/** + * \brief Windowed ratio of "bad" discrete events over the last ~5 seconds + * (e.g. RX CRC failures). Same 5x1s bucket idea, but count-based, + * since events are discrete rather than time fractions. + */ +class WindowedCountedRatio { + uint16_t ev[5], bad[5]; // completed 1s buckets: event / bad-event counts + uint16_t cur_ev, cur_bad; // current (partial) second + uint8_t oldest; + uint8_t filled; + uint32_t last_ms; + void advance(uint32_t now) { // roll completed seconds + uint32_t dt = now - last_ms; last_ms = now; + while (dt >= 1000) { + ev[oldest] = cur_ev; bad[oldest] = cur_bad; + oldest = (oldest + 1) % 5; + if (filled < 5) filled++; + cur_ev = 0; cur_bad = 0; // long stall: window just slides past + dt -= 1000; + } + } +public: + WindowedCountedRatio() : cur_ev(0), cur_bad(0), oldest(0), filled(0), last_ms(0) { + for (int i = 0; i < 5; i++) { ev[i] = 0; bad[i] = 0; } + } + + void add(uint32_t now, uint16_t n_ev, uint16_t n_bad) { + advance(now); + cur_ev += n_ev; cur_bad += n_bad; + } + + // Percent 0..100 of bad events; 0 when no events were seen (quiet = no verdict). + uint8_t badPct() const { + uint32_t e = cur_ev, b = cur_bad; + for (int i = 0; i < 5; i++) { e += ev[i]; b += bad[i]; } + return (e == 0) ? 0 : (uint8_t)((b * 100) / e); + } +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c27..a29edbd90f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -2,6 +2,8 @@ #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" +#include // millis() + #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 @@ -11,6 +13,15 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +// Channel is considered busy when the live RSSI sits this far above the noise +// floor. Fixed (not the configurable _threshold, which companions disable): +// must be >= SAMPLING_THRESHOLD or the energy the floor calibrator tolerates +// (floor+14) would already trip the busy verdict on plain noise. +#define CHAN_BUSY_MARGIN 15 + +// Rate limit for the busy-verdict RSSI poll (one SPI transaction each). +#define CHAN_BUSY_RSSI_INTERVAL_MS 50 + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -85,9 +96,39 @@ void RadioLibWrapper::resetAGC() { _noise_floor = 0; _num_floor_samples = 0; _floor_sample_sum = 0; + + // channel-health metrics: stamp now so the first window has no phantom sample + _last_metric_ms = _last_rssi_ms = millis(); + _last_recv_cnt = n_recv; + _last_err_cnt = n_recv_errors; + _cur_busy = false; } void RadioLibWrapper::loop() { + // --- windowed channel-health metrics (time-weighted, loop-rate independent) --- + // Busy covers what the radio cannot afford to miss: our own TX airtime (the + // receiver cannot measure while transmitting) and an in-progress reception; + // otherwise the verdict is the rate-limited RSSI poll above floor + margin. + // Deaf-but-not-TX windows (FIFO readout, TX turnaround, CAD scan, AGC warm + // sleep; each us..few ms) count as not-busy but stay in the denominator: + // a small, deliberate underestimate of utilization. + uint32_t now = millis(); + uint32_t dt = now - _last_metric_ms; _last_metric_ms = now; + bool in_rx = isInRecvMode(); + bool tx = ((state & ~STATE_INT_READY) == STATE_TX_WAIT); + if (tx || (in_rx && isReceivingPacket())) { + _cur_busy = true; + } else if (in_rx && now - _last_rssi_ms >= CHAN_BUSY_RSSI_INTERVAL_MS) { + _last_rssi_ms = now; + _cur_busy = (getCurrentRSSI() > _noise_floor + CHAN_BUSY_MARGIN); + } + _busy_win.add(now, _cur_busy ? dt : 0); + _deaf_win.add(now, in_rx ? 0 : dt); + uint32_t r = n_recv, e = n_recv_errors; // counter deltas -> RX error-rate window + _err_win.add(now, (uint16_t)(r - _last_recv_cnt), (uint16_t)(e - _last_err_cnt)); + _last_recv_cnt = r; _last_err_cnt = e; + + // --- noise floor sampling --- if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { int rssi = getCurrentRSSI(); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b..5de3ae1d10 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,7 @@ #include #include +#include #ifdef USE_CC310_HW_CRYPTO #include @@ -22,6 +23,15 @@ class RadioLibWrapper : public mesh::Radio { int32_t _floor_sample_sum; uint8_t _preamble_sf; + // windowed channel-health metrics (sampled in loop()) + WindowedPercent _busy_win; // channel busy: own TX, mid-receive, or energy above floor + margin + WindowedPercent _deaf_win; // radio not in RX (listening) mode + WindowedCountedRatio _err_win; // RX attempts with CRC errors + uint32_t _last_metric_ms; // stamp of previous loop() metric sample + uint32_t _last_rssi_ms; // rate limit for the RSSI busy poll + uint32_t _last_recv_cnt, _last_err_cnt; // previous packet counters (for deltas) + bool _cur_busy; // last busy verdict (held between RSSI polls) + void idle(); void startRecv(); float packetScoreInt(float snr, int sf, int packet_len); @@ -59,6 +69,9 @@ class RadioLibWrapper : public mesh::Radio { virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } + uint8_t getChannelUtilizationPct() override { return _busy_win.pct(); } + uint8_t getRxDeafnessPct() override { return _deaf_win.pct(); } + uint8_t getRxErrorRatePct() override { return _err_win.badPct(); } void triggerNoiseFloorCalibrate(int threshold) override; void setCADEnabled(bool enable) override { _cad_enabled = enable; } void resetAGC() override; From 1b3b98871864e6940789179cb803b63d8999b7e9 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Thu, 27 Aug 2026 21:35:10 +0000 Subject: [PATCH 02/14] Show channel-health bars on companion and repeater displays 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 --- examples/companion_radio/ui-new/UITask.cpp | 46 +++++++++++++++++---- examples/companion_radio/ui-tiny/UITask.cpp | 23 +++++++++++ examples/simple_repeater/UITask.cpp | 23 +++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 969ac3dd4a..23616b3310 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -170,6 +170,20 @@ class HomeScreen : public UIScreen { #endif } + // Channel-health mini bar (battery-indicator pattern): a small bar with a + // right-aligned "NN%" value at the display's right edge. Positive framing: + // a full bar is good; it turns warning-coloured below 'warn_below'. + void drawHealthBar(DisplayDriver& display, int y, uint8_t pct, uint8_t warn_below) { + display.setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); + char val[8]; + sprintf(val, "%u%%", pct); + display.drawTextRightAlign(display.width(), y, val); + const int bar_w = 24; + int bar_x = display.width() - display.getTextWidth(val) - 3 - bar_w; + display.drawRect(bar_x, y + 1, bar_w, 7); + display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + } + CayenneLPP sensors_lpp; int sensors_nb = 0; bool sensors_scroll = false; @@ -308,24 +322,38 @@ class HomeScreen : public UIScreen { display.print(tmp); } } else if (_page == HomePage::RADIO) { - display.setColor(UIColor::primary_txt); display.setTextSize(1); - // freq / sf + // freq / sf, plus RX quality (100 - windowed RX error rate) + display.setColor(UIColor::primary_txt); display.setCursor(0, 20); - sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); + sprintf(tmp, "FQ:%06.3f SF%d", _node_prefs->freq, _node_prefs->sf); display.print(tmp); + { + uint8_t rxq = 100 - radio_driver.getRxErrorRatePct(); + display.setColor(rxq < 90 ? UIColor::warning_txt : UIColor::primary_txt); + sprintf(tmp, "Q:%u%%", rxq); + display.drawTextRightAlign(display.width(), 20, tmp); + } + // bw / cr, plus noise floor + display.setColor(UIColor::primary_txt); display.setCursor(0, 31); - sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + sprintf(tmp, "BW:%03.2f CR%d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); + sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor()); + display.drawTextRightAlign(display.width(), 31, tmp); - // tx power, noise floor + // channel free % (100 - windowed utilization) with mini bar + display.setColor(UIColor::primary_txt); display.setCursor(0, 42); - sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); - display.print(tmp); + display.print("CH frei"); + drawHealthBar(display, 42, 100 - radio_driver.getChannelUtilizationPct(), 50); + + // RX readiness % (100 - windowed deafness) with mini bar + display.setColor(UIColor::primary_txt); display.setCursor(0, 53); - sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); - display.print(tmp); + display.print("RX-bereit"); + drawHealthBar(display, 53, 100 - radio_driver.getRxDeafnessPct(), 80); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index b6bdbcf4bd..30c6ddb444 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -150,6 +150,24 @@ class HomeScreen : public UIScreen { } } + // Channel-health bar row (battery-indicator pattern): label at the left, a + // small bar mid-right and a right-aligned "NN%" value. Positive framing: a + // full bar is good; it turns warning-coloured below 'warn_below'. + void drawHealthBar(DisplayDriver& display, int y, const char* label, uint8_t pct, uint8_t warn_below) { + display.setColor(UIColor::primary_txt); + display.setTextSize(1); + display.setCursor(0, y); + display.print(label); + char val[8]; + sprintf(val, "%u%%", pct); + display.drawTextRightAlign(display.width(), y, val); + const int bar_w = 36; + int bar_x = display.width() - display.getTextWidth(val) - 3 - bar_w; + display.setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); + display.drawRect(bar_x, y + 1, bar_w, 7); + display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + } + int render(DisplayDriver& display) override { char tmp[80]; @@ -237,6 +255,11 @@ class HomeScreen : public UIScreen { sprintf(tmp, "TX%d", _node_prefs->tx_power_dbm); display.drawTextRightAlign(display.width(), 26, tmp); + // channel-health bars (windowed, positive framing: full bar = good) + drawHealthBar(display, 35, "CH frei", 100 - radio_driver.getChannelUtilizationPct(), 50); + drawHealthBar(display, 44, "RX-bereit", 100 - radio_driver.getRxDeafnessPct(), 80); + drawHealthBar(display, 53, "RX-Guete", 100 - radio_driver.getRxErrorRatePct(), 90); + } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 8, diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e7225557dd..b5f04a6d08 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -29,6 +29,24 @@ static const uint8_t meshcore_logo [] PROGMEM = { 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; +// Channel-health bar row (battery-indicator pattern): label at the left, a +// small bar mid-right and a right-aligned "NN%" value. Positive framing: a +// full bar is good; it turns warning-coloured below 'warn_below'. +static void drawHealthBar(DisplayDriver* display, int y, const char* label, uint8_t pct, uint8_t warn_below) { + display->setTextSize(1); + display->setColor(UIColor::primary_txt); + display->setCursor(0, y); + display->print(label); + char val[8]; + sprintf(val, "%u%%", pct); + display->drawTextRightAlign(display->width(), y, val); + const int bar_w = 40; + int bar_x = display->width() - display->getTextWidth(val) - 3 - bar_w; + display->setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); + display->drawRect(bar_x, y + 1, bar_w, 7); + display->fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); +} + void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; _auto_off = millis() + AUTO_OFF_MILLIS; @@ -106,6 +124,11 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + + // channel-health bars (windowed, positive framing: full bar = good) + drawHealthBar(_display, 38, "CH frei", 100 - radio_driver.getChannelUtilizationPct(), 50); + drawHealthBar(_display, 47, "RX-bereit", 100 - radio_driver.getRxDeafnessPct(), 80); + drawHealthBar(_display, 56, "RX-Guete", 100 - radio_driver.getRxErrorRatePct(), 90); } } From 37ae9af72a75fcbff2d35d98f5db30bd3ab5b632 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 28 Aug 2026 09:14:43 +0000 Subject: [PATCH 03/14] Polish channel-health display: English labels, fixed bar anchor, RXQ 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 --- examples/companion_radio/ui-new/UITask.cpp | 19 ++++++++-------- examples/companion_radio/ui-tiny/UITask.cpp | 22 ++++++++++--------- examples/simple_repeater/UITask.cpp | 24 +++++++++++---------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 23616b3310..d74f304017 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -170,18 +170,19 @@ class HomeScreen : public UIScreen { #endif } - // Channel-health mini bar (battery-indicator pattern): a small bar with a - // right-aligned "NN%" value at the display's right edge. Positive framing: + // Channel-health mini bar (battery-indicator pattern): the bar is pinned to + // the display's right edge with the "NN%" value right-aligned just before + // it, so both stay put while the value's width changes. Positive framing: // a full bar is good; it turns warning-coloured below 'warn_below'. void drawHealthBar(DisplayDriver& display, int y, uint8_t pct, uint8_t warn_below) { display.setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); - char val[8]; - sprintf(val, "%u%%", pct); - display.drawTextRightAlign(display.width(), y, val); const int bar_w = 24; - int bar_x = display.width() - display.getTextWidth(val) - 3 - bar_w; + int bar_x = display.width() - bar_w - 1; display.drawRect(bar_x, y + 1, bar_w, 7); display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + char val[8]; + sprintf(val, "%u%%", pct); + display.drawTextRightAlign(bar_x - 3, y, val); } CayenneLPP sensors_lpp; @@ -331,7 +332,7 @@ class HomeScreen : public UIScreen { { uint8_t rxq = 100 - radio_driver.getRxErrorRatePct(); display.setColor(rxq < 90 ? UIColor::warning_txt : UIColor::primary_txt); - sprintf(tmp, "Q:%u%%", rxq); + sprintf(tmp, "RXQ%u%%", rxq); display.drawTextRightAlign(display.width(), 20, tmp); } @@ -346,13 +347,13 @@ class HomeScreen : public UIScreen { // channel free % (100 - windowed utilization) with mini bar display.setColor(UIColor::primary_txt); display.setCursor(0, 42); - display.print("CH frei"); + display.print("CH free"); drawHealthBar(display, 42, 100 - radio_driver.getChannelUtilizationPct(), 50); // RX readiness % (100 - windowed deafness) with mini bar display.setColor(UIColor::primary_txt); display.setCursor(0, 53); - display.print("RX-bereit"); + display.print("RX ready"); drawHealthBar(display, 53, 100 - radio_driver.getRxDeafnessPct(), 80); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 30c6ddb444..7a5bb9c143 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -150,22 +150,24 @@ class HomeScreen : public UIScreen { } } - // Channel-health bar row (battery-indicator pattern): label at the left, a - // small bar mid-right and a right-aligned "NN%" value. Positive framing: a - // full bar is good; it turns warning-coloured below 'warn_below'. + // Channel-health bar row (battery-indicator pattern): label at the left, + // then the "NN%" value right-aligned before a bar pinned to the display's + // right edge, so value and bar stay put while the value's width changes. + // Positive framing: a full bar is good; it turns warning-coloured below + // 'warn_below'. void drawHealthBar(DisplayDriver& display, int y, const char* label, uint8_t pct, uint8_t warn_below) { display.setColor(UIColor::primary_txt); display.setTextSize(1); display.setCursor(0, y); display.print(label); - char val[8]; - sprintf(val, "%u%%", pct); - display.drawTextRightAlign(display.width(), y, val); const int bar_w = 36; - int bar_x = display.width() - display.getTextWidth(val) - 3 - bar_w; + int bar_x = display.width() - bar_w - 1; display.setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); display.drawRect(bar_x, y + 1, bar_w, 7); display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + char val[8]; + sprintf(val, "%u%%", pct); + display.drawTextRightAlign(bar_x - 3, y, val); } int render(DisplayDriver& display) override { @@ -256,9 +258,9 @@ class HomeScreen : public UIScreen { display.drawTextRightAlign(display.width(), 26, tmp); // channel-health bars (windowed, positive framing: full bar = good) - drawHealthBar(display, 35, "CH frei", 100 - radio_driver.getChannelUtilizationPct(), 50); - drawHealthBar(display, 44, "RX-bereit", 100 - radio_driver.getRxDeafnessPct(), 80); - drawHealthBar(display, 53, "RX-Guete", 100 - radio_driver.getRxErrorRatePct(), 90); + drawHealthBar(display, 35, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); + drawHealthBar(display, 44, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); + drawHealthBar(display, 53, "RX quality", 100 - radio_driver.getRxErrorRatePct(), 90); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index b5f04a6d08..665ca0ad37 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -29,22 +29,24 @@ static const uint8_t meshcore_logo [] PROGMEM = { 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; -// Channel-health bar row (battery-indicator pattern): label at the left, a -// small bar mid-right and a right-aligned "NN%" value. Positive framing: a -// full bar is good; it turns warning-coloured below 'warn_below'. +// Channel-health bar row (battery-indicator pattern): label at the left, +// then the "NN%" value right-aligned before a bar pinned to the display's +// right edge, so value and bar stay put while the value's width changes. +// Positive framing: a full bar is good; it turns warning-coloured below +// 'warn_below'. static void drawHealthBar(DisplayDriver* display, int y, const char* label, uint8_t pct, uint8_t warn_below) { display->setTextSize(1); display->setColor(UIColor::primary_txt); display->setCursor(0, y); display->print(label); - char val[8]; - sprintf(val, "%u%%", pct); - display->drawTextRightAlign(display->width(), y, val); - const int bar_w = 40; - int bar_x = display->width() - display->getTextWidth(val) - 3 - bar_w; + const int bar_w = 36; + int bar_x = display->width() - bar_w - 1; display->setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); display->drawRect(bar_x, y + 1, bar_w, 7); display->fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + char val[8]; + sprintf(val, "%u%%", pct); + display->drawTextRightAlign(bar_x - 3, y, val); } void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { @@ -126,9 +128,9 @@ void UITask::renderCurrScreen() { _display->print(tmp); // channel-health bars (windowed, positive framing: full bar = good) - drawHealthBar(_display, 38, "CH frei", 100 - radio_driver.getChannelUtilizationPct(), 50); - drawHealthBar(_display, 47, "RX-bereit", 100 - radio_driver.getRxDeafnessPct(), 80); - drawHealthBar(_display, 56, "RX-Guete", 100 - radio_driver.getRxErrorRatePct(), 90); + drawHealthBar(_display, 38, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); + drawHealthBar(_display, 47, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); + drawHealthBar(_display, 56, "RX quality", 100 - radio_driver.getRxErrorRatePct(), 90); } } From 9eb756610fb2036ff0f5ac1e01acac552922cb49 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 28 Aug 2026 16:25:22 +0000 Subject: [PATCH 04/14] Make all three channel-health metrics uniform bar rows on ui-new RADIO page --- examples/companion_radio/ui-new/UITask.cpp | 35 +++++++++++----------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d74f304017..275ac7dbb1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -323,38 +323,37 @@ class HomeScreen : public UIScreen { display.print(tmp); } } else if (_page == HomePage::RADIO) { + // 5 rows at 9px pitch (text is 8px high) so all three channel-health + // metrics render as uniform label + bar rows within a 128x64 display display.setTextSize(1); - // freq / sf, plus RX quality (100 - windowed RX error rate) + // freq / sf, plus noise floor display.setColor(UIColor::primary_txt); - display.setCursor(0, 20); + display.setCursor(0, 19); sprintf(tmp, "FQ:%06.3f SF%d", _node_prefs->freq, _node_prefs->sf); display.print(tmp); - { - uint8_t rxq = 100 - radio_driver.getRxErrorRatePct(); - display.setColor(rxq < 90 ? UIColor::warning_txt : UIColor::primary_txt); - sprintf(tmp, "RXQ%u%%", rxq); - display.drawTextRightAlign(display.width(), 20, tmp); - } + sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor()); + display.drawTextRightAlign(display.width(), 19, tmp); - // bw / cr, plus noise floor - display.setColor(UIColor::primary_txt); - display.setCursor(0, 31); + // bw / cr + display.setCursor(0, 28); sprintf(tmp, "BW:%03.2f CR%d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); - sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor()); - display.drawTextRightAlign(display.width(), 31, tmp); // channel free % (100 - windowed utilization) with mini bar display.setColor(UIColor::primary_txt); - display.setCursor(0, 42); + display.setCursor(0, 37); display.print("CH free"); - drawHealthBar(display, 42, 100 - radio_driver.getChannelUtilizationPct(), 50); + drawHealthBar(display, 37, 100 - radio_driver.getChannelUtilizationPct(), 50); // RX readiness % (100 - windowed deafness) with mini bar - display.setColor(UIColor::primary_txt); - display.setCursor(0, 53); + display.setCursor(0, 46); display.print("RX ready"); - drawHealthBar(display, 53, 100 - radio_driver.getRxDeafnessPct(), 80); + drawHealthBar(display, 46, 100 - radio_driver.getRxDeafnessPct(), 80); + + // RX quality % (100 - windowed RX error rate) with mini bar + display.setCursor(0, 55); + display.print("RXQ"); + drawHealthBar(display, 55, 100 - radio_driver.getRxErrorRatePct(), 90); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, From 2198eb6566740178cf9b81b553d2b54debb100bb Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 28 Aug 2026 16:32:31 +0000 Subject: [PATCH 05/14] Changed output on ui-new RADIO page --- examples/companion_radio/ui-new/UITask.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 275ac7dbb1..b4a856845d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -326,18 +326,18 @@ class HomeScreen : public UIScreen { // 5 rows at 9px pitch (text is 8px high) so all three channel-health // metrics render as uniform label + bar rows within a 128x64 display display.setTextSize(1); - // freq / sf, plus noise floor + // freq / sf display.setColor(UIColor::primary_txt); display.setCursor(0, 19); sprintf(tmp, "FQ:%06.3f SF%d", _node_prefs->freq, _node_prefs->sf); display.print(tmp); - sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor()); - display.drawTextRightAlign(display.width(), 19, tmp); - // bw / cr + // bw / cr, plus noise floor display.setCursor(0, 28); sprintf(tmp, "BW:%03.2f CR%d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); + sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor()); + display.drawTextRightAlign(display.width(), 28, tmp); // channel free % (100 - windowed utilization) with mini bar display.setColor(UIColor::primary_txt); @@ -352,7 +352,7 @@ class HomeScreen : public UIScreen { // RX quality % (100 - windowed RX error rate) with mini bar display.setCursor(0, 55); - display.print("RXQ"); + display.print("RX quality"); drawHealthBar(display, 55, 100 - radio_driver.getRxErrorRatePct(), 90); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); From 190014c51f7cc632b77d366b528114428f73cc5e Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 29 Aug 2026 19:48:55 +0000 Subject: [PATCH 06/14] RX quality: show good/total decode counts; fix error-rate denominator 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 --- examples/companion_radio/ui-new/UITask.cpp | 9 +++++++-- examples/companion_radio/ui-tiny/UITask.cpp | 12 +++++++++++- examples/simple_repeater/UITask.cpp | 12 +++++++++++- src/Dispatcher.h | 4 +++- src/helpers/StatsFormatHelper.h | 10 ++++++++-- src/helpers/WindowedPercent.h | 8 +++++--- src/helpers/radiolib/RadioLibWrappers.cpp | 7 +++++-- src/helpers/radiolib/RadioLibWrappers.h | 9 +++++++-- 8 files changed, 57 insertions(+), 14 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b4a856845d..81c91d2f24 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -350,10 +350,15 @@ class HomeScreen : public UIScreen { display.print("RX ready"); drawHealthBar(display, 46, 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality % (100 - windowed RX error rate) with mini bar + // RX quality: good vs total packet decodes in the window ("good/total") - + // shows the corruption share AND how much traffic was heard (~5s window) + display.setColor(UIColor::primary_txt); display.setCursor(0, 55); display.print("RX quality"); - drawHealthBar(display, 55, 100 - radio_driver.getRxErrorRatePct(), 90); + uint16_t rx_good = 0, rx_total = 0; + radio_driver.getRxQualityCounts(rx_good, rx_total); + sprintf(tmp, "%u/%u", rx_good, rx_total); + display.drawTextRightAlign(display.width(), 55, tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 7a5bb9c143..04bdbd8384 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -260,7 +260,17 @@ class HomeScreen : public UIScreen { // channel-health bars (windowed, positive framing: full bar = good) drawHealthBar(display, 35, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); drawHealthBar(display, 44, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - drawHealthBar(display, 53, "RX quality", 100 - radio_driver.getRxErrorRatePct(), 90); + + // RX quality: good vs total decodes in the window ("good/total") - shows + // the corruption share AND how much traffic was heard (~5s window) + display.setColor(UIColor::primary_txt); + display.setTextSize(1); + display.setCursor(0, 53); + display.print("RX quality"); + uint16_t rx_good = 0, rx_total = 0; + radio_driver.getRxQualityCounts(rx_good, rx_total); + sprintf(tmp, "%u/%u", rx_good, rx_total); + display.drawTextRightAlign(display.width(), 53, tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 665ca0ad37..d0c6969c97 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -130,7 +130,17 @@ void UITask::renderCurrScreen() { // channel-health bars (windowed, positive framing: full bar = good) drawHealthBar(_display, 38, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); drawHealthBar(_display, 47, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - drawHealthBar(_display, 56, "RX quality", 100 - radio_driver.getRxErrorRatePct(), 90); + + // RX quality: good vs total decodes in the window ("good/total") - shows + // the corruption share AND how much traffic was heard (~5s window) + _display->setColor(UIColor::primary_txt); + _display->setTextSize(1); + _display->setCursor(0, 56); + _display->print("RX quality"); + uint16_t rx_good = 0, rx_total = 0; + radio_driver.getRxQualityCounts(rx_good, rx_total); + sprintf(tmp, "%u/%u", rx_good, rx_total); + _display->drawTextRightAlign(_display->width(), 56, tmp); } } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index d4f3564ad4..93a44ef932 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -70,7 +70,9 @@ class Radio { */ virtual uint8_t getChannelUtilizationPct() { return 0; } // % of time the channel was busy virtual uint8_t getRxDeafnessPct() { return 0; } // % of time the radio was NOT in RX - virtual uint8_t getRxErrorRatePct() { return 0; } // % of reception attempts with CRC errors + // Good vs total packet decodes in the RX-quality window (~5s): 'total' counts + // all reception attempts, 'good' the ones that decoded (passed CRC). + virtual void getRxQualityCounts(uint16_t& good, uint16_t& total) { good = 0; total = 0; } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 5a6f5eb5c7..995cfc7472 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,9 +24,13 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { + uint16_t rx_good = 0, rx_total = 0; + radio->getRxQualityCounts(rx_good, rx_total); + uint32_t rx_err_pct = (rx_total > 0) + ? ((uint32_t)(rx_total - rx_good) * 100) / rx_total : 0; sprintf(reply, "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," - "\"chan_util_pct\":%u,\"rx_deaf_pct\":%u,\"rx_err_pct\":%u}", + "\"chan_util_pct\":%u,\"rx_deaf_pct\":%u,\"rx_err_pct\":%u,\"rx_good\":%u,\"rx_total\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), driver.getLastSNR(), @@ -34,7 +38,9 @@ class StatsFormatHelper { total_rx_air_time_ms / 1000, radio->getChannelUtilizationPct(), radio->getRxDeafnessPct(), - radio->getRxErrorRatePct() + rx_err_pct, + rx_good, + rx_total ); } diff --git a/src/helpers/WindowedPercent.h b/src/helpers/WindowedPercent.h index c20a46550c..4bade8f266 100644 --- a/src/helpers/WindowedPercent.h +++ b/src/helpers/WindowedPercent.h @@ -75,15 +75,17 @@ class WindowedCountedRatio { for (int i = 0; i < 5; i++) { ev[i] = 0; bad[i] = 0; } } + // 'n_ev' counts ALL events (attempts), of which 'n_bad' failed. void add(uint32_t now, uint16_t n_ev, uint16_t n_bad) { advance(now); cur_ev += n_ev; cur_bad += n_bad; } - // Percent 0..100 of bad events; 0 when no events were seen (quiet = no verdict). - uint8_t badPct() const { + // Window totals: all events (attempts) and the failing subset. + void counts(uint16_t& n_ev, uint16_t& n_bad) const { uint32_t e = cur_ev, b = cur_bad; for (int i = 0; i < 5; i++) { e += ev[i]; b += bad[i]; } - return (e == 0) ? 0 : (uint8_t)((b * 100) / e); + n_ev = (e > 0xFFFF) ? 0xFFFF : (uint16_t)e; + n_bad = (b > 0xFFFF) ? 0xFFFF : (uint16_t)b; } }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index a29edbd90f..bb29c50c3d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -124,8 +124,11 @@ void RadioLibWrapper::loop() { } _busy_win.add(now, _cur_busy ? dt : 0); _deaf_win.add(now, in_rx ? 0 : dt); - uint32_t r = n_recv, e = n_recv_errors; // counter deltas -> RX error-rate window - _err_win.add(now, (uint16_t)(r - _last_recv_cnt), (uint16_t)(e - _last_err_cnt)); + uint32_t r = n_recv, e = n_recv_errors; // counter deltas -> RX-quality window + uint16_t d_ok = (uint16_t)(r - _last_recv_cnt), d_err = (uint16_t)(e - _last_err_cnt); + // events = ALL attempts (decodes + CRC failures), bad = the failures, so the + // ratio is errors-per-attempt rather than errors-per-good-decode + _err_win.add(now, d_ok + d_err, d_err); _last_recv_cnt = r; _last_err_cnt = e; // --- noise floor sampling --- diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 5de3ae1d10..4550a6954b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -39,7 +39,7 @@ class RadioLibWrapper : public mesh::Radio { virtual void doResetAGC(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = n_recv_errors = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -71,7 +71,12 @@ class RadioLibWrapper : public mesh::Radio { int getNoiseFloor() const override { return _noise_floor; } uint8_t getChannelUtilizationPct() override { return _busy_win.pct(); } uint8_t getRxDeafnessPct() override { return _deaf_win.pct(); } - uint8_t getRxErrorRatePct() override { return _err_win.badPct(); } + void getRxQualityCounts(uint16_t& good, uint16_t& total) override { + uint16_t ev, bad; + _err_win.counts(ev, bad); + total = ev; // all reception attempts + good = ev - bad; // ...of which decoded OK + } void triggerNoiseFloorCalibrate(int threshold) override; void setCADEnabled(bool enable) override { _cad_enabled = enable; } void resetAGC() override; From 3de02974a003bf4efa91ba674ce4fd73b4c6a930 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 29 Aug 2026 20:53:31 +0000 Subject: [PATCH 07/14] Fix WindowedCountedRatio never aging out (sub-second add() dt discarded) 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 --- src/helpers/WindowedPercent.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/helpers/WindowedPercent.h b/src/helpers/WindowedPercent.h index 4bade8f266..3fde88da60 100644 --- a/src/helpers/WindowedPercent.h +++ b/src/helpers/WindowedPercent.h @@ -57,21 +57,24 @@ class WindowedPercent { class WindowedCountedRatio { uint16_t ev[5], bad[5]; // completed 1s buckets: event / bad-event counts uint16_t cur_ev, cur_bad; // current (partial) second + uint32_t cur_ms; // ms accumulated toward the next bucket roll uint8_t oldest; uint8_t filled; uint32_t last_ms; void advance(uint32_t now) { // roll completed seconds uint32_t dt = now - last_ms; last_ms = now; - while (dt >= 1000) { + if (dt > 60000) dt = 60000; // long stall: the window has slid past anyway + cur_ms += dt; // accumulate: callers tick far faster than 1s, + while (cur_ms >= 1000) { // so no single dt ever reaches a second by itself ev[oldest] = cur_ev; bad[oldest] = cur_bad; oldest = (oldest + 1) % 5; if (filled < 5) filled++; cur_ev = 0; cur_bad = 0; // long stall: window just slides past - dt -= 1000; + cur_ms -= 1000; } } public: - WindowedCountedRatio() : cur_ev(0), cur_bad(0), oldest(0), filled(0), last_ms(0) { + WindowedCountedRatio() : cur_ev(0), cur_bad(0), cur_ms(0), oldest(0), filled(0), last_ms(0) { for (int i = 0; i < 5; i++) { ev[i] = 0; bad[i] = 0; } } From 4edee0d2894ffd354e3c1d7c1e8029efd5e3dbc5 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 31 Aug 2026 19:34:20 +0000 Subject: [PATCH 08/14] RX quality: extend counted window to ~10 min with warm-up extrapolation 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 --- examples/companion_radio/ui-new/UITask.cpp | 3 +- examples/companion_radio/ui-tiny/UITask.cpp | 3 +- examples/simple_repeater/UITask.cpp | 3 +- src/Dispatcher.h | 13 +++-- src/helpers/StatsFormatHelper.h | 3 ++ src/helpers/WindowedPercent.h | 58 +++++++++++++++------ src/helpers/radiolib/RadioLibWrappers.h | 11 +++- 7 files changed, 68 insertions(+), 26 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 81c91d2f24..96dff26c99 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -351,7 +351,8 @@ class HomeScreen : public UIScreen { drawHealthBar(display, 46, 100 - radio_driver.getRxDeafnessPct(), 80); // RX quality: good vs total packet decodes in the window ("good/total") - - // shows the corruption share AND how much traffic was heard (~5s window) + // shows the corruption share AND how much traffic was heard (~10 min + // window, extrapolated while the window fills after boot/reset) display.setColor(UIColor::primary_txt); display.setCursor(0, 55); display.print("RX quality"); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 04bdbd8384..3bda15f55b 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -262,7 +262,8 @@ class HomeScreen : public UIScreen { drawHealthBar(display, 44, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); // RX quality: good vs total decodes in the window ("good/total") - shows - // the corruption share AND how much traffic was heard (~5s window) + // the corruption share AND how much traffic was heard (~10 min window, + // extrapolated while the window fills after boot/reset) display.setColor(UIColor::primary_txt); display.setTextSize(1); display.setCursor(0, 53); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index d0c6969c97..7e6414dfd1 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -132,7 +132,8 @@ void UITask::renderCurrScreen() { drawHealthBar(_display, 47, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); // RX quality: good vs total decodes in the window ("good/total") - shows - // the corruption share AND how much traffic was heard (~5s window) + // the corruption share AND how much traffic was heard (~10 min window, + // extrapolated while the window fills after boot/reset) _display->setColor(UIColor::primary_txt); _display->setTextSize(1); _display->setCursor(0, 56); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 93a44ef932..a15f8d17b2 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -64,14 +64,17 @@ class Radio { virtual int getNoiseFloor() const { return 0; } /** - * \brief windowed channel-health metrics over the last ~5 observed seconds. - * All three use "0 = good" semantics; default 0 so radios that do - * not implement them (e.g. ESPNOW) degrade gracefully. + * \brief windowed channel-health metrics: utilization/deafness over the + * last ~5 observed seconds, RX-quality counts over the last ~10 + * minutes (extrapolated to the full window while it fills after a + * boot/reset). All use "0 = good" semantics; default 0 so radios + * that do not implement them (e.g. ESPNOW) degrade gracefully. */ virtual uint8_t getChannelUtilizationPct() { return 0; } // % of time the channel was busy virtual uint8_t getRxDeafnessPct() { return 0; } // % of time the radio was NOT in RX - // Good vs total packet decodes in the RX-quality window (~5s): 'total' counts - // all reception attempts, 'good' the ones that decoded (passed CRC). + // Good vs total packet decodes in the RX-quality window (~10 min, + // extrapolated to the full window while it fills after a boot/reset): + // 'total' counts all reception attempts, 'good' the ones that decoded. virtual void getRxQualityCounts(uint16_t& good, uint16_t& total) { good = 0; total = 0; } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 995cfc7472..95a37d1760 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,6 +24,9 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { + // rx_good/rx_total: decodes vs all reception attempts over the ~10 min + // RX-quality window, extrapolated to the full window while it fills after + // a boot/reset. uint16_t rx_good = 0, rx_total = 0; radio->getRxQualityCounts(rx_good, rx_total); uint32_t rx_err_pct = (rx_total > 0) diff --git a/src/helpers/WindowedPercent.h b/src/helpers/WindowedPercent.h index 3fde88da60..69e7621502 100644 --- a/src/helpers/WindowedPercent.h +++ b/src/helpers/WindowedPercent.h @@ -50,32 +50,39 @@ class WindowedPercent { }; /** - * \brief Windowed ratio of "bad" discrete events over the last ~5 seconds - * (e.g. RX CRC failures). Same 5x1s bucket idea, but count-based, - * since events are discrete rather than time fractions. + * \brief Windowed ratio of "bad" discrete events over the last ~10 minutes + * (e.g. RX CRC failures). Same bucket-ring idea as WindowedPercent, but + * count-based and much longer: packet counts on a quiet mesh need + * minutes, not seconds, to become statistically meaningful. The + * time-based utilization/deafness metrics stay at ~5 s. */ +template // 60 x 10 s = ~10 min class WindowedCountedRatio { - uint16_t ev[5], bad[5]; // completed 1s buckets: event / bad-event counts - uint16_t cur_ev, cur_bad; // current (partial) second - uint32_t cur_ms; // ms accumulated toward the next bucket roll + uint16_t ev[N_BUCKETS], bad[N_BUCKETS]; // completed buckets: event / bad-event counts + uint16_t cur_ev, cur_bad; // current (partial) bucket + uint32_t cur_ms; // ms accumulated toward the next bucket roll uint8_t oldest; uint8_t filled; uint32_t last_ms; - void advance(uint32_t now) { // roll completed seconds + void advance(uint32_t now) { // roll completed buckets uint32_t dt = now - last_ms; last_ms = now; - if (dt > 60000) dt = 60000; // long stall: the window has slid past anyway - cur_ms += dt; // accumulate: callers tick far faster than 1s, - while (cur_ms >= 1000) { // so no single dt ever reaches a second by itself + uint32_t window_ms = (uint32_t)N_BUCKETS * BUCKET_MS; + if (dt > window_ms) dt = window_ms; // long stall/sleep: the window has slid past anyway + cur_ms += dt; // accumulate: callers tick far faster than one bucket, + // A stall spanning the whole window makes everything pre-stall older than + // the window: drop it instead of baking it into the oldest (surviving) bucket. + if (cur_ms / BUCKET_MS >= N_BUCKETS) { cur_ev = 0; cur_bad = 0; } + while (cur_ms >= BUCKET_MS) { // so no single dt ever fills a bucket by itself ev[oldest] = cur_ev; bad[oldest] = cur_bad; - oldest = (oldest + 1) % 5; - if (filled < 5) filled++; + oldest = (oldest + 1) % N_BUCKETS; + if (filled < N_BUCKETS) filled++; cur_ev = 0; cur_bad = 0; // long stall: window just slides past - cur_ms -= 1000; + cur_ms -= BUCKET_MS; } } public: WindowedCountedRatio() : cur_ev(0), cur_bad(0), cur_ms(0), oldest(0), filled(0), last_ms(0) { - for (int i = 0; i < 5; i++) { ev[i] = 0; bad[i] = 0; } + for (int i = 0; i < N_BUCKETS; i++) { ev[i] = 0; bad[i] = 0; } } // 'n_ev' counts ALL events (attempts), of which 'n_bad' failed. @@ -84,10 +91,29 @@ class WindowedCountedRatio { cur_ev += n_ev; cur_bad += n_bad; } - // Window totals: all events (attempts) and the failing subset. + // Forget everything (stats reset). last_ms is left alone: loop()-rate + // callers pass dt of only a few ms, so post-clear observation restarts at ~0 + // and the warm-up extrapolation below applies again. + void clear() { + for (int i = 0; i < N_BUCKETS; i++) { ev[i] = 0; bad[i] = 0; } + cur_ev = 0; cur_bad = 0; cur_ms = 0; oldest = 0; filled = 0; + } + + // Window totals: all events (attempts) and the failing subset. While the + // window is still FILLING (the first window-length after construction or + // clear()) the counts are extrapolated to the full window: events per + // observed time x window length. Rough at first (an early burst + // overshoots), but the number has full-window scale immediately and + // converges as the window fills. void counts(uint16_t& n_ev, uint16_t& n_bad) const { uint32_t e = cur_ev, b = cur_bad; - for (int i = 0; i < 5; i++) { e += ev[i]; b += bad[i]; } + for (int i = 0; i < N_BUCKETS; i++) { e += ev[i]; b += bad[i]; } + uint32_t window_ms = (uint32_t)N_BUCKETS * BUCKET_MS; + uint32_t observed = (uint32_t)filled * BUCKET_MS + cur_ms; + if (observed >= BUCKET_MS && observed < window_ms) { // warm-up: scale up + e = (uint32_t)(((uint64_t)e * window_ms) / observed); + b = (uint32_t)(((uint64_t)b * window_ms) / observed); + } n_ev = (e > 0xFFFF) ? 0xFFFF : (uint16_t)e; n_bad = (b > 0xFFFF) ? 0xFFFF : (uint16_t)b; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 4550a6954b..6682668ab3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -26,7 +26,7 @@ class RadioLibWrapper : public mesh::Radio { // windowed channel-health metrics (sampled in loop()) WindowedPercent _busy_win; // channel busy: own TX, mid-receive, or energy above floor + margin WindowedPercent _deaf_win; // radio not in RX (listening) mode - WindowedCountedRatio _err_win; // RX attempts with CRC errors + WindowedCountedRatio<60, 10000> _err_win; // RX attempts with CRC errors (~10 min window) uint32_t _last_metric_ms; // stamp of previous loop() metric sample uint32_t _last_rssi_ms; // rate limit for the RSSI busy poll uint32_t _last_recv_cnt, _last_err_cnt; // previous packet counters (for deltas) @@ -86,7 +86,14 @@ class RadioLibWrapper : public mesh::Radio { uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + // Zeroing the counters without re-stamping the delta bases would underflow + // the next loop() delta and inject a garbage spike into one ~10 min window + // bucket, so clear the window and stamps together with the counters. + void resetStats() { + n_recv = n_sent = n_recv_errors = 0; + _last_recv_cnt = 0; _last_err_cnt = 0; + _err_win.clear(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() const override; From 9dc56e66153446b4a07b35f0d146fe99240b62c7 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 31 Aug 2026 19:34:35 +0000 Subject: [PATCH 09/14] RX quality: count only SNR-relevant CRC failures (exclude distant stations) 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 --- src/Dispatcher.h | 6 +-- src/helpers/StatsFormatHelper.h | 6 +-- src/helpers/radiolib/RadioLibWrappers.cpp | 53 ++++++++++++++++------- src/helpers/radiolib/RadioLibWrappers.h | 10 +++-- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/Dispatcher.h b/src/Dispatcher.h index a15f8d17b2..d471a4baed 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -72,9 +72,9 @@ class Radio { */ virtual uint8_t getChannelUtilizationPct() { return 0; } // % of time the channel was busy virtual uint8_t getRxDeafnessPct() { return 0; } // % of time the radio was NOT in RX - // Good vs total packet decodes in the RX-quality window (~10 min, - // extrapolated to the full window while it fills after a boot/reset): - // 'total' counts all reception attempts, 'good' the ones that decoded. + // Good vs total packet decodes in the RX-quality window (~10 min): 'total' + // counts decodes plus SNR-relevant CRC failures (weak distant stations are + // excluded), 'good' the ones that decoded (passed CRC). virtual void getRxQualityCounts(uint16_t& good, uint16_t& total) { good = 0; total = 0; } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 95a37d1760..c45cbcc696 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,9 +24,9 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { - // rx_good/rx_total: decodes vs all reception attempts over the ~10 min - // RX-quality window, extrapolated to the full window while it fills after - // a boot/reset. + // rx_good/rx_total: decodes vs (decodes + SNR-relevant CRC failures) over + // the ~10 min RX-quality window, extrapolated to the full window while it + // fills after a boot/reset. Weak distant-station failures are excluded. uint16_t rx_good = 0, rx_total = 0; radio->getRxQualityCounts(rx_good, rx_total); uint32_t rx_err_pct = (rx_total > 0) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index bb29c50c3d..b9cac8073a 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -124,12 +124,12 @@ void RadioLibWrapper::loop() { } _busy_win.add(now, _cur_busy ? dt : 0); _deaf_win.add(now, in_rx ? 0 : dt); - uint32_t r = n_recv, e = n_recv_errors; // counter deltas -> RX-quality window - uint16_t d_ok = (uint16_t)(r - _last_recv_cnt), d_err = (uint16_t)(e - _last_err_cnt); - // events = ALL attempts (decodes + CRC failures), bad = the failures, so the - // ratio is errors-per-attempt rather than errors-per-good-decode + uint32_t r = n_recv, es = n_recv_errors_strong; // counter deltas -> RX-quality window + uint16_t d_ok = (uint16_t)(r - _last_recv_cnt), d_err = (uint16_t)(es - _last_strong_err_cnt); + // events = decodes + SNR-relevant CRC failures (weak distant stations are + // excluded in recvRaw, from both numerator and denominator), bad = those failures _err_win.add(now, d_ok + d_err, d_err); - _last_recv_cnt = r; _last_err_cnt = e; + _last_recv_cnt = r; _last_strong_err_cnt = es; // --- noise floor sampling --- if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { @@ -169,6 +169,22 @@ bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } +// Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) +static float snr_threshold[] = { + -7.5, // SF7 needs at least -7.5 dB SNR + -10, // SF8 needs at least -10 dB SNR + -12.5, // SF9 needs at least -12.5 dB SNR + -15, // SF10 needs at least -15 dB SNR + -17.5,// SF11 needs at least -17.5 dB SNR + -20 // SF12 needs at least -20 dB SNR +}; + +// A CRC-failed packet counts as an RX-quality failure only if its SNR was this +// far above the per-SF decode threshold: "should have decoded, but didn't" = +// collision/interference verdict on this channel. Distant stations below the +// decode threshold are physics, not channel health. +#define RXQ_FAIL_SNR_GUARD_DB 3.0f + int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { @@ -180,6 +196,23 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); len = 0; n_recv_errors++; + // Only "relevant" failures enter the RX-quality window: a packet whose + // SNR says it SHOULD have decoded (>= per-SF threshold + guard) but + // failed CRC indicates a collision/interference on THIS channel, while + // a distant station below the decode threshold is expected to fail. + // The packet-status SNR stays latched after the failed read (readData + // clears IRQ/FIFO state, not packet status). Weak failures drop out of + // both numerator and denominator of the window. + if (err == RADIOLIB_ERR_CRC_MISMATCH || err == RADIOLIB_ERR_LORA_HEADER_DAMAGED) { + uint8_t sf = getSpreadingFactor(); + if (sf < 7) sf = 7; else if (sf > 12) sf = 12; + float snr = getLastSNR(); + bool relevant = (snr >= snr_threshold[sf - 7] + RXQ_FAIL_SNR_GUARD_DB); + if (relevant) n_recv_errors_strong++; + #ifdef MESH_DEBUG_RXQ + MESH_DEBUG_PRINTLN("RXQ fail: snr=%.1f sf=%u -> %s", (double)snr, sf, relevant ? "counted" : "excluded(weak)"); + #endif + } } else { // Serial.print(" readData() -> "); Serial.println(len); n_recv++; @@ -264,16 +297,6 @@ float RadioLibWrapper::getLastSNR() const { return _radio->getSNR(); } -// Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) -static float snr_threshold[] = { - -7.5, // SF7 needs at least -7.5 dB SNR - -10, // SF8 needs at least -10 dB SNR - -12.5, // SF9 needs at least -12.5 dB SNR - -15, // SF10 needs at least -15 dB SNR - -17.5,// SF11 needs at least -17.5 dB SNR - -20 // SF12 needs at least -20 dB SNR -}; - float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { if (sf < 7) return 0.0f; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 6682668ab3..effb7c88a4 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -17,6 +17,7 @@ class RadioLibWrapper : public mesh::Radio { PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; + uint32_t n_recv_errors_strong; // failures whose SNR says they should have decoded (RX-quality window) int16_t _noise_floor, _threshold; bool _cad_enabled; uint16_t _num_floor_samples; @@ -26,10 +27,11 @@ class RadioLibWrapper : public mesh::Radio { // windowed channel-health metrics (sampled in loop()) WindowedPercent _busy_win; // channel busy: own TX, mid-receive, or energy above floor + margin WindowedPercent _deaf_win; // radio not in RX (listening) mode - WindowedCountedRatio<60, 10000> _err_win; // RX attempts with CRC errors (~10 min window) + WindowedCountedRatio<60, 10000> _err_win; // RX attempts with relevant CRC errors (~10 min window) uint32_t _last_metric_ms; // stamp of previous loop() metric sample uint32_t _last_rssi_ms; // rate limit for the RSSI busy poll uint32_t _last_recv_cnt, _last_err_cnt; // previous packet counters (for deltas) + uint32_t _last_strong_err_cnt = 0; // previous SNR-relevant failure counter (for deltas) bool _cur_busy; // last busy verdict (held between RSSI polls) void idle(); @@ -39,7 +41,7 @@ class RadioLibWrapper : public mesh::Radio { virtual void doResetAGC(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = n_recv_errors = 0; } + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = n_recv_errors = n_recv_errors_strong = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -90,8 +92,8 @@ class RadioLibWrapper : public mesh::Radio { // the next loop() delta and inject a garbage spike into one ~10 min window // bucket, so clear the window and stamps together with the counters. void resetStats() { - n_recv = n_sent = n_recv_errors = 0; - _last_recv_cnt = 0; _last_err_cnt = 0; + n_recv = n_sent = n_recv_errors = n_recv_errors_strong = 0; + _last_recv_cnt = 0; _last_err_cnt = 0; _last_strong_err_cnt = 0; _err_win.clear(); } From 4111e11d13f86153973582d8f5e0bb9fe910048b Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 31 Aug 2026 20:13:45 +0000 Subject: [PATCH 10/14] RX quality UI: prefix the counts with the windowed percentage ("NN%=good/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 --- examples/companion_radio/ui-new/UITask.cpp | 20 ++++++++++++++++---- examples/companion_radio/ui-tiny/UITask.cpp | 20 ++++++++++++++++---- examples/simple_repeater/UITask.cpp | 20 ++++++++++++++++---- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 96dff26c99..581df7978b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -350,15 +350,27 @@ class HomeScreen : public UIScreen { display.print("RX ready"); drawHealthBar(display, 46, 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality: good vs total packet decodes in the window ("good/total") - - // shows the corruption share AND how much traffic was heard (~10 min - // window, extrapolated while the window fills after boot/reset) + // RX quality: windowed good vs total packet decodes as "NN%=good/total" + // (~10 min window, extrapolated while it fills after boot/reset) - the + // percentage scans like the bars above, the counts show the sample size + // and traffic level behind it display.setColor(UIColor::primary_txt); display.setCursor(0, 55); display.print("RX quality"); uint16_t rx_good = 0, rx_total = 0; radio_driver.getRxQualityCounts(rx_good, rx_total); - sprintf(tmp, "%u/%u", rx_good, rx_total); + if (rx_total > 0) { + uint8_t rxq_pct = (uint8_t)((rx_good * 100u) / rx_total); + sprintf(tmp, "%u%%=%u/%u", rxq_pct, rx_good, rx_total); + // very large counts on a narrow display: drop the percentage, keep the counts + if (display.getTextWidth(tmp) + display.getTextWidth("RX quality") + 4 > display.width()) { + sprintf(tmp, "%u/%u", rx_good, rx_total); + } + display.setColor(rxq_pct < 80 ? UIColor::warning_txt : UIColor::primary_txt); + } else { + sprintf(tmp, "%u/%u", rx_good, rx_total); // quiet: no data, no verdict + display.setColor(UIColor::primary_txt); + } display.drawTextRightAlign(display.width(), 55, tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 3bda15f55b..88cfcb9921 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -261,16 +261,28 @@ class HomeScreen : public UIScreen { drawHealthBar(display, 35, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); drawHealthBar(display, 44, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality: good vs total decodes in the window ("good/total") - shows - // the corruption share AND how much traffic was heard (~10 min window, - // extrapolated while the window fills after boot/reset) + // RX quality: windowed good vs total decodes as "NN%=good/total" (~10 min + // window, extrapolated while it fills after boot/reset) - the percentage + // scans like the bars above, the counts show the sample size and traffic + // level behind it display.setColor(UIColor::primary_txt); display.setTextSize(1); display.setCursor(0, 53); display.print("RX quality"); uint16_t rx_good = 0, rx_total = 0; radio_driver.getRxQualityCounts(rx_good, rx_total); - sprintf(tmp, "%u/%u", rx_good, rx_total); + if (rx_total > 0) { + uint8_t rxq_pct = (uint8_t)((rx_good * 100u) / rx_total); + sprintf(tmp, "%u%%=%u/%u", rxq_pct, rx_good, rx_total); + // very large counts on a narrow display: drop the percentage, keep the counts + if (display.getTextWidth(tmp) + display.getTextWidth("RX quality") + 4 > display.width()) { + sprintf(tmp, "%u/%u", rx_good, rx_total); + } + display.setColor(rxq_pct < 80 ? UIColor::warning_txt : UIColor::primary_txt); + } else { + sprintf(tmp, "%u/%u", rx_good, rx_total); // quiet: no data, no verdict + display.setColor(UIColor::primary_txt); + } display.drawTextRightAlign(display.width(), 53, tmp); } else if (_page == HomePage::BLUETOOTH) { diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 7e6414dfd1..998435c46d 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -131,16 +131,28 @@ void UITask::renderCurrScreen() { drawHealthBar(_display, 38, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); drawHealthBar(_display, 47, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality: good vs total decodes in the window ("good/total") - shows - // the corruption share AND how much traffic was heard (~10 min window, - // extrapolated while the window fills after boot/reset) + // RX quality: windowed good vs total decodes as "NN%=good/total" (~10 min + // window, extrapolated while it fills after boot/reset) - the percentage + // scans like the bars above, the counts show the sample size and traffic + // level behind it _display->setColor(UIColor::primary_txt); _display->setTextSize(1); _display->setCursor(0, 56); _display->print("RX quality"); uint16_t rx_good = 0, rx_total = 0; radio_driver.getRxQualityCounts(rx_good, rx_total); - sprintf(tmp, "%u/%u", rx_good, rx_total); + if (rx_total > 0) { + uint8_t rxq_pct = (uint8_t)((rx_good * 100u) / rx_total); + sprintf(tmp, "%u%%=%u/%u", rxq_pct, rx_good, rx_total); + // very large counts on a narrow display: drop the percentage, keep the counts + if (_display->getTextWidth(tmp) + _display->getTextWidth("RX quality") + 4 > _display->width()) { + sprintf(tmp, "%u/%u", rx_good, rx_total); + } + _display->setColor(rxq_pct < 80 ? UIColor::warning_txt : UIColor::primary_txt); + } else { + sprintf(tmp, "%u/%u", rx_good, rx_total); // quiet: no data, no verdict + _display->setColor(UIColor::primary_txt); + } _display->drawTextRightAlign(_display->width(), 56, tmp); } } From 42636bb61d5af750a330ee66bbca90ee0093764c Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 1 Sep 2026 05:18:30 +0000 Subject: [PATCH 11/14] RX quality UI: render as a uniform bar row like CH free / RX ready 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 --- examples/companion_radio/ui-new/UITask.cpp | 36 ++++++++---------- examples/companion_radio/ui-tiny/UITask.cpp | 42 ++++++++------------- examples/simple_repeater/UITask.cpp | 42 ++++++++------------- 3 files changed, 47 insertions(+), 73 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 581df7978b..70f2f94672 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -174,14 +174,20 @@ class HomeScreen : public UIScreen { // the display's right edge with the "NN%" value right-aligned just before // it, so both stay put while the value's width changes. Positive framing: // a full bar is good; it turns warning-coloured below 'warn_below'. - void drawHealthBar(DisplayDriver& display, int y, uint8_t pct, uint8_t warn_below) { - display.setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); + // 'no_data' renders "--%" with an empty, dimmed bar - nothing measured + // yet, so no verdict. + void drawHealthBar(DisplayDriver& display, int y, uint8_t pct, uint8_t warn_below, bool no_data = false) { + display.setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); const int bar_w = 24; int bar_x = display.width() - bar_w - 1; display.drawRect(bar_x, y + 1, bar_w, 7); - display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + if (!no_data) display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); char val[8]; - sprintf(val, "%u%%", pct); + if (no_data) { + strcpy(val, "--%"); + } else { + sprintf(val, "%u%%", pct); + } display.drawTextRightAlign(bar_x - 3, y, val); } @@ -350,28 +356,16 @@ class HomeScreen : public UIScreen { display.print("RX ready"); drawHealthBar(display, 46, 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality: windowed good vs total packet decodes as "NN%=good/total" - // (~10 min window, extrapolated while it fills after boot/reset) - the - // percentage scans like the bars above, the counts show the sample size - // and traffic level behind it + // RX quality: windowed good vs total packet decodes (~10 min window, + // extrapolated while it fills after boot/reset) as a uniform bar row like + // the two above; the underlying counts stay available via stats-radio display.setColor(UIColor::primary_txt); display.setCursor(0, 55); display.print("RX quality"); uint16_t rx_good = 0, rx_total = 0; radio_driver.getRxQualityCounts(rx_good, rx_total); - if (rx_total > 0) { - uint8_t rxq_pct = (uint8_t)((rx_good * 100u) / rx_total); - sprintf(tmp, "%u%%=%u/%u", rxq_pct, rx_good, rx_total); - // very large counts on a narrow display: drop the percentage, keep the counts - if (display.getTextWidth(tmp) + display.getTextWidth("RX quality") + 4 > display.width()) { - sprintf(tmp, "%u/%u", rx_good, rx_total); - } - display.setColor(rxq_pct < 80 ? UIColor::warning_txt : UIColor::primary_txt); - } else { - sprintf(tmp, "%u/%u", rx_good, rx_total); // quiet: no data, no verdict - display.setColor(UIColor::primary_txt); - } - display.drawTextRightAlign(display.width(), 55, tmp); + uint8_t rxq_pct = (rx_total > 0) ? (uint8_t)((rx_good * 100u) / rx_total) : 0; + drawHealthBar(display, 55, rxq_pct, 80, rx_total == 0); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 88cfcb9921..de6341debe 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -154,19 +154,25 @@ class HomeScreen : public UIScreen { // then the "NN%" value right-aligned before a bar pinned to the display's // right edge, so value and bar stay put while the value's width changes. // Positive framing: a full bar is good; it turns warning-coloured below - // 'warn_below'. - void drawHealthBar(DisplayDriver& display, int y, const char* label, uint8_t pct, uint8_t warn_below) { + // 'warn_below'. 'no_data' renders "--%" with an empty, dimmed bar - nothing + // measured yet, so no verdict. + void drawHealthBar(DisplayDriver& display, int y, const char* label, uint8_t pct, uint8_t warn_below, + bool no_data = false) { display.setColor(UIColor::primary_txt); display.setTextSize(1); display.setCursor(0, y); display.print(label); const int bar_w = 36; int bar_x = display.width() - bar_w - 1; - display.setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); + display.setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); display.drawRect(bar_x, y + 1, bar_w, 7); - display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + if (!no_data) display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); char val[8]; - sprintf(val, "%u%%", pct); + if (no_data) { + strcpy(val, "--%"); + } else { + sprintf(val, "%u%%", pct); + } display.drawTextRightAlign(bar_x - 3, y, val); } @@ -261,29 +267,13 @@ class HomeScreen : public UIScreen { drawHealthBar(display, 35, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); drawHealthBar(display, 44, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality: windowed good vs total decodes as "NN%=good/total" (~10 min - // window, extrapolated while it fills after boot/reset) - the percentage - // scans like the bars above, the counts show the sample size and traffic - // level behind it - display.setColor(UIColor::primary_txt); - display.setTextSize(1); - display.setCursor(0, 53); - display.print("RX quality"); + // RX quality: windowed good vs total packet decodes (~10 min window, + // extrapolated while it fills after boot/reset) as a uniform bar row like + // the two above; the underlying counts stay available via stats-radio uint16_t rx_good = 0, rx_total = 0; radio_driver.getRxQualityCounts(rx_good, rx_total); - if (rx_total > 0) { - uint8_t rxq_pct = (uint8_t)((rx_good * 100u) / rx_total); - sprintf(tmp, "%u%%=%u/%u", rxq_pct, rx_good, rx_total); - // very large counts on a narrow display: drop the percentage, keep the counts - if (display.getTextWidth(tmp) + display.getTextWidth("RX quality") + 4 > display.width()) { - sprintf(tmp, "%u/%u", rx_good, rx_total); - } - display.setColor(rxq_pct < 80 ? UIColor::warning_txt : UIColor::primary_txt); - } else { - sprintf(tmp, "%u/%u", rx_good, rx_total); // quiet: no data, no verdict - display.setColor(UIColor::primary_txt); - } - display.drawTextRightAlign(display.width(), 53, tmp); + uint8_t rxq_pct = (rx_total > 0) ? (uint8_t)((rx_good * 100u) / rx_total) : 0; + drawHealthBar(display, 53, "RX quality", rxq_pct, 80, rx_total == 0); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 998435c46d..7e9e7d96b0 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -33,19 +33,25 @@ static const uint8_t meshcore_logo [] PROGMEM = { // then the "NN%" value right-aligned before a bar pinned to the display's // right edge, so value and bar stay put while the value's width changes. // Positive framing: a full bar is good; it turns warning-coloured below -// 'warn_below'. -static void drawHealthBar(DisplayDriver* display, int y, const char* label, uint8_t pct, uint8_t warn_below) { +// 'warn_below'. 'no_data' renders "--%" with an empty, dimmed bar - nothing +// measured yet, so no verdict. +static void drawHealthBar(DisplayDriver* display, int y, const char* label, uint8_t pct, uint8_t warn_below, + bool no_data = false) { display->setTextSize(1); display->setColor(UIColor::primary_txt); display->setCursor(0, y); display->print(label); const int bar_w = 36; int bar_x = display->width() - bar_w - 1; - display->setColor(pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt); + display->setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); display->drawRect(bar_x, y + 1, bar_w, 7); - display->fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + if (!no_data) display->fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); char val[8]; - sprintf(val, "%u%%", pct); + if (no_data) { + strcpy(val, "--%"); + } else { + sprintf(val, "%u%%", pct); + } display->drawTextRightAlign(bar_x - 3, y, val); } @@ -131,29 +137,13 @@ void UITask::renderCurrScreen() { drawHealthBar(_display, 38, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); drawHealthBar(_display, 47, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - // RX quality: windowed good vs total decodes as "NN%=good/total" (~10 min - // window, extrapolated while it fills after boot/reset) - the percentage - // scans like the bars above, the counts show the sample size and traffic - // level behind it - _display->setColor(UIColor::primary_txt); - _display->setTextSize(1); - _display->setCursor(0, 56); - _display->print("RX quality"); + // RX quality: windowed good vs total packet decodes (~10 min window, + // extrapolated while it fills after boot/reset) as a uniform bar row like + // the two above; the underlying counts stay available via stats-radio uint16_t rx_good = 0, rx_total = 0; radio_driver.getRxQualityCounts(rx_good, rx_total); - if (rx_total > 0) { - uint8_t rxq_pct = (uint8_t)((rx_good * 100u) / rx_total); - sprintf(tmp, "%u%%=%u/%u", rxq_pct, rx_good, rx_total); - // very large counts on a narrow display: drop the percentage, keep the counts - if (_display->getTextWidth(tmp) + _display->getTextWidth("RX quality") + 4 > _display->width()) { - sprintf(tmp, "%u/%u", rx_good, rx_total); - } - _display->setColor(rxq_pct < 80 ? UIColor::warning_txt : UIColor::primary_txt); - } else { - sprintf(tmp, "%u/%u", rx_good, rx_total); // quiet: no data, no verdict - _display->setColor(UIColor::primary_txt); - } - _display->drawTextRightAlign(_display->width(), 56, tmp); + uint8_t rxq_pct = (rx_total > 0) ? (uint8_t)((rx_good * 100u) / rx_total) : 0; + drawHealthBar(_display, 56, "RX quality", rxq_pct, 80, rx_total == 0); } } From 04af452691879fcb9a0f1677143c4a0de0d83060 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 1 Sep 2026 06:39:00 +0000 Subject: [PATCH 12/14] Channel-health review fixes: SPI cost, packet classification, buffer 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 --- examples/companion_radio/ui-new/UITask.cpp | 58 ++++++--------------- examples/companion_radio/ui-tiny/UITask.cpp | 49 +++++------------ examples/simple_repeater/UITask.cpp | 49 +++++------------ src/Dispatcher.h | 12 +++-- src/helpers/StatsFormatHelper.h | 15 +++--- src/helpers/WindowedPercent.h | 32 +++++------- src/helpers/radiolib/RadioLibWrappers.cpp | 49 +++++++++++++---- src/helpers/radiolib/RadioLibWrappers.h | 29 +++++++---- src/helpers/ui/DisplayDriver.h | 28 ++++++++++ 9 files changed, 157 insertions(+), 164 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 70f2f94672..fdaca11f65 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -170,26 +170,7 @@ class HomeScreen : public UIScreen { #endif } - // Channel-health mini bar (battery-indicator pattern): the bar is pinned to - // the display's right edge with the "NN%" value right-aligned just before - // it, so both stay put while the value's width changes. Positive framing: - // a full bar is good; it turns warning-coloured below 'warn_below'. - // 'no_data' renders "--%" with an empty, dimmed bar - nothing measured - // yet, so no verdict. - void drawHealthBar(DisplayDriver& display, int y, uint8_t pct, uint8_t warn_below, bool no_data = false) { - display.setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); - const int bar_w = 24; - int bar_x = display.width() - bar_w - 1; - display.drawRect(bar_x, y + 1, bar_w, 7); - if (!no_data) display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); - char val[8]; - if (no_data) { - strcpy(val, "--%"); - } else { - sprintf(val, "%u%%", pct); - } - display.drawTextRightAlign(bar_x - 3, y, val); - } + // Channel-health bars use DisplayDriver::drawHealthBar (shared row helper). CayenneLPP sensors_lpp; int sensors_nb = 0; @@ -332,10 +313,10 @@ class HomeScreen : public UIScreen { // 5 rows at 9px pitch (text is 8px high) so all three channel-health // metrics render as uniform label + bar rows within a 128x64 display display.setTextSize(1); - // freq / sf + // freq / sf / tx power display.setColor(UIColor::primary_txt); display.setCursor(0, 19); - sprintf(tmp, "FQ:%06.3f SF%d", _node_prefs->freq, _node_prefs->sf); + sprintf(tmp, "FQ:%06.3f SF%d TX%d", _node_prefs->freq, _node_prefs->sf, _node_prefs->tx_power_dbm); display.print(tmp); // bw / cr, plus noise floor @@ -345,27 +326,18 @@ class HomeScreen : public UIScreen { sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor()); display.drawTextRightAlign(display.width(), 28, tmp); - // channel free % (100 - windowed utilization) with mini bar - display.setColor(UIColor::primary_txt); - display.setCursor(0, 37); - display.print("CH free"); - drawHealthBar(display, 37, 100 - radio_driver.getChannelUtilizationPct(), 50); - - // RX readiness % (100 - windowed deafness) with mini bar - display.setCursor(0, 46); - display.print("RX ready"); - drawHealthBar(display, 46, 100 - radio_driver.getRxDeafnessPct(), 80); - - // RX quality: windowed good vs total packet decodes (~10 min window, - // extrapolated while it fills after boot/reset) as a uniform bar row like - // the two above; the underlying counts stay available via stats-radio - display.setColor(UIColor::primary_txt); - display.setCursor(0, 55); - display.print("RX quality"); - uint16_t rx_good = 0, rx_total = 0; - radio_driver.getRxQualityCounts(rx_good, rx_total); - uint8_t rxq_pct = (rx_total > 0) ? (uint8_t)((rx_good * 100u) / rx_total) : 0; - drawHealthBar(display, 55, rxq_pct, 80, rx_total == 0); + // channel-health bars (windowed, positive framing: full bar = good); + // radios that measure nothing (e.g. ESP-NOW) render as no-data instead + // of a false "all healthy" bar + bool has_health = radio_driver.hasChannelHealth(); + display.drawHealthBar(37, "CH free", has_health ? 100 - radio_driver.getChannelUtilizationPct() : 0, 50, !has_health); + display.drawHealthBar(46, "RX ready", has_health ? 100 - radio_driver.getRxDeafnessPct() : 0, 80, !has_health); + + // RX quality: windowed good vs total packet decodes (~10 min window) as + // a uniform bar row like the two above; the underlying counts stay + // available via stats-radio + uint8_t rxq_pct = 0; + display.drawHealthBar(55, "RX quality", rxq_pct, 80, !radio_driver.getRxQualityPct(rxq_pct)); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index de6341debe..8ee9076125 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -150,31 +150,7 @@ class HomeScreen : public UIScreen { } } - // Channel-health bar row (battery-indicator pattern): label at the left, - // then the "NN%" value right-aligned before a bar pinned to the display's - // right edge, so value and bar stay put while the value's width changes. - // Positive framing: a full bar is good; it turns warning-coloured below - // 'warn_below'. 'no_data' renders "--%" with an empty, dimmed bar - nothing - // measured yet, so no verdict. - void drawHealthBar(DisplayDriver& display, int y, const char* label, uint8_t pct, uint8_t warn_below, - bool no_data = false) { - display.setColor(UIColor::primary_txt); - display.setTextSize(1); - display.setCursor(0, y); - display.print(label); - const int bar_w = 36; - int bar_x = display.width() - bar_w - 1; - display.setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); - display.drawRect(bar_x, y + 1, bar_w, 7); - if (!no_data) display.fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); - char val[8]; - if (no_data) { - strcpy(val, "--%"); - } else { - sprintf(val, "%u%%", pct); - } - display.drawTextRightAlign(bar_x - 3, y, val); - } + // Channel-health bars use DisplayDriver::drawHealthBar (shared row helper). int render(DisplayDriver& display) override { char tmp[80]; @@ -263,17 +239,18 @@ class HomeScreen : public UIScreen { sprintf(tmp, "TX%d", _node_prefs->tx_power_dbm); display.drawTextRightAlign(display.width(), 26, tmp); - // channel-health bars (windowed, positive framing: full bar = good) - drawHealthBar(display, 35, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); - drawHealthBar(display, 44, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - - // RX quality: windowed good vs total packet decodes (~10 min window, - // extrapolated while it fills after boot/reset) as a uniform bar row like - // the two above; the underlying counts stay available via stats-radio - uint16_t rx_good = 0, rx_total = 0; - radio_driver.getRxQualityCounts(rx_good, rx_total); - uint8_t rxq_pct = (rx_total > 0) ? (uint8_t)((rx_good * 100u) / rx_total) : 0; - drawHealthBar(display, 53, "RX quality", rxq_pct, 80, rx_total == 0); + // channel-health bars (windowed, positive framing: full bar = good); + // radios that measure nothing (e.g. ESP-NOW) render as no-data instead + // of a false "all healthy" bar + bool has_health = radio_driver.hasChannelHealth(); + display.drawHealthBar(35, "CH free", has_health ? 100 - radio_driver.getChannelUtilizationPct() : 0, 50, !has_health); + display.drawHealthBar(44, "RX ready", has_health ? 100 - radio_driver.getRxDeafnessPct() : 0, 80, !has_health); + + // RX quality: windowed good vs total packet decodes (~10 min window) as + // a uniform bar row like the two above; the underlying counts stay + // available via stats-radio + uint8_t rxq_pct = 0; + display.drawHealthBar(53, "RX quality", rxq_pct, 80, !radio_driver.getRxQualityPct(rxq_pct)); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 7e9e7d96b0..13c589b964 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -29,31 +29,7 @@ static const uint8_t meshcore_logo [] PROGMEM = { 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; -// Channel-health bar row (battery-indicator pattern): label at the left, -// then the "NN%" value right-aligned before a bar pinned to the display's -// right edge, so value and bar stay put while the value's width changes. -// Positive framing: a full bar is good; it turns warning-coloured below -// 'warn_below'. 'no_data' renders "--%" with an empty, dimmed bar - nothing -// measured yet, so no verdict. -static void drawHealthBar(DisplayDriver* display, int y, const char* label, uint8_t pct, uint8_t warn_below, - bool no_data = false) { - display->setTextSize(1); - display->setColor(UIColor::primary_txt); - display->setCursor(0, y); - display->print(label); - const int bar_w = 36; - int bar_x = display->width() - bar_w - 1; - display->setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); - display->drawRect(bar_x, y + 1, bar_w, 7); - if (!no_data) display->fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); - char val[8]; - if (no_data) { - strcpy(val, "--%"); - } else { - sprintf(val, "%u%%", pct); - } - display->drawTextRightAlign(bar_x - 3, y, val); -} +// Channel-health bars use DisplayDriver::drawHealthBar (shared row helper). void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; @@ -133,17 +109,18 @@ void UITask::renderCurrScreen() { sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); - // channel-health bars (windowed, positive framing: full bar = good) - drawHealthBar(_display, 38, "CH free", 100 - radio_driver.getChannelUtilizationPct(), 50); - drawHealthBar(_display, 47, "RX ready", 100 - radio_driver.getRxDeafnessPct(), 80); - - // RX quality: windowed good vs total packet decodes (~10 min window, - // extrapolated while it fills after boot/reset) as a uniform bar row like - // the two above; the underlying counts stay available via stats-radio - uint16_t rx_good = 0, rx_total = 0; - radio_driver.getRxQualityCounts(rx_good, rx_total); - uint8_t rxq_pct = (rx_total > 0) ? (uint8_t)((rx_good * 100u) / rx_total) : 0; - drawHealthBar(_display, 56, "RX quality", rxq_pct, 80, rx_total == 0); + // channel-health bars (windowed, positive framing: full bar = good); + // radios that measure nothing render as no-data instead of a false + // "all healthy" bar + bool has_health = radio_driver.hasChannelHealth(); + _display->drawHealthBar(38, "CH free", has_health ? 100 - radio_driver.getChannelUtilizationPct() : 0, 50, !has_health); + _display->drawHealthBar(47, "RX ready", has_health ? 100 - radio_driver.getRxDeafnessPct() : 0, 80, !has_health); + + // RX quality: windowed good vs total packet decodes (~10 min window) as a + // uniform bar row like the two above; the underlying counts stay + // available via stats-radio + uint8_t rxq_pct = 0; + _display->drawHealthBar(56, "RX quality", rxq_pct, 80, !radio_driver.getRxQualityPct(rxq_pct)); } } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index d471a4baed..2557b31a14 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -66,16 +66,20 @@ class Radio { /** * \brief windowed channel-health metrics: utilization/deafness over the * last ~5 observed seconds, RX-quality counts over the last ~10 - * minutes (extrapolated to the full window while it fills after a - * boot/reset). All use "0 = good" semantics; default 0 so radios - * that do not implement them (e.g. ESPNOW) degrade gracefully. + * minutes. All use "0 = good" semantics. hasChannelHealth() tells + * callers whether this radio measures them at all: radios that do + * not (e.g. ESPNOW) must be rendered as "no data" instead of a + * false "all healthy" 0%. */ + virtual bool hasChannelHealth() { return false; } virtual uint8_t getChannelUtilizationPct() { return 0; } // % of time the channel was busy virtual uint8_t getRxDeafnessPct() { return 0; } // % of time the radio was NOT in RX // Good vs total packet decodes in the RX-quality window (~10 min): 'total' // counts decodes plus SNR-relevant CRC failures (weak distant stations are - // excluded), 'good' the ones that decoded (passed CRC). + // excluded), 'good' the ones that decoded (passed CRC). The pct variant + // returns false while the window holds no events yet ("no data"). virtual void getRxQualityCounts(uint16_t& good, uint16_t& total) { good = 0; total = 0; } + virtual bool getRxQualityPct(uint8_t& pct) { pct = 0; return false; } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index c45cbcc696..06c5df7b52 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,16 +24,18 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { - // rx_good/rx_total: decodes vs (decodes + SNR-relevant CRC failures) over - // the ~10 min RX-quality window, extrapolated to the full window while it - // fills after a boot/reset. Weak distant-station failures are excluded. + // good/tot: decodes vs (decodes + SNR-relevant CRC failures) over the + // ~10 min RX-quality window. Weak distant-station failures are excluded. + // The new keys are deliberately SHORT: callers format this into a + // 160-byte CLI reply buffer and the 5 legacy fields already take ~110 + // bytes at large counter values - worst case here must stay below 160 + // incl. NUL (it peaks at ~151). The error % is derivable as + // 100 - good*100/tot and is not printed separately. uint16_t rx_good = 0, rx_total = 0; radio->getRxQualityCounts(rx_good, rx_total); - uint32_t rx_err_pct = (rx_total > 0) - ? ((uint32_t)(rx_total - rx_good) * 100) / rx_total : 0; sprintf(reply, "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," - "\"chan_util_pct\":%u,\"rx_deaf_pct\":%u,\"rx_err_pct\":%u,\"rx_good\":%u,\"rx_total\":%u}", + "\"util\":%u,\"deaf\":%u,\"good\":%u,\"tot\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), driver.getLastSNR(), @@ -41,7 +43,6 @@ class StatsFormatHelper { total_rx_air_time_ms / 1000, radio->getChannelUtilizationPct(), radio->getRxDeafnessPct(), - rx_err_pct, rx_good, rx_total ); diff --git a/src/helpers/WindowedPercent.h b/src/helpers/WindowedPercent.h index 69e7621502..23366f8f4c 100644 --- a/src/helpers/WindowedPercent.h +++ b/src/helpers/WindowedPercent.h @@ -47,6 +47,13 @@ class WindowedPercent { for (int i = 0; i < 5; i++) num += buckets[i]; return (den == 0) ? 0 : (uint8_t)((num * 100) / den); } + + // Forget everything (stats reset). last_ms is left alone: loop()-rate + // callers pass dt of only a few ms, so observation restarts at ~0. + void clear() { + for (int i = 0; i < 5; i++) buckets[i] = 0; + cur_active = 0; cur_total = 0; oldest = 0; filled = 0; + } }; /** @@ -62,7 +69,6 @@ class WindowedCountedRatio { uint16_t cur_ev, cur_bad; // current (partial) bucket uint32_t cur_ms; // ms accumulated toward the next bucket roll uint8_t oldest; - uint8_t filled; uint32_t last_ms; void advance(uint32_t now) { // roll completed buckets uint32_t dt = now - last_ms; last_ms = now; @@ -75,13 +81,12 @@ class WindowedCountedRatio { while (cur_ms >= BUCKET_MS) { // so no single dt ever fills a bucket by itself ev[oldest] = cur_ev; bad[oldest] = cur_bad; oldest = (oldest + 1) % N_BUCKETS; - if (filled < N_BUCKETS) filled++; cur_ev = 0; cur_bad = 0; // long stall: window just slides past cur_ms -= BUCKET_MS; } } public: - WindowedCountedRatio() : cur_ev(0), cur_bad(0), cur_ms(0), oldest(0), filled(0), last_ms(0) { + WindowedCountedRatio() : cur_ev(0), cur_bad(0), cur_ms(0), oldest(0), last_ms(0) { for (int i = 0; i < N_BUCKETS; i++) { ev[i] = 0; bad[i] = 0; } } @@ -92,28 +97,19 @@ class WindowedCountedRatio { } // Forget everything (stats reset). last_ms is left alone: loop()-rate - // callers pass dt of only a few ms, so post-clear observation restarts at ~0 - // and the warm-up extrapolation below applies again. + // callers pass dt of only a few ms, so post-clear observation restarts at ~0. void clear() { for (int i = 0; i < N_BUCKETS; i++) { ev[i] = 0; bad[i] = 0; } - cur_ev = 0; cur_bad = 0; cur_ms = 0; oldest = 0; filled = 0; + cur_ev = 0; cur_bad = 0; cur_ms = 0; oldest = 0; } - // Window totals: all events (attempts) and the failing subset. While the - // window is still FILLING (the first window-length after construction or - // clear()) the counts are extrapolated to the full window: events per - // observed time x window length. Rough at first (an early burst - // overshoots), but the number has full-window scale immediately and - // converges as the window fills. + // Window totals: all events (attempts) and the failing subset, saturated at + // 0xFFFF. Only the good/bad RATIO is meaningful for display; the absolute + // counts reflect what has actually been observed since construction or the + // last clear() (they grow to full-window scale as the window fills). void counts(uint16_t& n_ev, uint16_t& n_bad) const { uint32_t e = cur_ev, b = cur_bad; for (int i = 0; i < N_BUCKETS; i++) { e += ev[i]; b += bad[i]; } - uint32_t window_ms = (uint32_t)N_BUCKETS * BUCKET_MS; - uint32_t observed = (uint32_t)filled * BUCKET_MS + cur_ms; - if (observed >= BUCKET_MS && observed < window_ms) { // warm-up: scale up - e = (uint32_t)(((uint64_t)e * window_ms) / observed); - b = (uint32_t)(((uint64_t)b * window_ms) / observed); - } n_ev = (e > 0xFFFF) ? 0xFFFF : (uint16_t)e; n_bad = (b > 0xFFFF) ? 0xFFFF : (uint16_t)b; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b9cac8073a..a6aa296433 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -100,27 +100,42 @@ void RadioLibWrapper::resetAGC() { // channel-health metrics: stamp now so the first window has no phantom sample _last_metric_ms = _last_rssi_ms = millis(); _last_recv_cnt = n_recv; - _last_err_cnt = n_recv_errors; + _last_strong_err_cnt = n_recv_errors_strong; _cur_busy = false; } void RadioLibWrapper::loop() { // --- windowed channel-health metrics (time-weighted, loop-rate independent) --- // Busy covers what the radio cannot afford to miss: our own TX airtime (the - // receiver cannot measure while transmitting) and an in-progress reception; - // otherwise the verdict is the rate-limited RSSI poll above floor + margin. - // Deaf-but-not-TX windows (FIFO readout, TX turnaround, CAD scan, AGC warm - // sleep; each us..few ms) count as not-busy but stay in the denominator: - // a small, deliberate underestimate of utilization. + // receiver cannot measure while transmitting), an in-progress reception, or + // energy above floor + margin. The RX-based verdicts are sampled on the + // CHAN_BUSY_RSSI_INTERVAL_MS tick, NOT on every loop() call (the main loop + // spins at kHz on ESP32): 2 SPI transactions per tick instead of thousands + // per second. This is a pure observation margin and deliberately independent + // of the send gate's operator-configured verdict in isChannelActive() + // (int.thresh / CAD): the display should not change just because the + // operator retunes when the node is allowed to send. + // Deaf-but-not-TX windows (FIFO readout, TX turnaround; each us..few ms) + // count as not-busy but stay in the denominator: a small, deliberate + // underestimate of utilization. (CAD dwells are attributed by + // isChannelActive() itself, where they block.) uint32_t now = millis(); uint32_t dt = now - _last_metric_ms; _last_metric_ms = now; bool in_rx = isInRecvMode(); bool tx = ((state & ~STATE_INT_READY) == STATE_TX_WAIT); - if (tx || (in_rx && isReceivingPacket())) { + if (tx) { _cur_busy = true; } else if (in_rx && now - _last_rssi_ms >= CHAN_BUSY_RSSI_INTERVAL_MS) { _last_rssi_ms = now; - _cur_busy = (getCurrentRSSI() > _noise_floor + CHAN_BUSY_MARGIN); + // Never call isReceivingPacket() while a completed packet is unread + // (STATE_INT_READY): on SX126x its header-error branch clears HEADER_ERR, + // which readData() needs to classify the packet - clearing it beforehand + // would count a header-damaged packet as a good decode. The RSSI poll + // still marks the channel busy while that packet drains. + bool mid_rx = ((state & STATE_INT_READY) == 0) && isReceivingPacket(); + _cur_busy = mid_rx || (getCurrentRSSI() > _noise_floor + CHAN_BUSY_MARGIN); + } else if (!in_rx) { + _cur_busy = false; // out of RX without TX: nothing measurable, never hold a stale verdict } _busy_win.add(now, _cur_busy ? dt : 0); _deaf_win.add(now, in_rx ? 0 : dt); @@ -201,9 +216,13 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { // failed CRC indicates a collision/interference on THIS channel, while // a distant station below the decode threshold is expected to fail. // The packet-status SNR stays latched after the failed read (readData - // clears IRQ/FIFO state, not packet status). Weak failures drop out of - // both numerator and denominator of the window. - if (err == RADIOLIB_ERR_CRC_MISMATCH || err == RADIOLIB_ERR_LORA_HEADER_DAMAGED) { + // clears IRQ/FIFO state, not packet status) - but it is only + // trustworthy once ANY packet has latched a status: before that it + // reads the 0 dB reset value, which passes every threshold + guard. + // Header-damaged receptions may still read the previous packet's + // latch (the modem aborted before the payload): best effort. + // Weak failures drop out of both numerator and denominator. + if (_rx_snr_latched && (err == RADIOLIB_ERR_CRC_MISMATCH || err == RADIOLIB_ERR_LORA_HEADER_DAMAGED)) { uint8_t sf = getSpreadingFactor(); if (sf < 7) sf = 7; else if (sf > 12) sf = 12; float snr = getLastSNR(); @@ -216,6 +235,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } else { // Serial.print(" readData() -> "); Serial.println(len); n_recv++; + _rx_snr_latched = true; // a packet status is now latched -> SNR verdicts are meaningful } } #if defined(USE_LR2021) @@ -278,7 +298,14 @@ bool RadioLibWrapper::isChannelActive() { // cad: hardware channel activity detection if (_cad_enabled) { + // The CAD runs in standby (radio NOT listening) and blocks this thread for + // ms: attribute the dwell to the deafness window here, where it happens - + // once loop() next runs the radio is back in RX and the dwell would + // otherwise vanish from both metrics. + uint32_t cad_start = millis(); int16_t result = performChannelScan(); + uint32_t cad_end = millis(); + _deaf_win.add(cad_end, cad_end - cad_start); // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't // try to read a non-existent packet and count a spurious recv error. diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index effb7c88a4..f93cee6e4b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -27,12 +27,13 @@ class RadioLibWrapper : public mesh::Radio { // windowed channel-health metrics (sampled in loop()) WindowedPercent _busy_win; // channel busy: own TX, mid-receive, or energy above floor + margin WindowedPercent _deaf_win; // radio not in RX (listening) mode - WindowedCountedRatio<60, 10000> _err_win; // RX attempts with relevant CRC errors (~10 min window) - uint32_t _last_metric_ms; // stamp of previous loop() metric sample - uint32_t _last_rssi_ms; // rate limit for the RSSI busy poll - uint32_t _last_recv_cnt, _last_err_cnt; // previous packet counters (for deltas) - uint32_t _last_strong_err_cnt = 0; // previous SNR-relevant failure counter (for deltas) - bool _cur_busy; // last busy verdict (held between RSSI polls) + WindowedCountedRatio<> _err_win; // RX attempts with relevant CRC errors (~10 min window) + uint32_t _last_metric_ms = 0; // stamp of previous loop() metric sample + uint32_t _last_rssi_ms = 0; // rate limit for the RSSI busy poll + uint32_t _last_recv_cnt = 0; // previous packet counter (for deltas) + uint32_t _last_strong_err_cnt = 0; // previous SNR-relevant failure counter (for deltas) + bool _cur_busy = false; // last busy verdict (held between RSSI polls) + bool _rx_snr_latched = false; // any packet status latched: getLastSNR() is trustworthy void idle(); void startRecv(); @@ -71,6 +72,7 @@ class RadioLibWrapper : public mesh::Radio { virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } + bool hasChannelHealth() override { return true; } uint8_t getChannelUtilizationPct() override { return _busy_win.pct(); } uint8_t getRxDeafnessPct() override { return _deaf_win.pct(); } void getRxQualityCounts(uint16_t& good, uint16_t& total) override { @@ -79,6 +81,13 @@ class RadioLibWrapper : public mesh::Radio { total = ev; // all reception attempts good = ev - bad; // ...of which decoded OK } + bool getRxQualityPct(uint8_t& pct) override { + uint16_t ev, bad; + _err_win.counts(ev, bad); + if (ev == 0) { pct = 0; return false; } // nothing observed yet: no verdict + pct = (uint8_t)(((ev - bad) * 100u) / ev); + return true; + } void triggerNoiseFloorCalibrate(int threshold) override; void setCADEnabled(bool enable) override { _cad_enabled = enable; } void resetAGC() override; @@ -90,11 +99,13 @@ class RadioLibWrapper : public mesh::Radio { uint32_t getPacketsSent() const { return n_sent; } // Zeroing the counters without re-stamping the delta bases would underflow // the next loop() delta and inject a garbage spike into one ~10 min window - // bucket, so clear the window and stamps together with the counters. + // bucket, so clear the window and stamps together with the counters. All + // three channel-health windows are cleared so a stats reset produces a + // consistent all-metrics snapshot (the 5 s windows refill within seconds). void resetStats() { n_recv = n_sent = n_recv_errors = n_recv_errors_strong = 0; - _last_recv_cnt = 0; _last_err_cnt = 0; _last_strong_err_cnt = 0; - _err_win.clear(); + _last_recv_cnt = 0; _last_strong_err_cnt = 0; + _busy_win.clear(); _deaf_win.clear(); _err_win.clear(); } virtual float getLastRSSI() const override; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 3e9e2dde86..7b3936d72e 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -2,6 +2,7 @@ #include #include +#include using ColorVal = uint16_t; @@ -51,6 +52,33 @@ class DisplayDriver { setCursor(x_anch, y); print(str); } + + // Channel-health bar row (battery-indicator pattern): optional label at the + // left, then the "NN%" value right-aligned before a bar pinned to the + // display's right edge, so value and bar stay put while the value's width + // changes. Positive framing: a full bar is good; it turns warning-coloured + // below 'warn_below'. 'no_data' renders "--%" with an empty, dimmed bar - + // nothing measured yet, so no verdict. + void drawHealthBar(int y, const char* label, uint8_t pct, uint8_t warn_below, bool no_data = false) { + setTextSize(1); + if (label != NULL) { + setColor(UIColor::primary_txt); + setCursor(0, y); + print(label); + } + const int bar_w = 36; + int bar_x = width() - bar_w - 1; + setColor(no_data ? UIColor::secondary_txt : (pct < warn_below ? UIColor::warning_txt : UIColor::primary_txt)); + drawRect(bar_x, y + 1, bar_w, 7); + if (!no_data) fillRect(bar_x + 1, y + 2, (pct * (bar_w - 2)) / 100, 5); + char val[8]; + if (no_data) { + strcpy(val, "--%"); + } else { + sprintf(val, "%u%%", (unsigned)pct); + } + drawTextRightAlign(bar_x - 3, y, val); + } // convert UTF-8 characters to displayable block characters for compatibility virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { From 4c431f8b6580c856f3f69a91972fe00d03d45cf1 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 1 Sep 2026 18:08:38 +0000 Subject: [PATCH 13/14] Fix RX-quality bar always showing 0%: unsequenced argument read/write 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 (4111e11d), which computed the percentage before the draw call, showed ~90%. Sequence the fetch explicitly in all three UIs. Co-Authored-By: Claude --- examples/companion_radio/ui-new/UITask.cpp | 8 ++++++-- examples/companion_radio/ui-tiny/UITask.cpp | 8 ++++++-- examples/simple_repeater/UITask.cpp | 8 ++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index fdaca11f65..8de2100e78 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -335,9 +335,13 @@ class HomeScreen : public UIScreen { // RX quality: windowed good vs total packet decodes (~10 min window) as // a uniform bar row like the two above; the underlying counts stay - // available via stats-radio + // available via stats-radio. The fetch is sequenced separately from the + // draw call: passing rxq_pct by value AND by reference (getRxQualityPct) + // in one argument list is unsequenced read+write (undefined behavior) - + // the bar could receive the pre-call 0 instead of the measured value. uint8_t rxq_pct = 0; - display.drawHealthBar(55, "RX quality", rxq_pct, 80, !radio_driver.getRxQualityPct(rxq_pct)); + bool has_rxq = radio_driver.getRxQualityPct(rxq_pct); + display.drawHealthBar(55, "RX quality", rxq_pct, 80, !has_rxq); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 8ee9076125..7894587d5f 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -248,9 +248,13 @@ class HomeScreen : public UIScreen { // RX quality: windowed good vs total packet decodes (~10 min window) as // a uniform bar row like the two above; the underlying counts stay - // available via stats-radio + // available via stats-radio. The fetch is sequenced separately from the + // draw call: passing rxq_pct by value AND by reference (getRxQualityPct) + // in one argument list is unsequenced read+write (undefined behavior) - + // the bar could receive the pre-call 0 instead of the measured value. uint8_t rxq_pct = 0; - display.drawHealthBar(53, "RX quality", rxq_pct, 80, !radio_driver.getRxQualityPct(rxq_pct)); + bool has_rxq = radio_driver.getRxQualityPct(rxq_pct); + display.drawHealthBar(53, "RX quality", rxq_pct, 80, !has_rxq); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 13c589b964..bf56466a55 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -118,9 +118,13 @@ void UITask::renderCurrScreen() { // RX quality: windowed good vs total packet decodes (~10 min window) as a // uniform bar row like the two above; the underlying counts stay - // available via stats-radio + // available via stats-radio. The fetch is sequenced separately from the + // draw call: passing rxq_pct by value AND by reference (getRxQualityPct) + // in one argument list is unsequenced read+write (undefined behavior) - + // the bar could receive the pre-call 0 instead of the measured value. uint8_t rxq_pct = 0; - _display->drawHealthBar(56, "RX quality", rxq_pct, 80, !radio_driver.getRxQualityPct(rxq_pct)); + bool has_rxq = radio_driver.getRxQualityPct(rxq_pct); + _display->drawHealthBar(56, "RX quality", rxq_pct, 80, !has_rxq); } } From 5cf6f4be05bb81d3aae6a925f11382dacdf78f8c Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 1 Sep 2026 18:50:42 +0000 Subject: [PATCH 14/14] Channel-health: quiet-floor busy reference + RX-quality jam verdict 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 --- src/helpers/radiolib/RadioLibWrappers.cpp | 77 ++++++++++++++++++++++- src/helpers/radiolib/RadioLibWrappers.h | 30 ++++++--- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index a6aa296433..d3e7d18574 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -22,8 +22,28 @@ // Rate limit for the busy-verdict RSSI poll (one SPI transaction each). #define CHAN_BUSY_RSSI_INTERVAL_MS 50 +// RX-quality jam verdict: ambient-jam share of the last ~5 s that counts as +// "currently jammed"... +#define RXQ_JAM_MIN_PCT 80 +// ...combined with no decodable attempt for this long. A jammed channel produces +// zero header-valid IRQs, i.e. ZERO RX-quality events, so the pure event ratio +// would freeze at its last (healthy) value for the whole 10 min window - the row +// would read 100% exactly while nothing can be received. +#define RXQ_JAM_STALE_MS 120000 + static volatile uint8_t state = STATE_IDLE; +// In-place insertion sort of int16_t samples for the quiet-floor percentile. Runs +// once per calibration block (64 elements), so O(n^2) is irrelevant here. +static void sortInt16(int16_t* a, int n) { + for (int i = 1; i < n; i++) { + int16_t v = a[i]; + int j = i - 1; + while (j >= 0 && a[j] > v) { a[j + 1] = a[j]; j--; } + a[j + 1] = v; + } +} + // this function is called when a complete packet // is transmitted by the module static @@ -52,6 +72,10 @@ void RadioLibWrapper::begin() { // start average out some samples _num_floor_samples = 0; _floor_sample_sum = 0; + _quiet_floor_cnt = 0; + _quiet_floor_idx = 0; + _quiet_floor = 0; // busyRefFloor() falls back to _noise_floor (capped) until the ring fills + _cur_jam = false; } uint32_t RadioLibWrapper::getRngSeed() { @@ -102,6 +126,27 @@ void RadioLibWrapper::resetAGC() { _last_recv_cnt = n_recv; _last_strong_err_cnt = n_recv_errors_strong; _cur_busy = false; + _cur_jam = false; // (the quiet-floor ring stays: published history is not invalidated by an AFE reset) +} + +int16_t RadioLibWrapper::busyRefFloor() { + int16_t ref = (_quiet_floor_cnt >= QUIET_FLOOR_MIN_BLOCKS) ? _quiet_floor : _noise_floor; + if (ref > CHAN_BUSY_REF_MAX_DB) ref = CHAN_BUSY_REF_MAX_DB; + return ref; +} + +bool RadioLibWrapper::getRxQualityPct(uint8_t& pct) { + uint16_t ev, bad; + _err_win.counts(ev, bad); + if (ev == 0) { pct = 0; return false; } // nothing observed yet: no verdict + // Sustained interference with no decode attempt at all is a reception failure, + // not "no traffic": report 0% instead of a ratio frozen at its last healthy value. + if (_jam_win.pct() >= RXQ_JAM_MIN_PCT && millis() - _last_rxq_ev_ms >= RXQ_JAM_STALE_MS) { + pct = 0; + return true; + } + pct = (uint8_t)(((ev - bad) * 100u) / ev); + return true; } void RadioLibWrapper::loop() { @@ -125,6 +170,7 @@ void RadioLibWrapper::loop() { bool tx = ((state & ~STATE_INT_READY) == STATE_TX_WAIT); if (tx) { _cur_busy = true; + _cur_jam = false; // our own transmission is not ambient interference } else if (in_rx && now - _last_rssi_ms >= CHAN_BUSY_RSSI_INTERVAL_MS) { _last_rssi_ms = now; // Never call isReceivingPacket() while a completed packet is unread @@ -133,17 +179,28 @@ void RadioLibWrapper::loop() { // would count a header-damaged packet as a good decode. The RSSI poll // still marks the channel busy while that packet drains. bool mid_rx = ((state & STATE_INT_READY) == 0) && isReceivingPacket(); - _cur_busy = mid_rx || (getCurrentRSSI() > _noise_floor + CHAN_BUSY_MARGIN); + int16_t rssi = (int16_t)getCurrentRSSI(); + int16_t ref = busyRefFloor(); + _cur_busy = mid_rx || (rssi > ref + CHAN_BUSY_MARGIN); + // Ambient jam: the same energy test, but strictly against the QUIET reference and + // never while locked onto a preamble (that is a packet, not interference). This is + // the Dauerstoerer detector: the adapted _noise_floor follows a sustained + // interferer up to its level, so busy measured against _noise_floor would call the + // channel "free" exactly while nothing can be decoded. + _cur_jam = !mid_rx && (rssi > ref + CHAN_BUSY_MARGIN); } else if (!in_rx) { _cur_busy = false; // out of RX without TX: nothing measurable, never hold a stale verdict + _cur_jam = false; } _busy_win.add(now, _cur_busy ? dt : 0); _deaf_win.add(now, in_rx ? 0 : dt); + _jam_win.add(now, _cur_jam ? dt : 0); uint32_t r = n_recv, es = n_recv_errors_strong; // counter deltas -> RX-quality window uint16_t d_ok = (uint16_t)(r - _last_recv_cnt), d_err = (uint16_t)(es - _last_strong_err_cnt); // events = decodes + SNR-relevant CRC failures (weak distant stations are // excluded in recvRaw, from both numerator and denominator), bad = those failures _err_win.add(now, d_ok + d_err, d_err); + if (d_ok + d_err > 0) _last_rxq_ev_ms = now; _last_recv_cnt = r; _last_strong_err_cnt = es; // --- noise floor sampling --- @@ -162,6 +219,24 @@ void RadioLibWrapper::loop() { } _floor_sample_sum = 0; + // Quiet-floor ring: retain the published values and keep their 10th percentile as + // the busy-verdict reference. The adapted floor can drift (ratchet) or follow a + // sustained interferer (other estimator lineages); the quietest decile of the last + // several minutes stays near the real ambient, and CHAN_BUSY_REF_MAX_DB bounds even + // a jam that outlives the ring. Recovers on its own once quiet blocks return. + _quiet_floor_ring[_quiet_floor_idx] = _noise_floor; + _quiet_floor_idx = (_quiet_floor_idx + 1) % QUIET_FLOOR_BLOCKS; + if (_quiet_floor_cnt < QUIET_FLOOR_BLOCKS) _quiet_floor_cnt++; + { + int16_t sorted[QUIET_FLOOR_BLOCKS]; + for (uint8_t i = 0; i < _quiet_floor_cnt; i++) sorted[i] = _quiet_floor_ring[i]; + sortInt16(sorted, _quiet_floor_cnt); + _quiet_floor = sorted[_quiet_floor_cnt / 10]; + #ifdef MESH_DEBUG_NOISE_FLOOR + MESH_DEBUG_PRINTLN("RadioLibWrapper: quiet_floor = %d (P10 of %u blocks)", (int)_quiet_floor, _quiet_floor_cnt); + #endif + } + #ifdef MESH_DEBUG_NOISE_FLOOR MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); #endif diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index f93cee6e4b..bffcbe58cb 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -4,6 +4,15 @@ #include #include +#define QUIET_FLOOR_BLOCKS 64 // published noise-floor values retained for the quiet-floor percentile. A block + // spans a calibration cycle, so this ring covers the last several minutes +#define QUIET_FLOOR_MIN_BLOCKS 8 // ring fill required before the percentile is trusted (before that the busy + // verdict falls back to the current noise floor, as before) +#define CHAN_BUSY_REF_MAX_DB -100 // absolute cap on the busy-verdict reference floor: ambient noise this high is + // interference, not a quiet channel the node merely adapted to. Keeps a + // multi-hour jammer visible in the utilization even once the ring has filled + // with contaminated floor values + #ifdef USE_CC310_HW_CRYPTO #include #endif @@ -22,21 +31,34 @@ class RadioLibWrapper : public mesh::Radio { bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; + int16_t _quiet_floor_ring[QUIET_FLOOR_BLOCKS]; // recently published noise-floor values + uint8_t _quiet_floor_cnt; // ring fill level (grows to QUIET_FLOOR_BLOCKS) + uint8_t _quiet_floor_idx; // next slot to overwrite + int16_t _quiet_floor; // P10 of the ring: the busy-verdict reference (see busyRefFloor()) uint8_t _preamble_sf; // windowed channel-health metrics (sampled in loop()) WindowedPercent _busy_win; // channel busy: own TX, mid-receive, or energy above floor + margin WindowedPercent _deaf_win; // radio not in RX (listening) mode + WindowedPercent _jam_win; // ambient energy far above the QUIET floor (interference, not our traffic) WindowedCountedRatio<> _err_win; // RX attempts with relevant CRC errors (~10 min window) uint32_t _last_metric_ms = 0; // stamp of previous loop() metric sample uint32_t _last_rssi_ms = 0; // rate limit for the RSSI busy poll uint32_t _last_recv_cnt = 0; // previous packet counter (for deltas) uint32_t _last_strong_err_cnt = 0; // previous SNR-relevant failure counter (for deltas) + uint32_t _last_rxq_ev_ms = 0; // millis() of the last RX-quality window event (staleness vs jam) bool _cur_busy = false; // last busy verdict (held between RSSI polls) + bool _cur_jam = false; // last ambient-jam verdict (held between RSSI polls) bool _rx_snr_latched = false; // any packet status latched: getLastSNR() is trustworthy void idle(); void startRecv(); + // Reference floor for the channel-busy verdict: the quietest decile of recently + // published noise floors, absolutely capped. Unlike the adapted _noise_floor + // (which must follow a sustained interferer for LBT), this stays near the real + // ambient so a Dauerstoerer keeps the utilization high instead of hiding under + // its own adapted floor. + int16_t busyRefFloor(); float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); @@ -81,13 +103,7 @@ class RadioLibWrapper : public mesh::Radio { total = ev; // all reception attempts good = ev - bad; // ...of which decoded OK } - bool getRxQualityPct(uint8_t& pct) override { - uint16_t ev, bad; - _err_win.counts(ev, bad); - if (ev == 0) { pct = 0; return false; } // nothing observed yet: no verdict - pct = (uint8_t)(((ev - bad) * 100u) / ev); - return true; - } + bool getRxQualityPct(uint8_t& pct) override; // defined in the .cpp: needs millis() for the jam-staleness policy void triggerNoiseFloorCalibrate(int threshold) override; void setCADEnabled(bool enable) override { _cad_enabled = enable; } void resetAGC() override;