diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index bc962932..b302ca5b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3,6 +3,16 @@ #include // needed for PlatformIO #include +#ifndef RXPS_FIXED_ENABLED +#define RXPS_FIXED_ENABLED 1 +#endif +#ifndef RXPS_FIXED_LEVEL +#define RXPS_FIXED_LEVEL 2 +#endif +#ifndef RXPS_FIXED_PREAMBLE +#define RXPS_FIXED_PREAMBLE 16 +#endif + #define CMD_APP_START 1 #define CMD_SEND_TXT_MSG 2 #define CMD_SEND_CHANNEL_TXT_MSG 3 @@ -301,6 +311,49 @@ int MyMesh::getInterferenceThreshold() const { return 0; // disabled for now, until currentRSSI() problem is resolved } +#if 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); @@ -1079,6 +1132,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); +#if RXPS_FIXED_ENABLED + applyFixedRxPowerSaving(_prefs.sf, _prefs.bw); +#endif MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); // NOTE: no FEM LNA wiring here — companion has its own NodePrefs without @@ -1510,6 +1566,9 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +#if 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 5366cde5..2ddf6b98 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1145,14 +1145,8 @@ static uint8_t getRetryLogCodingRate(const mesh::Packet* packet, uint8_t default } static uint16_t getRetryLogPreambleLength(const mesh::Packet* packet, uint16_t default_preamble_len) { - if (packet == NULL || default_preamble_len <= 16 || !(packet->tx_cr == 4 || packet->tx_cr == 5)) { - return default_preamble_len; - } - - bool has_direct_path = packet->getPathHashCount() > 0 - || (packet->getPayloadType() == PAYLOAD_TYPE_TRACE && packet->payload_len > 9); - if (packet->isRouteDirect() && has_direct_path) { - return 16; + if (packet != NULL && packet->tx_cr >= 4 && packet->tx_cr <= 8) { + return 32; } return default_preamble_len; } @@ -2294,6 +2288,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif // Observer defaults (radio_watchdog, alert.*, snmp.*) moved to applyMQTTDefaults() // in MQTTDefaults.h — they live in /mqtt_prefs now, not NodePrefs. + _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 @@ -2418,6 +2414,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); // LoRa FEM LNA (FEM boards only) + setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -3097,6 +3094,21 @@ 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; +} + +void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { + *soft = radio_driver.getRxPsWatchdogSoftCount(); + *hard = radio_driver.getRxPsWatchdogHardCount(); +} + bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } @@ -3959,6 +3971,8 @@ bool MyMesh::hasPendingWork() const { const BridgeBase* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) return true; #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 if (_mgr->getOutboundTotal() > 0) return true; if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; if (isMillisTimerDue(dirty_contacts_expiry)) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 52ede1e1..8d187c25 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -387,6 +387,8 @@ 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 47bcb81f..22c6f451 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -675,6 +675,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.interference_threshold = 0; // disabled _prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards) _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 @@ -738,6 +740,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); // LoRa FEM LNA (FEM boards only) + setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -899,6 +902,15 @@ 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::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) IdentityStore store(*_fs, ""); @@ -1171,6 +1183,8 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge && 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 if (_mgr->getOutboundTotal() > 0) return true; if (acl.getNumClients() > 0 && isMillisTimerDue(next_push)) return true; if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index c744ced8..790c41e5 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -230,6 +230,8 @@ 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 cfebd9ca..24d81860 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -769,6 +769,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.interference_threshold = 0; // disabled _prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards) _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; @@ -812,6 +814,7 @@ void SensorMesh::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); // LoRa FEM LNA (FEM boards only) + setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -891,6 +894,15 @@ 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::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 1538a837..d082d6b0 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -68,6 +68,8 @@ 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 getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0fe2f72..76746899 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -381,25 +381,19 @@ void Dispatcher::checkSend() { outbound_restore_cr = 0; outbound_restore_preamble_len = 0; uint8_t default_cr = getDefaultTxCodingRate(); - bool using_low_retry_cr = false; + bool is_retry = outbound->tx_cr >= 4 && outbound->tx_cr <= 8; if (outbound->tx_cr >= 4 && outbound->tx_cr <= 8 && default_cr >= 4 && default_cr <= 8 && outbound->tx_cr != default_cr) { if (_radio->setCodingRate(outbound->tx_cr)) { outbound_restore_cr = default_cr; - using_low_retry_cr = outbound->tx_cr == 4 || outbound->tx_cr == 5; max_airtime = _radio->getEstAirtimeFor(len)*3/2; } else { MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): WARN: failed to set packet CR%d", getLogDateTime(), (uint32_t)outbound->tx_cr); } - } else if (outbound->tx_cr == 4 || outbound->tx_cr == 5) { - using_low_retry_cr = outbound->tx_cr == default_cr; } - bool has_direct_path = outbound->getPathHashCount() > 0 - || (outbound->getPayloadType() == PAYLOAD_TYPE_TRACE && outbound->payload_len > 9); - if (outbound->isRouteDirect() && has_direct_path - && using_low_retry_cr) { + if (is_retry) { uint16_t default_preamble_len = _radio->getDefaultPreambleLength(); - if (default_preamble_len > 16 && _radio->setPreambleLength(16)) { + if (default_preamble_len != 32 && _radio->setPreambleLength(32)) { outbound_restore_preamble_len = default_preamble_len; max_airtime = _radio->getEstAirtimeFor(len)*3/2; } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 557e9c8f..138d6891 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -86,6 +86,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/Mesh.cpp b/src/Mesh.cpp index dabf3bb1..2288df25 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1364,6 +1364,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) { } *retry = *packet; + retry->tx_cr = getDefaultTxCodingRate(); uint32_t retry_delay = getFloodRetryAttemptDelay(packet, _flood_retries[i].retry_attempts_sent); if (queueOutboundPacket(retry, _flood_retries[i].priority, retry_delay)) { _flood_retries[i].packet = retry; @@ -1394,6 +1395,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) { } *retry = *packet; + retry->tx_cr = getDefaultTxCodingRate(); if (queueOutboundPacket(retry, _flood_retries[i].priority, _flood_retries[i].retry_delay)) { unsigned long now = _ms->getMillis(); _flood_retries[i].packet = retry; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 33b94e02..1b9e8d41 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -313,6 +313,80 @@ static const char* retryPresetName(uint8_t preset) { } } +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; +} + static void markDirectRetryPrefsValid(NodePrefs* prefs) { prefs->direct_retry_prefs_magic[0] = DIRECT_RETRY_PREFS_MAGIC_0; prefs->direct_retry_prefs_magic[1] = DIRECT_RETRY_PREFS_MAGIC_1; @@ -763,6 +837,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // (upstream defaults: FEM RX gain on, CAD off) — overwritten below if present. _prefs->radio_fem_rxgain = 1; _prefs->cad_enabled = 0; + _prefs->rx_powersaving_enabled = 0; + _prefs->rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; + _prefs->rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + _prefs->rx_ps_level = 0; + _prefs->rx_ps_preamble = 0; #if defined(ENABLE_OTA) // OTA settings were appended after Keymind's retry/flood tail. Initialize them // before reading so older and legacy preference files remain conservative. @@ -987,6 +1066,36 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { } // next: 811 #endif + // RXPS is stored at a fixed offset after the reserved OTA tail. This keeps + // both old OTA and non-OTA /com_prefs files unambiguous. + bool has_rxps_tail = file.available() >= 11; +#if !defined(ENABLE_OTA) + const size_t ota_tail_size = 136; + const size_t padded_rxps_tail_size = sizeof(_prefs->rx_powersaving_enabled) + + sizeof(_prefs->rx_ps_rx_us) + sizeof(_prefs->rx_ps_sleep_us) + + sizeof(_prefs->rx_ps_level) + sizeof(_prefs->rx_ps_preamble); + if (file.available() >= (int)(ota_tail_size + padded_rxps_tail_size)) { + size_t remaining = ota_tail_size; + while (remaining > 0) { + size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining; + file.read(pad, n); + remaining -= n; + } + has_rxps_tail = true; + } else { + has_rxps_tail = false; + } +#endif + const size_t rxps_tail_size = sizeof(_prefs->rx_powersaving_enabled) + + sizeof(_prefs->rx_ps_rx_us) + sizeof(_prefs->rx_ps_sleep_us) + + sizeof(_prefs->rx_ps_level) + sizeof(_prefs->rx_ps_preamble); + if (has_rxps_tail && file.available() >= (int)rxps_tail_size) { + file.read((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled)); + file.read((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us)); + file.read((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); + file.read((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); + file.read((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); + } } // sanitise bad pref values @@ -1071,6 +1180,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys #endif + _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(); } @@ -1132,9 +1248,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 - // MQTT/observer settings are stored in /mqtt_prefs, not here. No zero-gap is - // written anymore — /com_prefs holds the upstream fields plus the keymind - // retry tail below (loadPrefsInt reads the same sequence; keep in sync). + // MQTT/observer settings are stored in /mqtt_prefs, not here. /com_prefs + // holds the upstream fields plus the keymind retry tail below. file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 @@ -1178,7 +1293,28 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 808 file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 810 // next: 811 +#else + // Reserve the OTA tail so RXPS has the same offset in every build. Write + // OTA's normal defaults so a later OTA-enabled firmware reads sane values. + file.write(pad, 3); // autofetch, autoinstall, signer_count + for (size_t remaining = 128; remaining > 0; ) { + size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining; + file.write(pad, n); + remaining -= n; + } + const uint16_t ota_checkpoint_blocks = 4; + const uint16_t ota_advert_interval = 0; + const uint8_t ota_max_hops = 3; + file.write((uint8_t *)&ota_checkpoint_blocks, sizeof(ota_checkpoint_blocks)); + file.write((uint8_t *)&ota_advert_interval, sizeof(ota_advert_interval)); + file.write((uint8_t *)&ota_max_hops, sizeof(ota_max_hops)); #endif + file.write((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled)); // 811 + file.write((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us)); // 812 + file.write((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 816 + file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 820 + file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 821 + // next: 822 file.close(); } @@ -1992,6 +2128,105 @@ 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; + level = RX_POWERSAVING_CONSERVATIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "balanced") == 0) { + enable = 1; + 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]; + 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) { + // 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; + } + 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]; @@ -2005,8 +2240,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"); } @@ -2573,6 +2811,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->formatScheduledRadioParams(true, skipSpacesConst(&config[11]), reply); } else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) { _callbacks->formatScheduledRadioParams(false, skipSpacesConst(&config[7]), reply); + } 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, "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 d0d2788e..c7b1c632 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -102,6 +102,18 @@ #define TELEMETRY_ACCESS_ALL 0 #define TELEMETRY_ACCESS_ACL 1 +#define RX_POWERSAVING_DEFAULT_RX_US 65625UL +#define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL +#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]; @@ -196,6 +208,12 @@ struct NodePrefs { // persisted to file uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable) uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable) #endif + + 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 }; #ifdef WITH_MQTT_BRIDGE @@ -541,6 +559,14 @@ public: virtual bool resolveAlertScope(TransportKey& /*dest*/) { return false; // no op by default } + + 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/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 4061c6b1..6f7bb1a7 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -24,6 +24,45 @@ 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. + // RadioLib 7.6 does not expose LR11x0::tcxoDelay. MeshCore initializes + // these radios with RadioLib's 5000 us default TCXO delay. + const uint32_t transitionTime = 5000 + 1000; + if (sleepPeriod <= transitionTime) { + return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + } + 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); @@ -31,11 +70,20 @@ class CustomLR1110 : public LR1110 { bool getRxBoostedGainMode() const { return _rx_boosted; } + // 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(); + 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)); return detected; } 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 0a9a0a70..349aa4f9 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -28,6 +28,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); @@ -44,6 +47,41 @@ 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_armed) { + // stop the previous duty-cycle sequence with an explicit standby before + // reconfiguring the radio + stopReceiveDutyCycle(); + } + 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(); + } + + const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; + // 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; + } + + 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/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index ca62fc26..9337aea4 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -98,12 +98,39 @@ class CustomSX1262 : public SX1262 { return true; // success } + // 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(); + 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); 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 11726035..15d8529d 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); @@ -24,9 +25,12 @@ public: return ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } - 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); } @@ -42,6 +46,52 @@ public: ((CustomSX1262 *)_radio)->sleep(false); } + bool supportsRxPowerSaving() const override { return true; } + +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 || _nf_calib_active) { + // plain continuous RX: powersaving off, or a periodic noise-floor + // calibration window is in progress + 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) { + _rx_ps_armed = true; + return err; + } + + MESH_DEBUG_PRINTLN("CustomSX1262Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err); + 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; + } + + 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); } bool setRxBoostedGainMode(bool en) override { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 6930cf5d..f96c1b87 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 @@ -48,6 +53,8 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + _cur_dbm = dbm; + _dbm_valid = true; _radio->setOutputPower(dbm); } @@ -84,9 +91,132 @@ void RadioLibWrapper::resetAGC() { _floor_sample_sum = 0; } +void RadioLibWrapper::rxPsWatchdogCheck() { + // don't interfere mid-transmit or with a completed-but-unread packet + // (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; + + 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; + _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) { + // 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 + + if (!tripped) return; + + _wd_last_transition = now; // grace period before the next escalation + _startrx_fails = 0; + _wd_observe_until = 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) + } +} + +// 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) { - if (!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++; @@ -101,50 +231,110 @@ 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 + } } } void RadioLibWrapper::startRecv() { - int err = _radio->startReceive(); + 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. + // 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 { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); + if (_startrx_fails < 255) _startrx_fails++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceiveMode(%d)", err); } } +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; } +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) { last_radio_interrupt_millis = millis(); // ISR fired → radio hardware is alive - 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++; - last_recv_millis = millis(); + 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++; + last_recv_millis = millis(); + } } } state = STATE_IDLE; // need another startReceive() } 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; } @@ -154,6 +344,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) { @@ -186,8 +381,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). + // 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 3c732568..50a488f6 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; @@ -15,20 +22,79 @@ protected: unsigned long last_recv_millis; unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors 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; + + // 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 (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 + float _cur_freq, _cur_bw; + uint8_t _cur_sf, _cur_cr; + 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() override; void startRecv() override; + 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; + } + 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(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { - n_recv = n_sent = n_recv_errors = 0; - last_recv_millis = 0; - last_radio_interrupt_millis = 0; - _cad_enabled = false; - } + 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), _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), + _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; + last_recv_millis = 0; + last_radio_interrupt_millis = 0; + _cad_enabled = false; + } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -38,6 +104,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 { @@ -68,6 +135,15 @@ public: uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const override { 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; } + // 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; } uint8_t getRadioState() const override;