From a1330facfff45f40b0b8bde16f4aef5aeb7cb5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 9 Jul 2026 14:39:20 +0200 Subject: [PATCH] Improved RX duty-cycle watchdog observation with esp32 sleep in mind --- examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_room_server/MyMesh.cpp | 1 + src/helpers/radiolib/RadioLibWrappers.cpp | 41 +++++++++++++++++++++-- src/helpers/radiolib/RadioLibWrappers.h | 17 ++++++++-- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index d73c6632..f57de659 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1464,5 +1464,6 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif + if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle return _mgr->getOutboundTotal() > 0; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 0eed525f..43f479a0 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1049,5 +1049,6 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif + if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle return _mgr->getOutboundTotal() > 0; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 203fe163..082be007 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -88,7 +88,12 @@ void RadioLibWrapper::resetAGC() { void RadioLibWrapper::rxPsWatchdogCheck() { // don't interfere mid-transmit or with a completed-but-unread packet - if ((state & STATE_INT_READY) != 0 || (state & ~STATE_INT_READY) == STATE_TX_WAIT) return; + // (a pending DIO1 event is itself proof the radio is alive; recvRaw() will + // re-arm and re-base the watchdog) + if ((state & STATE_INT_READY) != 0 || (state & ~STATE_INT_READY) == STATE_TX_WAIT) { + _wd_observe_until = 0; + return; + } unsigned long now = millis(); bool tripped = false; @@ -96,12 +101,35 @@ void RadioLibWrapper::rxPsWatchdogCheck() { if (_rx_ps_armed && state == STATE_RX && _wd_stuck_thresh > 0) { bool busy = isChipBusy(); if (busy != _wd_last_busy) { + // the sleep/listen wave is present -> radio healthy _wd_last_busy = busy; _wd_last_transition = now; - _wd_stage = 0; // the sleep/listen wave is present -> radio healthy + _wd_stage = 0; + _wd_strikes = 0; + _wd_observe_until = 0; + } else if (_wd_observe_until != 0) { + // active observation window in progress (MCU kept awake via + // isWatchdogObserving()); a healthy chip must toggle BUSY within it + if ((long)(now - _wd_observe_until) >= 0) { + _wd_observe_until = 0; + if (!busy && isReceivingPacket()) { + // BUSY held low by an ongoing reception (extended RX) - alive + _wd_last_transition = now; + _wd_strikes = 0; + } else if (++_wd_strikes >= 2) { + _wd_strikes = 0; + tripped = true; + } else { + _wd_last_transition = now; // full threshold before the next window + } + } } else if (now - _wd_last_transition > _wd_stuck_thresh) { - tripped = true; // BUSY frozen: chip fell out of the duty cycle with no IRQ + // no proof of life for too long: actively watch one full cycle + _wd_observe_until = now + _wd_observe_ms; + if (_wd_observe_until == 0) _wd_observe_until = 1; // 0 means "off" } + } else { + _wd_observe_until = 0; } if (_startrx_fails >= 3) tripped = true; // can't even re-arm receive mode @@ -109,6 +137,7 @@ void RadioLibWrapper::rxPsWatchdogCheck() { _wd_last_transition = now; // grace period before the next escalation _startrx_fails = 0; + _wd_observe_until = 0; if (_wd_stage == 0) { _wd_stage = 1; @@ -168,9 +197,15 @@ void RadioLibWrapper::startRecv() { // Longest legitimate silence on the BUSY pin: one full cycle, plus the // extended RX after a (possibly false) preamble detect (2*rx + sleep), // plus a worst-case packet airtime, plus margin for TCXO/transitions. + // Floored at 60s so a light-sleeping MCU (ESP32 wakes every ~30s) opens + // an observation window every couple of wakeups instead of on each one. uint32_t rx_ms = _rx_ps_rx_us / 1000, sleep_ms = _rx_ps_sleep_us / 1000; _wd_stuck_thresh = (rx_ms + sleep_ms) + 2 * (2 * rx_ms + sleep_ms) + getEstAirtimeFor(MAX_TRANS_UNIT) + 1000; + if (_wd_stuck_thresh < 60000) _wd_stuck_thresh = 60000; + // active observation window must cover one full duty cycle + _wd_observe_ms = rx_ms + sleep_ms + 50; + if (_wd_observe_ms > 1500) _wd_observe_ms = 1500; } } else { if (_startrx_fails < 255) _startrx_fails++; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 6ecf26c9..3f226ebb 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -28,11 +28,18 @@ protected: // RX duty-cycle watchdog: a healthy duty cycle shows a square wave on the // BUSY pin (high in the sleep window / TCXO warmup, low while listening). // If the wave stops, the chip fell out of the cycle without an IRQ. + // Passive sampling only works while the main loop spins; on MCUs that light + // sleep between wakeups the watchdog instead opens an "active observation" + // window (isWatchdogObserving() keeps the MCU awake) spanning one full radio + // cycle - a healthy chip must toggle BUSY within it. bool _wd_last_busy; uint8_t _wd_stage; // 0 = healthy, 1 = soft re-arm done, 2 = hard reset done + uint8_t _wd_strikes; // consecutive failed observation windows uint8_t _startrx_fails; // consecutive startReceiveMode() failures - unsigned long _wd_last_transition; // millis of last BUSY level change - unsigned long _wd_stuck_thresh; // ms without a transition considered stuck + unsigned long _wd_last_transition; // millis of last BUSY level change (proof of life) + unsigned long _wd_stuck_thresh; // ms without proof of life before observing + unsigned long _wd_observe_until; // 0 = not observing, else millis deadline + uint32_t _wd_observe_ms; // observation window: one full cycle + margin uint32_t n_wd_soft, n_wd_hard; // last applied radio settings, reapplied after a hard radio reset @@ -64,7 +71,8 @@ public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), _rx_ps_rx_us(RX_PS_FALLBACK_RX_US), _rx_ps_sleep_us(RX_PS_FALLBACK_SLEEP_US), - _wd_last_busy(false), _wd_stage(0), _startrx_fails(0), _wd_last_transition(0), _wd_stuck_thresh(0), + _wd_last_busy(false), _wd_stage(0), _wd_strikes(0), _startrx_fails(0), _wd_last_transition(0), + _wd_stuck_thresh(0), _wd_observe_until(0), _wd_observe_ms(0), _params_valid(false), _dbm_valid(false) { n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0; } void begin() override; @@ -106,6 +114,9 @@ public: uint32_t getPacketsSent() const { return n_sent; } uint32_t getRxPsWatchdogSoftCount() const { return n_wd_soft; } uint32_t getRxPsWatchdogHardCount() const { return n_wd_hard; } + // true while the watchdog is actively watching for BUSY transitions; used by + // the app's hasPendingWork() to keep the MCU out of light sleep for the window + bool isWatchdogObserving() const { return _wd_observe_until != 0; } void resetStats() { n_recv = n_sent = n_recv_errors = 0; } virtual float getLastRSSI() const override;