diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 969ac3dd4a..8de2100e78 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -170,6 +170,8 @@ class HomeScreen : public UIScreen { #endif } + // Channel-health bars use DisplayDriver::drawHealthBar (shared row helper). + CayenneLPP sensors_lpp; int sensors_nb = 0; bool sensors_scroll = false; @@ -308,24 +310,38 @@ class HomeScreen : public UIScreen { display.print(tmp); } } else if (_page == HomePage::RADIO) { - display.setColor(UIColor::primary_txt); + // 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 - display.setCursor(0, 20); - sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); - display.print(tmp); - - display.setCursor(0, 31); - sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + // freq / sf / tx power + display.setColor(UIColor::primary_txt); + display.setCursor(0, 19); + sprintf(tmp, "FQ:%06.3f SF%d TX%d", _node_prefs->freq, _node_prefs->sf, _node_prefs->tx_power_dbm); display.print(tmp); - // tx power, noise floor - display.setCursor(0, 42); - sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); - display.print(tmp); - display.setCursor(0, 53); - sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); + // 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-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. 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; + 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 b6bdbcf4bd..7894587d5f 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -150,6 +150,8 @@ class HomeScreen : public UIScreen { } } + // Channel-health bars use DisplayDriver::drawHealthBar (shared row helper). + int render(DisplayDriver& display) override { char tmp[80]; @@ -237,6 +239,23 @@ 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); + // 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. 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; + 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); display.drawXbm((display.width() - 32) / 2, 8, diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e7225557dd..bf56466a55 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -29,6 +29,8 @@ 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 bars use DisplayDriver::drawHealthBar (shared row helper). + void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; _auto_off = millis() + AUTO_OFF_MILLIS; @@ -106,6 +108,23 @@ 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); + // 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. 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; + bool has_rxq = radio_driver.getRxQualityPct(rxq_pct); + _display->drawHealthBar(56, "RX quality", rxq_pct, 80, !has_rxq); } } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec..2557b31a14 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -63,6 +63,24 @@ class Radio { virtual int getNoiseFloor() const { return 0; } + /** + * \brief windowed channel-health metrics: utilization/deafness over the + * last ~5 observed seconds, RX-quality counts over the last ~10 + * 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). 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) { } virtual void setCADEnabled(bool enable) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133e9..06c5df7b52 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,13 +24,27 @@ 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}", + // 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); + sprintf(reply, + "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," + "\"util\":%u,\"deaf\":%u,\"good\":%u,\"tot\":%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(), + rx_good, + rx_total ); } diff --git a/src/helpers/WindowedPercent.h b/src/helpers/WindowedPercent.h new file mode 100644 index 0000000000..23366f8f4c --- /dev/null +++ b/src/helpers/WindowedPercent.h @@ -0,0 +1,116 @@ +#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); + } + + // 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; + } +}; + +/** + * \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[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; + uint32_t last_ms; + void advance(uint32_t now) { // roll completed buckets + uint32_t dt = now - last_ms; last_ms = now; + 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) % N_BUCKETS; + 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), last_ms(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. + void add(uint32_t now, uint16_t n_ev, uint16_t n_bad) { + advance(now); + cur_ev += n_ev; cur_bad += n_bad; + } + + // 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. + 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; + } + + // 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]; } + 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 e4d2ba1c27..d3e7d18574 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,8 +13,37 @@ #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 + +// 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 @@ -41,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() { @@ -85,9 +120,90 @@ 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_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() { + // --- 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), 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) { + _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 + // (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(); + 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 --- if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { int rssi = getCurrentRSSI(); @@ -103,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 @@ -125,6 +259,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) { @@ -136,9 +286,31 @@ 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) - 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(); + 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++; + _rx_snr_latched = true; // a packet status is now latched -> SNR verdicts are meaningful } } #if defined(USE_LR2021) @@ -201,7 +373,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. @@ -220,16 +399,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 77dd93116b..bffcbe58cb 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,16 @@ #include #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 @@ -16,20 +26,45 @@ 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; 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(); 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 = n_recv_errors_strong = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -59,6 +94,16 @@ 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 { + uint16_t ev, bad; + _err_win.counts(ev, bad); + total = ev; // all reception attempts + good = ev - bad; // ...of which decoded OK + } + 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; @@ -68,7 +113,16 @@ 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. 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_strong_err_cnt = 0; + _busy_win.clear(); _deaf_win.clear(); _err_win.clear(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() 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) {