From e97f0fecc6cc396d4510b7f7d616a1b7b49cf306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Wed, 8 Jul 2026 08:49:37 +0200 Subject: [PATCH 1/6] Added RX duty-cycle power saving support --- examples/companion_radio/MyMesh.cpp | 50 ++++++ examples/simple_repeater/MyMesh.cpp | 13 ++ examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 7 + examples/simple_room_server/MyMesh.h | 1 + examples/simple_sensor/SensorMesh.cpp | 7 + examples/simple_sensor/SensorMesh.h | 1 + src/Dispatcher.h | 3 + src/helpers/CommonCLI.cpp | 198 ++++++++++++++++++++- src/helpers/CommonCLI.h | 15 ++ src/helpers/radiolib/CustomLR1110.h | 36 +++- src/helpers/radiolib/CustomLR1110Wrapper.h | 21 +++ src/helpers/radiolib/CustomSX1262Wrapper.h | 25 +++ src/helpers/radiolib/RadioLibWrappers.cpp | 39 +++- src/helpers/radiolib/RadioLibWrappers.h | 16 +- 15 files changed, 419 insertions(+), 14 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 10578511..c514b04c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -267,6 +267,50 @@ bool MyMesh::getCADEnabled() const { return true; // hardware CAD before TX (no CLI toggle on companion; enabled by default) } +#ifdef RXPS_FIXED_ENABLED +static uint32_t ceilPositiveFloat(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; +} + +static bool calcFixedRxPowerSaving(uint8_t sf, float bw, uint32_t* rx_us, uint32_t* sleep_us) { + if (RXPS_FIXED_LEVEL < 1 || RXPS_FIXED_LEVEL > 10 || sf < 5 || sf > 12 || + bw <= 0.0f || (RXPS_FIXED_PREAMBLE != 16 && RXPS_FIXED_PREAMBLE != 32)) { + return false; + } + + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float amount = (float)(RXPS_FIXED_LEVEL - 1) / 9.0f; + const float rx_start_symbols = RXPS_FIXED_PREAMBLE == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = RXPS_FIXED_PREAMBLE == 16 ? 2.0f : 15.0f; + const float rx_edge_symbols = 8.0f; + const float sleep_edge_symbols = (float)RXPS_FIXED_PREAMBLE + 4.25f - 8.0f; + + const float rx_symbols = rx_start_symbols + amount * (rx_edge_symbols - rx_start_symbols); + const float sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + + *rx_us = ceilPositiveFloat(rx_symbols * symbol_us); + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + return true; +} + +static void applyFixedRxPowerSaving(uint8_t sf, float bw) { + uint32_t rx_us, sleep_us; + if (!calcFixedRxPowerSaving(sf, bw, &rx_us, &sleep_us)) { + MESH_DEBUG_PRINTLN("RX Power Saving fixed profile invalid"); + return; + } + + bool ok = radio_driver.setRxPowerSaving(true, rx_us, sleep_us); + MESH_DEBUG_PRINTLN("RX Power Saving fixed level %d p%d: %s (%lu/%lu us)", + RXPS_FIXED_LEVEL, + RXPS_FIXED_PREAMBLE, + ok ? "Enabled" : "Unsupported", + (unsigned long)rx_us, + (unsigned long)sleep_us); +} +#endif + int MyMesh::calcRxDelay(float score, uint32_t air_time) const { if (_prefs.rx_delay_base <= 0.0f) return 0; return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); @@ -972,6 +1016,9 @@ void MyMesh::begin(bool has_display) { radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); +#ifdef RXPS_FIXED_ENABLED + applyFixedRxPowerSaving(_prefs.sf, _prefs.bw); +#endif MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); } @@ -1397,6 +1444,9 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +#ifdef RXPS_FIXED_ENABLED + applyFixedRxPowerSaving(_prefs.sf, _prefs.bw); +#endif MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 68cc3685..fdd93be7 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -896,6 +896,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') + _prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; + _prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -970,6 +972,7 @@ void MyMesh::begin(FILESYSTEM *fs) { MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); + setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -1064,6 +1067,16 @@ void MyMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } +bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + bool ok = radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); + MESH_DEBUG_PRINTLN("RX Power Saving: %s (%lu/%lu us)%s", + enable ? "Enabled" : "Disabled", + (unsigned long)rx_us, + (unsigned long)sleep_us, + ok ? "" : " unsupported"); + return ok; +} + #if defined(USE_SX1262) || defined(USE_SX1268) void MyMesh::setRxBoostedGain(bool enable) { radio_driver.setRxBoostedGainMode(enable); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index c2d662a2..a37c8f74 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -212,6 +212,7 @@ public: void dumpLogFile() override; void setTxPower(int8_t power_dbm) override; + bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; void formatNeighborsReply(char *reply) override; void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c311c941..9cd14647 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -651,6 +651,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') + _prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; + _prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; #ifdef ROOM_PASSWORD StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password)); #endif @@ -702,6 +704,7 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); + setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -808,6 +811,10 @@ void MyMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } +bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 380e54da..6205f31a 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -206,6 +206,7 @@ public: void dumpLogFile() override; void setTxPower(int8_t power_dbm) override; + bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 055c579d..7dc256cb 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -730,6 +730,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.flood_max = 64; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') + _prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; + _prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; // GPS defaults _prefs.gps_enabled = 0; @@ -770,6 +772,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); + setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -849,6 +852,10 @@ void SensorMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } +bool SensorMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); +} + void SensorMesh::formatStatsReply(char *reply) { StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 1d65b877..e110eaca 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -68,6 +68,7 @@ public: void eraseLogFile() override { } void dumpLogFile() override { } void setTxPower(int8_t power_dbm) override; + bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3..3adf283e 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -71,6 +71,9 @@ public: virtual bool isInRecvMode() const = 0; + virtual bool supportsRxPowerSaving() const { return false; } + virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { return !enabled; } + /** * \returns true if the radio is currently mid-receive of a packet. */ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 23efe7fa..9a505ded 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -29,6 +29,80 @@ static bool isValidName(const char *n) { return true; } +static bool isValidRxPowerSavingPeriod(uint32_t us) { + return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US; +} + +// MeshCore preamble convention used for the RX powersaving timing calculation. +// Must stay in sync with RadioLibWrapper::preambleLengthForSF() (the value the +// radio actually transmits); kept local here because CommonCLI is radio-agnostic. +static uint16_t rxPowerSavingPreambleForSF(uint8_t sf) { + return sf <= 8 ? 32 : 16; +} + +static bool isNumeric(const char* sp) { + if (!sp || !*sp) return false; + while (*sp) { + if (*sp < '0' || *sp > '9') return false; + sp++; + } + return true; +} + +static uint32_t ceilPositiveFloat(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; +} + +static bool calcRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, + uint32_t* rx_us, uint32_t* sleep_us) { + if (level < 1 || level > 10 || sf < 5 || sf > 12 || bw <= 0.0f || (preamble != 16 && preamble != 32)) { + return false; + } + + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float amount = (float)(level - 1) / 9.0f; + const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; + const float rx_edge_symbols = 8.0f; + const float sleep_edge_symbols = (float)preamble + 4.25f - 8.0f; + + const float rx_symbols = rx_start_symbols + amount * (rx_edge_symbols - rx_start_symbols); + const float sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + + *rx_us = ceilPositiveFloat(rx_symbols * symbol_us); + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + return true; +} + +static void ensureRxPowerSavingDefaults(NodePrefs* prefs) { + if (!isValidRxPowerSavingPeriod(prefs->rx_ps_rx_us)) { + prefs->rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; + } + if (!isValidRxPowerSavingPeriod(prefs->rx_ps_sleep_us)) { + prefs->rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + } +} + +// Recomputes rx_ps_rx_us/rx_ps_sleep_us from the stored level and the current +// radio SF/BW. No-op (returns false) for manual timings (rx_ps_level == 0). +// Lets level-based RX powersaving auto-retune when SF/BW change. +static bool recalcRxPowerSavingFromLevel(NodePrefs* prefs) { + if (prefs->rx_ps_level < 1 || prefs->rx_ps_level > 10) return false; // manual: nothing to recompute + uint32_t preamble = prefs->rx_ps_preamble ? prefs->rx_ps_preamble + : rxPowerSavingPreambleForSF(prefs->sf); + uint32_t rx_us, sleep_us; + if (!calcRxPowerSavingLevel(prefs->rx_ps_level, prefs->sf, prefs->bw, preamble, &rx_us, &sleep_us)) { + return false; + } + if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) { + return false; + } + prefs->rx_ps_rx_us = rx_us; + prefs->rx_ps_sleep_us = sleep_us; + return true; +} + void CommonCLI::loadPrefs(FILESYSTEM* fs) { if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); // new filename @@ -96,7 +170,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.read((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled)); // 295 + file.read((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us)); // 296 + file.read((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 300 + file.read((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 304 + file.read((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 305 + // next: 306 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -129,6 +208,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean + _prefs->rx_powersaving_enabled = constrain(_prefs->rx_powersaving_enabled, 0, 1); + _prefs->rx_ps_level = constrain(_prefs->rx_ps_level, 0, 10); + if (_prefs->rx_ps_preamble != 16 && _prefs->rx_ps_preamble != 32) { + _prefs->rx_ps_preamble = 0; // 0 = auto (derive from SF) + } + ensureRxPowerSavingDefaults(_prefs); + recalcRxPowerSavingFromLevel(_prefs); // retune level-based timings to the loaded SF/BW file.close(); } @@ -195,7 +281,12 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.write((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled)); // 295 + file.write((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us)); // 296 + file.write((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 300 + file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 304 + file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 305 + // next: 306 file.close(); } @@ -635,6 +726,100 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error: state must be on or off"); } + } else if (memcmp(config, "radio.rxps ", 11) == 0) { + const char* value = &config[11]; + uint8_t enable = _prefs->rx_powersaving_enabled; + uint32_t rx_us = _prefs->rx_ps_rx_us; + uint32_t sleep_us = _prefs->rx_ps_sleep_us; + uint32_t level = 0; + uint32_t preamble = rxPowerSavingPreambleForSF(_prefs->sf); + bool level_requested = false; + bool preamble_overridden = false; + + ensureRxPowerSavingDefaults(_prefs); + rx_us = _prefs->rx_ps_rx_us; + sleep_us = _prefs->rx_ps_sleep_us; + + if (strcmp(value, "off") == 0) { + enable = 0; + } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + enable = 1; + rx_us = RX_POWERSAVING_DEFAULT_RX_US; + sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + } else if (strcmp(value, "balanced") == 0) { + enable = 1; + rx_us = RX_POWERSAVING_DEFAULT_RX_US; + sleep_us = RX_POWERSAVING_BALANCED_SLEEP_US; + } else { + StrHelper::strncpy(tmp, value, sizeof(tmp)); + const char *parts[4]; + int num = mesh::Utils::parseTextParts(tmp, parts, 4, ' '); + if (num == 1 && isNumeric(parts[0])) { + level = _atoi(parts[0]); + level_requested = true; + enable = 1; + } else if (num == 2 && strcmp(parts[0], "level") == 0 && isNumeric(parts[1])) { + level = _atoi(parts[1]); + level_requested = true; + enable = 1; + } else if (num == 4 && strcmp(parts[0], "level") == 0 && isNumeric(parts[1]) && + strcmp(parts[2], "preamble") == 0 && isNumeric(parts[3])) { + level = _atoi(parts[1]); + preamble = _atoi(parts[3]); + level_requested = true; + preamble_overridden = true; + enable = 1; + } else if (num == 2 && isNumeric(parts[0]) && isNumeric(parts[1])) { + rx_us = _atoi(parts[0]); + sleep_us = _atoi(parts[1]); + enable = 1; + } else { + strcpy(reply, "ERROR: use off|on|conservative|balanced|level <1-10>| "); + return; + } + + if (level_requested && !calcRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { + strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + return; + } + } + + if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) { + sprintf(reply, "ERROR: range is %lu-%lu us", + (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); + return; + } + + if (!_callbacks->setRxPowerSaving(enable, rx_us, sleep_us)) { + strcpy(reply, "ERROR: RX powersaving unsupported"); + return; + } + + _prefs->rx_powersaving_enabled = enable; + _prefs->rx_ps_rx_us = rx_us; + _prefs->rx_ps_sleep_us = sleep_us; + if (level_requested) { + // Remember the intent so the timings can auto-retune when SF/BW change. + _prefs->rx_ps_level = level; + _prefs->rx_ps_preamble = preamble_overridden ? preamble : 0; // 0 = auto (derive from SF) + } else if (strcmp(value, "off") != 0) { + // on/conservative/balanced/manual timings are fixed, not level-derived. + _prefs->rx_ps_level = 0; + _prefs->rx_ps_preamble = 0; + } + savePrefs(); + if (level_requested) { + sprintf(reply, "OK - level %lu,%s,%lu,%lu,preamble=%lu", + (unsigned long)level, + enable ? "on" : "off", + (unsigned long)rx_us, + (unsigned long)sleep_us, + (unsigned long)preamble); + } else { + sprintf(reply, "OK - %s,%lu,%lu", enable ? "on" : "off", + (unsigned long)rx_us, (unsigned long)sleep_us); + } } else if (memcmp(config, "radio ", 6) == 0) { strcpy(tmp, &config[6]); const char *parts[4]; @@ -648,8 +833,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->cr = cr; _prefs->freq = freq; _prefs->bw = bw; + // Retune level-based RX powersaving to the new SF/BW. Persist only; the + // radio itself is "reboot to apply", and begin() re-arms the timings then. + bool rxps_retuned = recalcRxPowerSavingFromLevel(_prefs); _callbacks->savePrefs(); - strcpy(reply, "OK - reboot to apply"); + strcpy(reply, rxps_retuned ? "OK - reboot to apply (rxps retuned)" : "OK - reboot to apply"); } else { strcpy(reply, "Error, invalid radio params"); } @@ -893,6 +1081,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "> %s", _board->isLoRaFemLnaEnabled() ? "on" : "off"); } + } else if (memcmp(config, "radio.rxps", 10) == 0) { + ensureRxPowerSavingDefaults(_prefs); + sprintf(reply, "> %s,%lu,%lu", _prefs->rx_powersaving_enabled ? "on" : "off", + (unsigned long)_prefs->rx_ps_rx_us, (unsigned long)_prefs->rx_ps_sleep_us); } else if (memcmp(config, "radio", 5) == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 695d2053..6c1e839e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -19,6 +19,12 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +#define RX_POWERSAVING_DEFAULT_RX_US 65625UL +#define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL +#define RX_POWERSAVING_BALANCED_SLEEP_US 80000UL +#define RX_POWERSAVING_MIN_PERIOD_US 1000UL +#define RX_POWERSAVING_MAX_PERIOD_US 30000000UL + struct NodePrefs { // persisted to file float airtime_factor; char node_name[32]; @@ -66,6 +72,11 @@ struct NodePrefs { // persisted to file uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean) + uint8_t rx_powersaving_enabled; // boolean + uint32_t rx_ps_rx_us; + uint32_t rx_ps_sleep_us; + uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change) + uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc }; class CommonCLICallbacks { @@ -115,6 +126,10 @@ public: virtual void setRxBoostedGain(bool enable) { // no op by default }; + + virtual bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + return !enable; + }; }; class CommonCLI { diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 4061c6b1..5d1618cb 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -24,6 +24,40 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK) { + // RadioLib's LR11x0 duty-cycle path stages RX but does not call + // launchMode(), where the software RF switch is normally set to RX. + uint32_t transitionTime = this->tcxoDelay + 1000; + sleepPeriod -= transitionTime; + + uint32_t rxPeriodRaw = (rxPeriod * 32768UL) / 1000000UL; + uint32_t sleepPeriodRaw = (sleepPeriod * 32768UL) / 1000000UL; + + if ((rxPeriodRaw & 0xFF000000) || (rxPeriodRaw == 0)) { + return RADIOLIB_ERR_INVALID_RX_PERIOD; + } + + if ((sleepPeriodRaw & 0xFF000000) || (sleepPeriodRaw == 0)) { + return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + } + + RadioModeConfig_t cfg = { + .receive = { + .timeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF, + .irqFlags = irqFlags, + .irqMask = irqMask, + .len = 0, + } + }; + int16_t state = this->stageMode(RADIOLIB_RADIO_MODE_RX, &cfg); + RADIOLIB_ASSERT(state); + + this->mod->setRfSwitchState(Module::MODE_RX); + return this->setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + } + int16_t setRxBoostedGainMode(bool en) { _rx_boosted = en; return LR1110::setRxBoostedGainMode(en); @@ -38,4 +72,4 @@ class CustomLR1110 : public LR1110 { } uint8_t getSpreadingFactor() const { return spreadingFactor; } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 13efd25b..9f459d8a 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -31,6 +31,27 @@ public: _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int startReceiveMode() override { + if (!_rx_ps_enabled) { + return _radio->startReceive(); + } + + const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; + const RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK; + + int err = ((CustomLR1110 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask); + if (err == RADIOLIB_ERR_NONE) { + return err; + } + + MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err); + return _radio->startReceive(); + } + +public: float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index cc7bb223..22d5a2f2 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -38,6 +38,31 @@ public: ((CustomSX1262 *)_radio)->sleep(false); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int startReceiveMode() override { + if (!_rx_ps_enabled) { + return _radio->startReceive(); + } + + const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; + const RadioLibIrqFlags_t irqMask = + (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR); + + int err = ((CustomSX1262 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask); + if (err == RADIOLIB_ERR_NONE) { + return err; + } + + MESH_DEBUG_PRINTLN("CustomSX1262Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err); + return _radio->startReceive(); + } + +public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } void setRxBoostedGainMode(bool en) override { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 66606362..22df970d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -85,7 +85,11 @@ void RadioLibWrapper::resetAGC() { } void RadioLibWrapper::loop() { - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + // In RX duty-cycle (powersaving) mode the radio auto-sleeps between listen + // windows, so getCurrentRSSI() would read a sleeping frontend and corrupt the + // noise floor. Skip adaptive sampling; note this also degrades interference + // detection (isChannelActive) while powersaving is enabled. + if (!_rx_ps_enabled && state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { int rssi = getCurrentRSSI(); if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD @@ -105,18 +109,40 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { - int err = _radio->startReceive(); + int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); + MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceiveMode(%d)", err); } } +int RadioLibWrapper::startReceiveMode() { + return _radio->startReceive(); +} + bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } +bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + if (enabled && !supportsRxPowerSaving()) { + return false; + } + + _rx_ps_enabled = enabled; + _rx_ps_rx_us = rx_us; + _rx_ps_sleep_us = sleep_us; + // Force the next recvRaw() to arm the requested RX mode, but don't clobber a + // completed-but-unread packet (STATE_INT_READY): recvRaw() will consume it and + // then re-arm with the new mode. Also leave an in-flight TX alone. (Same + // non-atomic guard style as resetAGC().) + if ((state & STATE_INT_READY) == 0 && (state & ~STATE_INT_READY) != STATE_TX_WAIT) { + state = STATE_IDLE; + } + return true; +} + int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { @@ -137,12 +163,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } if (state != STATE_RX) { - int err = _radio->startReceive(); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; - } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); - } + startRecv(); } return len; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 9943bcab..af3609f2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -3,6 +3,13 @@ #include #include +// Fallback RX powersaving timings, only used until setRxPowerSaving() is called +// (begin() always applies the persisted values). The authoritative defaults live +// in CommonCLI.h as RX_POWERSAVING_DEFAULT_RX_US / _SLEEP_US and are delivered +// via NodePrefs; keep these mirrored. +#define RX_PS_FALLBACK_RX_US 65625UL +#define RX_PS_FALLBACK_SLEEP_US 60000UL + class RadioLibWrapper : public mesh::Radio { protected: PhysicalLayer* _radio; @@ -13,15 +20,21 @@ protected: uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; + bool _rx_ps_enabled; + uint32_t _rx_ps_rx_us; + uint32_t _rx_ps_sleep_us; void idle(); void startRecv(); + virtual int startReceiveMode(); 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), _rx_ps_enabled(false), + _rx_ps_rx_us(RX_PS_FALLBACK_RX_US), _rx_ps_sleep_us(RX_PS_FALLBACK_SLEEP_US) { n_recv = n_sent = n_recv_errors = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -31,6 +44,7 @@ public: bool isSendComplete() override; void onSendFinished() override; bool isInRecvMode() const override; + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; bool isChannelActive(); bool isReceiving() override { From 38034d2ee2fb35abb3ad8396d6683650566385bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Wed, 8 Jul 2026 14:25:14 +0200 Subject: [PATCH 2/6] Fixed RX duty-cycle rearming and profile timings --- src/helpers/CommonCLI.cpp | 23 +++++---- src/helpers/CommonCLI.h | 8 +++- src/helpers/radiolib/CustomLR1110.h | 6 +++ src/helpers/radiolib/CustomLR1110Wrapper.h | 14 +++++- src/helpers/radiolib/CustomSX1262.h | 24 ++++++++++ src/helpers/radiolib/CustomSX1262Wrapper.h | 13 ++++++ src/helpers/radiolib/RadioLibWrappers.cpp | 54 ++++++++++++++++------ src/helpers/radiolib/RadioLibWrappers.h | 5 +- 8 files changed, 122 insertions(+), 25 deletions(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9a505ded..83eb2066 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -744,12 +744,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep enable = 0; } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { enable = 1; - rx_us = RX_POWERSAVING_DEFAULT_RX_US; - sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + level = RX_POWERSAVING_CONSERVATIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; } else if (strcmp(value, "balanced") == 0) { enable = 1; - rx_us = RX_POWERSAVING_DEFAULT_RX_US; - sleep_us = RX_POWERSAVING_BALANCED_SLEEP_US; + level = RX_POWERSAVING_BALANCED_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; } else { StrHelper::strncpy(tmp, value, sizeof(tmp)); const char *parts[4]; @@ -777,11 +781,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "ERROR: use off|on|conservative|balanced|level <1-10>| "); return; } + } - if (level_requested && !calcRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { - strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); - return; - } + if (level_requested && !calcRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { + strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + return; } if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) { @@ -804,7 +808,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->rx_ps_level = level; _prefs->rx_ps_preamble = preamble_overridden ? preamble : 0; // 0 = auto (derive from SF) } else if (strcmp(value, "off") != 0) { - // on/conservative/balanced/manual timings are fixed, not level-derived. + // manual timings are fixed, not level-derived + // (the named profiles set level_requested and are handled above) _prefs->rx_ps_level = 0; _prefs->rx_ps_preamble = 0; } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 6c1e839e..46d55ccc 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -21,10 +21,16 @@ #define RX_POWERSAVING_DEFAULT_RX_US 65625UL #define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL -#define RX_POWERSAVING_BALANCED_SLEEP_US 80000UL #define RX_POWERSAVING_MIN_PERIOD_US 1000UL #define RX_POWERSAVING_MAX_PERIOD_US 30000000UL +// The named profiles are level presets pinned to a 16-symbol preamble: most +// deployed senders still transmit 16-symbol preambles regardless of the newer +// SF-based rule (32 for SF <= 8). Revisit once the field has largely migrated. +#define RX_POWERSAVING_CONSERVATIVE_LEVEL 1UL +#define RX_POWERSAVING_BALANCED_LEVEL 5UL +#define RX_POWERSAVING_PROFILE_PREAMBLE 16UL + struct NodePrefs { // persisted to file float airtime_factor; char node_name[32]; diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 5d1618cb..95e51ba4 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -66,6 +66,12 @@ class CustomLR1110 : public LR1110 { bool getRxBoostedGainMode() const { return _rx_boosted; } bool isReceiving() { + // BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid + // command, so it cannot be mid-receive - and the SPI read below would + // stall until the chip's next listen window. + uint32_t busy = this->mod->getGpio(); + if (busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy)) return false; + uint16_t irq = getIrqStatus(); bool detected = ((irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) || (irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED)); return detected; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 9f459d8a..f5e7dbff 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -35,15 +35,27 @@ public: protected: int startReceiveMode() override { + if (_rx_ps_armed) { + // stop the previous duty-cycle sequence with an explicit standby before + // reconfiguring the radio + stopReceiveDutyCycle(); + } if (!_rx_ps_enabled) { return _radio->startReceive(); } const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; - const RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK; + // route RX timeout (false preamble detect) and error IRQs to the IRQ pin + // as well, so the wrapper re-arms RX instead of waiting forever + const RadioLibIrqFlags_t irqMask = + (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR); int err = ((CustomLR1110 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask); if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; return err; } diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index ad201229..87b6b548 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -87,11 +87,35 @@ class CustomSX1262 : public SX1262 { } bool isReceiving() { + // BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid + // command, so it cannot be mid-receive - and the SPI read below would + // stall until the chip's next listen window. + uint32_t busy = this->mod->getGpio(); + if (busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy)) return false; + uint16_t irq = getIrqFlags(); bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); return detected; } + // Port of Semtech's sx126x_stop_rtc() (same registers as RadioLib's + // fixImplicitTimeout / datasheet errata 15.3): after duty-cycle RX ends via + // RxDone or SetStandby, the internal RTC keeps running and its pending + // event can silently knock a subsequently started RX back to standby with + // no IRQ, leaving the node deaf. Must be called before re-arming RX. + int16_t stopRTC() { + uint8_t rtcStop = 0x00; + int16_t state = writeRegister(RADIOLIB_SX126X_REG_RTC_CTRL, &rtcStop, 1); + RADIOLIB_ASSERT(state); + + uint8_t rtcEvent = 0; + state = readRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + RADIOLIB_ASSERT(state); + + rtcEvent |= 0x02; // clear the RX timeout event + return writeRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + } + bool getRxBoostedGainMode() { uint8_t rxGain = 0; readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 22d5a2f2..6e880c0e 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -42,6 +42,12 @@ public: protected: int startReceiveMode() override { + if (_rx_ps_armed) { + // leaving duty-cycle mode (after RxDone or a reconfig): stop the + // sequencer and the still-running RTC, or its pending event can + // silently abort the RX we are about to start + stopReceiveDutyCycle(); + } if (!_rx_ps_enabled) { return _radio->startReceive(); } @@ -55,6 +61,7 @@ protected: int err = ((CustomSX1262 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask); if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; return err; } @@ -62,6 +69,12 @@ protected: return _radio->startReceive(); } + void stopReceiveDutyCycle() override { + _radio->standby(); // also wakes the chip if it is in the sleep window + ((CustomSX1262 *)_radio)->stopRTC(); + _rx_ps_armed = false; + } + public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 22df970d..66001102 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -121,6 +121,24 @@ int RadioLibWrapper::startReceiveMode() { return _radio->startReceive(); } +void RadioLibWrapper::stopReceiveDutyCycle() { + // The duty-cycle sequencer only stops on RxDone or an explicit standby; + // issuing other mode commands while it runs leads to undefined behaviour. + _radio->standby(); + _rx_ps_armed = false; +} + +bool RadioLibWrapper::isPacketReady() { + if (!_rx_ps_armed) return true; // continuous RX: DIO1 only fires for RxDone/TxDone here + + // In duty-cycle RX the DIO1 interrupt also fires for RX timeout (false + // preamble detect) and header errors. GetRxBufferStatus still reports the + // *previous* packet's length then, so reading the buffer would re-deliver + // stale bytes as a ghost packet. Only read when the radio reports RxDone. + // (checkIrq errors are treated as ready, falling back to old behaviour.) + return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; +} + bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } @@ -146,17 +164,19 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { - len = _radio->getPacketLength(); - if (len > 0) { - if (len > sz) { len = sz; } - int err = _radio->readData(bytes, len); - if (err != RADIOLIB_ERR_NONE) { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); - len = 0; - n_recv_errors++; - } else { - // Serial.print(" readData() -> "); Serial.println(len); - n_recv++; + if (isPacketReady()) { + len = _radio->getPacketLength(); + if (len > 0) { + if (len > sz) { len = sz; } + int err = _radio->readData(bytes, len); + if (err != RADIOLIB_ERR_NONE) { + MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); + len = 0; + n_recv_errors++; + } else { + // Serial.print(" readData() -> "); Serial.println(len); + n_recv++; + } } } state = STATE_IDLE; // need another startReceive() @@ -173,6 +193,11 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + if (_rx_ps_armed) { + // stop the duty-cycle sequencer before SetTx, otherwise its next RTC + // event can fire mid-transmission and abort the TX + stopReceiveDutyCycle(); + } _board->onBeforeTransmit(); int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { @@ -205,8 +230,11 @@ int16_t RadioLibWrapper::performChannelScan() { } bool RadioLibWrapper::isChannelActive() { - // int.thresh: RSSI-based interference detection (relative to noise floor) - if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; + // int.thresh: RSSI-based interference detection (relative to noise floor). + // Skipped in RX duty-cycle mode: the frontend sleeps part of the time, so an + // instantaneous RSSI is meaningless and the SPI read would block until the + // chip's next listen window. + if (!_rx_ps_enabled && _threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; // cad: hardware channel activity detection if (_cad_enabled) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index af3609f2..12f3e4e4 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -21,19 +21,22 @@ protected: int32_t _floor_sample_sum; uint8_t _preamble_sf; bool _rx_ps_enabled; + bool _rx_ps_armed; // radio is currently in RX duty-cycle mode uint32_t _rx_ps_rx_us; uint32_t _rx_ps_sleep_us; void idle(); void startRecv(); virtual int startReceiveMode(); + virtual void stopReceiveDutyCycle(); + virtual bool isPacketReady(); 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), _rx_ps_enabled(false), + : _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) { n_recv = n_sent = n_recv_errors = 0; } void begin() override; From 92d09c84ef6329031baef20d65a577ed75fb3cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Wed, 8 Jul 2026 23:49:35 +0200 Subject: [PATCH 3/6] Fixed RX power saving RSSI sampling during listen windows --- src/helpers/radiolib/CustomLR1110.h | 13 ++++++++----- src/helpers/radiolib/CustomLR1110Wrapper.h | 3 +++ src/helpers/radiolib/CustomSX1262.h | 13 ++++++++----- src/helpers/radiolib/CustomSX1262Wrapper.h | 5 ++++- src/helpers/radiolib/RadioLibWrappers.cpp | 19 +++++++++---------- src/helpers/radiolib/RadioLibWrappers.h | 3 +++ 6 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 95e51ba4..e4f9d06f 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -65,12 +65,15 @@ class CustomLR1110 : public LR1110 { bool getRxBoostedGainMode() const { return _rx_boosted; } - bool isReceiving() { - // BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid - // command, so it cannot be mid-receive - and the SPI read below would - // stall until the chip's next listen window. + // BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid + // command; any SPI access would stall until the chip's next listen window. + bool isChipBusy() { uint32_t busy = this->mod->getGpio(); - if (busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy)) return false; + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + + bool isReceiving() { + if (isChipBusy()) return false; // asleep, cannot be mid-receive uint16_t irq = getIrqStatus(); bool detected = ((irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) || (irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED)); diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index f5e7dbff..56aec8be 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -20,6 +20,9 @@ public: bool isReceivingPacket() override { return ((CustomLR1110 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomLR1110 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { float rssi = -110; ((CustomLR1110 *)_radio)->getRssiInst(&rssi); diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 87b6b548..f24604e8 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -86,12 +86,15 @@ class CustomSX1262 : public SX1262 { return true; // success } - bool isReceiving() { - // BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid - // command, so it cannot be mid-receive - and the SPI read below would - // stall until the chip's next listen window. + // BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid + // command; any SPI access would stall until the chip's next listen window. + bool isChipBusy() { uint32_t busy = this->mod->getGpio(); - if (busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy)) return false; + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + + bool isReceiving() { + if (isChipBusy()) return false; // asleep, cannot be mid-receive uint16_t irq = getIrqFlags(); bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 6e880c0e..585ba4a9 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -20,9 +20,12 @@ public: updatePreamble(sf); } - bool isReceivingPacket() override { + bool isReceivingPacket() override { return ((CustomSX1262 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomSX1262 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 66001102..f93bf350 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -85,12 +85,11 @@ void RadioLibWrapper::resetAGC() { } void RadioLibWrapper::loop() { - // In RX duty-cycle (powersaving) mode the radio auto-sleeps between listen - // windows, so getCurrentRSSI() would read a sleeping frontend and corrupt the - // noise floor. Skip adaptive sampling; note this also degrades interference - // detection (isChannelActive) while powersaving is enabled. - if (!_rx_ps_enabled && state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!isReceivingPacket()) { + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + // In RX duty-cycle (powersaving) mode only sample while the chip is in a + // listen window: during the sleep window the frontend is off, so the RSSI + // is meaningless and the SPI read would stall until the next window. + if (!(_rx_ps_armed && isChipBusy()) && !isReceivingPacket()) { int rssi = getCurrentRSSI(); if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD _num_floor_samples++; @@ -231,10 +230,10 @@ int16_t RadioLibWrapper::performChannelScan() { bool RadioLibWrapper::isChannelActive() { // int.thresh: RSSI-based interference detection (relative to noise floor). - // Skipped in RX duty-cycle mode: the frontend sleeps part of the time, so an - // instantaneous RSSI is meaningless and the SPI read would block until the - // chip's next listen window. - if (!_rx_ps_enabled && _threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; + // In RX duty-cycle mode only checked while the chip is in a listen window + // (during the sleep window the frontend is off and the read would stall). + if (_threshold != 0 && !(_rx_ps_armed && isChipBusy()) + && getCurrentRSSI() > _noise_floor + _threshold) return true; // cad: hardware channel activity detection if (_cad_enabled) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 12f3e4e4..a4236982 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -30,6 +30,9 @@ protected: virtual int startReceiveMode(); virtual void stopReceiveDutyCycle(); virtual bool isPacketReady(); + // true while the chip cannot service SPI (RX duty-cycle sleep window, or + // briefly while processing a command); radios expose this via the BUSY pin + virtual bool isChipBusy() { return false; } float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); From 9cbd944a293adf9cb773f82436d4117df4e314dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 9 Jul 2026 12:00:15 +0200 Subject: [PATCH 4/6] Added RX duty-cycle watchdog recovery --- examples/simple_repeater/MyMesh.cpp | 5 ++ examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 5 ++ examples/simple_room_server/MyMesh.h | 1 + examples/simple_sensor/SensorMesh.cpp | 5 ++ examples/simple_sensor/SensorMesh.h | 1 + src/helpers/CommonCLI.cpp | 4 ++ src/helpers/CommonCLI.h | 4 ++ src/helpers/radiolib/CustomSX1262Wrapper.h | 7 +++ src/helpers/radiolib/RadioLibWrappers.cpp | 62 ++++++++++++++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 29 +++++++++- 11 files changed, 123 insertions(+), 1 deletion(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index fdd93be7..d73c6632 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1076,6 +1076,11 @@ bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { ok ? "" : " unsupported"); return ok; } +void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { + *soft = radio_driver.getRxPsWatchdogSoftCount(); + *hard = radio_driver.getRxPsWatchdogHardCount(); +} + #if defined(USE_SX1262) || defined(USE_SX1268) void MyMesh::setRxBoostedGain(bool enable) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a37c8f74..a3fabc5b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -213,6 +213,7 @@ public: void dumpLogFile() override; void setTxPower(int8_t power_dbm) override; bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; void formatNeighborsReply(char *reply) override; void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9cd14647..0eed525f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -814,6 +814,11 @@ void MyMesh::setTxPower(int8_t power_dbm) { bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); } +void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { + *soft = radio_driver.getRxPsWatchdogSoftCount(); + *hard = radio_driver.getRxPsWatchdogHardCount(); +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 6205f31a..ff4b951a 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -207,6 +207,7 @@ public: void dumpLogFile() override; void setTxPower(int8_t power_dbm) override; bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7dc256cb..2cb120d0 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -855,6 +855,11 @@ void SensorMesh::setTxPower(int8_t power_dbm) { bool SensorMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); } +void SensorMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { + *soft = radio_driver.getRxPsWatchdogSoftCount(); + *hard = radio_driver.getRxPsWatchdogHardCount(); +} + void SensorMesh::formatStatsReply(char *reply) { StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index e110eaca..e9c9f485 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -69,6 +69,7 @@ public: void dumpLogFile() override { } void setTxPower(int8_t power_dbm) override; bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 83eb2066..4b094343 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1090,6 +1090,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep ensureRxPowerSavingDefaults(_prefs); sprintf(reply, "> %s,%lu,%lu", _prefs->rx_powersaving_enabled ? "on" : "off", (unsigned long)_prefs->rx_ps_rx_us, (unsigned long)_prefs->rx_ps_sleep_us); + } else if (memcmp(config, "rxps.wd", 7) == 0) { + uint32_t wd_soft, wd_hard; + _callbacks->getRxPsWatchdogCounts(&wd_soft, &wd_hard); + sprintf(reply, "> soft=%lu,hard=%lu", (unsigned long)wd_soft, (unsigned long)wd_hard); } else if (memcmp(config, "radio", 5) == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 46d55ccc..6ec5aa01 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -136,6 +136,10 @@ public: virtual bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return !enable; }; + + virtual void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { + *soft = 0; *hard = 0; + }; }; class CommonCLI { diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 585ba4a9..b1b9ce57 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -13,6 +13,7 @@ public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + cacheParams(freq, bw, sf, cr); ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -78,6 +79,12 @@ protected: _rx_ps_armed = false; } + bool radioDeepInit() override { + // std_init() re-runs RadioLib begin(), which starts with a hardware reset + // via NRST - the only way out of a hard-locked chip (BUSY stuck high). + return ((CustomSX1262 *)_radio)->std_init(); + } + public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index f93bf350..203fe163 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -48,6 +48,8 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + _cur_dbm = dbm; + _dbm_valid = true; _radio->setOutputPower(dbm); } @@ -84,7 +86,54 @@ void RadioLibWrapper::resetAGC() { _floor_sample_sum = 0; } +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; + + unsigned long now = millis(); + bool tripped = false; + + if (_rx_ps_armed && state == STATE_RX && _wd_stuck_thresh > 0) { + bool busy = isChipBusy(); + if (busy != _wd_last_busy) { + _wd_last_busy = busy; + _wd_last_transition = now; + _wd_stage = 0; // the sleep/listen wave is present -> radio healthy + } else if (now - _wd_last_transition > _wd_stuck_thresh) { + tripped = true; // BUSY frozen: chip fell out of the duty cycle with no IRQ + } + } + if (_startrx_fails >= 3) tripped = true; // can't even re-arm receive mode + + if (!tripped) return; + + _wd_last_transition = now; // grace period before the next escalation + _startrx_fails = 0; + + if (_wd_stage == 0) { + _wd_stage = 1; + n_wd_soft++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: watchdog: RX duty-cycle stuck, soft re-arm"); + state = STATE_IDLE; // next recvRaw() re-arms receive mode + } else { + _wd_stage = 2; + n_wd_hard++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: watchdog: still stuck, hard radio reset"); + if (radioDeepInit()) { + _rx_ps_armed = false; // chip is factory-fresh after NRST + _radio->setPacketReceivedAction(setFlag); + if (_params_valid) setParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr); + if (_dbm_valid) _radio->setOutputPower(_cur_dbm); + } + state = STATE_IDLE; // re-arm (rx powersaving settings are kept in members) + } +} + void RadioLibWrapper::loop() { + if (_rx_ps_enabled) { + rxPsWatchdogCheck(); + } + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { // In RX duty-cycle (powersaving) mode only sample while the chip is in a // listen window: during the sleep window the frontend is off, so the RSSI @@ -111,7 +160,20 @@ void RadioLibWrapper::startRecv() { int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; + _startrx_fails = 0; + if (_rx_ps_armed) { + // (re)base the duty-cycle watchdog on the freshly armed cycle + _wd_last_busy = isChipBusy(); + _wd_last_transition = millis(); + // 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. + 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; + } } else { + if (_startrx_fails < 255) _startrx_fails++; MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceiveMode(%d)", err); } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index a4236982..6ecf26c9 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -25,14 +25,37 @@ protected: uint32_t _rx_ps_rx_us; uint32_t _rx_ps_sleep_us; + // 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. + bool _wd_last_busy; + uint8_t _wd_stage; // 0 = healthy, 1 = soft re-arm done, 2 = hard reset done + 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 + uint32_t n_wd_soft, n_wd_hard; + + // last applied radio settings, reapplied after a hard radio reset + float _cur_freq, _cur_bw; + uint8_t _cur_sf, _cur_cr; + int8_t _cur_dbm; + bool _params_valid, _dbm_valid; + void idle(); void startRecv(); + void rxPsWatchdogCheck(); + void cacheParams(float freq, float bw, uint8_t sf, uint8_t cr) { + _cur_freq = freq; _cur_bw = bw; _cur_sf = sf; _cur_cr = cr; _params_valid = true; + } virtual int startReceiveMode(); virtual void stopReceiveDutyCycle(); virtual bool isPacketReady(); // true while the chip cannot service SPI (RX duty-cycle sleep window, or // briefly while processing a command); radios expose this via the BUSY pin virtual bool isChipBusy() { return false; } + // full radio recovery: hardware reset (NRST) + re-init to boot defaults; + // returns false if unsupported. Caller reapplies cached runtime params. + virtual bool radioDeepInit() { return false; } float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); @@ -40,7 +63,9 @@ protected: 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) { n_recv = n_sent = n_recv_errors = 0; } + _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), + _params_valid(false), _dbm_valid(false) { n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -79,6 +104,8 @@ public: uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } + uint32_t getRxPsWatchdogSoftCount() const { return n_wd_soft; } + uint32_t getRxPsWatchdogHardCount() const { return n_wd_hard; } void resetStats() { n_recv = n_sent = n_recv_errors = 0; } virtual float getLastRSSI() const override; 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 5/6] 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; From 415faea52f2b8222ed9de11b0637b6737ecdee64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 9 Jul 2026 22:23:56 +0200 Subject: [PATCH 6/6] Added RX duty-cycle noise floor calibration again (this time it works!) --- examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_room_server/MyMesh.cpp | 1 + src/helpers/radiolib/CustomLR1110Wrapper.h | 4 +- src/helpers/radiolib/CustomSX1262Wrapper.h | 4 +- src/helpers/radiolib/RadioLibWrappers.cpp | 61 ++++++++++++++++++++-- src/helpers/radiolib/RadioLibWrappers.h | 20 ++++++- 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f57de659..8502834c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1465,5 +1465,6 @@ bool MyMesh::hasPendingWork() const { 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 + if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window return _mgr->getOutboundTotal() > 0; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 43f479a0..77962863 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1050,5 +1050,6 @@ bool MyMesh::hasPendingWork() const { 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 + if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window return _mgr->getOutboundTotal() > 0; } diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 56aec8be..5482d7df 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -43,7 +43,9 @@ protected: // reconfiguring the radio stopReceiveDutyCycle(); } - if (!_rx_ps_enabled) { + if (!_rx_ps_enabled || _nf_calib_active) { + // plain continuous RX: powersaving off, or a periodic noise-floor + // calibration window is in progress return _radio->startReceive(); } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index b1b9ce57..eb8c3980 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -52,7 +52,9 @@ protected: // silently abort the RX we are about to start stopReceiveDutyCycle(); } - if (!_rx_ps_enabled) { + if (!_rx_ps_enabled || _nf_calib_active) { + // plain continuous RX: powersaving off, or a periodic noise-floor + // calibration window is in progress return _radio->startReceive(); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 082be007..86c71086 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,11 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +// periodic noise-floor calibration windows (RX duty-cycle powersaving only) +#define NF_CALIB_INTERVAL_MS 60000UL // at least once a minute +#define NF_CALIB_TIMEOUT_MS 5000UL // give up on the batch (busy channel) +#define NF_CALIB_SETTLE_MS 20UL // frontend/AGC settle after RX entry + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -158,16 +163,60 @@ void RadioLibWrapper::rxPsWatchdogCheck() { } } +// Periodic noise-floor calibration, active only with RX duty-cycle powersaving: +// a duty-cycled receiver can't be sampled reliably (the frontend is off in the +// sleep windows and settling right after each wake), so at least once a minute +// the receive mode is dropped to plain continuous RX, a fresh sample batch is +// collected exactly like the non-powersaving path does, and the duty cycle is +// re-armed. The published average stays in _noise_floor as usual. +void RadioLibWrapper::noiseFloorCalibCheck() { + unsigned long now = millis(); + if (_nf_calib_active) { + if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) { + // powersaving turned off mid-window, or the batch couldn't complete + // (busy channel / stuck filter) - keep the previous floor + endNoiseFloorCalib(now); + } + } else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX + && now - _nf_last_calib >= NF_CALIB_INTERVAL_MS + && !isReceivingPacket()) { + // never interrupt an ongoing reception to calibrate (a TX in flight is + // already excluded by state == STATE_RX); retries next loop iteration + _nf_calib_active = true; + _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; + _nf_sample_from = now + NF_CALIB_SETTLE_MS; + _num_floor_samples = 0; // start a fresh batch for this window + _floor_sample_sum = 0; + state = STATE_IDLE; // recvRaw() re-arms; startReceiveMode() sees the + // active flag and starts continuous RX, not duty-cycle + } +} + +void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { + _nf_calib_active = false; + _nf_last_calib = now; + // force a receive re-arm back into duty-cycle mode, but don't clobber a + // completed-but-unread packet or an in-flight TX (recvRaw()/onSendFinished() + // will re-arm right after those anyway; same guard style as setRxPowerSaving) + if ((state & STATE_INT_READY) == 0 && (state & ~STATE_INT_READY) != STATE_TX_WAIT) { + state = STATE_IDLE; + } +} + void RadioLibWrapper::loop() { if (_rx_ps_enabled) { rxPsWatchdogCheck(); } + noiseFloorCalibCheck(); if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - // In RX duty-cycle (powersaving) mode only sample while the chip is in a - // listen window: during the sleep window the frontend is off, so the RSSI - // is meaningless and the SPI read would stall until the next window. - if (!(_rx_ps_armed && isChipBusy()) && !isReceivingPacket()) { + // Noise floor is only sampled outside RX duty-cycle mode: continuously in + // plain RX (powersaving off), or inside the periodic calibration window + // (powersaving on), skipping the first moments after RX entry there while + // the frontend/AGC settles (unsettled GetRssiInst reads ~-127 dBm garbage). + if (!_rx_ps_armed + && !(_nf_calib_active && (long)(millis() - _nf_sample_from) < 0) + && !isReceivingPacket()) { int rssi = getCurrentRSSI(); if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD _num_floor_samples++; @@ -182,6 +231,10 @@ void RadioLibWrapper::loop() { _floor_sample_sum = 0; MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + + if (_nf_calib_active) { + endNoiseFloorCalib(millis()); // fresh floor published - back to duty cycle + } } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3f226ebb..54b82479 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -48,9 +48,21 @@ protected: int8_t _cur_dbm; bool _params_valid, _dbm_valid; + // Periodic noise-floor calibration (only while RX duty-cycle powersaving is + // armed): a duty-cycled receiver can't be sampled reliably, so at least once + // a minute the wrapper drops to plain continuous RX, collects a fresh sample + // batch exactly like the non-powersaving path does, publishes the average + // into _noise_floor and re-arms the duty cycle. + bool _nf_calib_active; + unsigned long _nf_last_calib; // millis of last completed/attempted window + unsigned long _nf_calib_deadline; // abort window if the batch can't complete + unsigned long _nf_sample_from; // no samples before this (RX entry settle) + void idle(); void startRecv(); void rxPsWatchdogCheck(); + void noiseFloorCalibCheck(); + void endNoiseFloorCalib(unsigned long now); void cacheParams(float freq, float bw, uint8_t sf, uint8_t cr) { _cur_freq = freq; _cur_bw = bw; _cur_sf = sf; _cur_cr = cr; _params_valid = true; } @@ -73,7 +85,9 @@ public: _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), _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; } + _params_valid(false), _dbm_valid(false), + _nf_calib_active(false), _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) + { n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -117,6 +131,10 @@ public: // 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; } + // true while a periodic noise-floor calibration window is in progress; the + // app's hasPendingWork() must keep the MCU awake so the sample batch and the + // return to duty-cycle complete promptly + bool isCalibratingNoiseFloor() const { return _nf_calib_active; } void resetStats() { n_recv = n_sent = n_recv_errors = 0; } virtual float getLastRSSI() const override;