From 285cf10a7ad60ff826ed55aa71ede1c4e07c3091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 14 Aug 2026 16:11:21 +0200 Subject: [PATCH] RX power saving using rx duty cycle --- docs/cli_commands.md | 66 +++ examples/companion_radio/MyMesh.cpp | 37 +- examples/simple_repeater/MyMesh.cpp | 30 +- platformio.ini | 2 + src/helpers/CommonCLI.cpp | 17 +- src/helpers/CommonCLI.h | 15 +- src/helpers/radiolib/CustomLR1110.h | 75 +++ src/helpers/radiolib/CustomLR1110Wrapper.h | 29 +- src/helpers/radiolib/CustomSX1262.h | 41 +- src/helpers/radiolib/CustomSX1262Wrapper.h | 54 ++- src/helpers/radiolib/RXPowerSaving.cpp | 81 ++++ src/helpers/radiolib/RXPowerSaving.h | 102 ++++ src/helpers/radiolib/RXPowerSavingCLI.cpp | 197 ++++++++ src/helpers/radiolib/RXPowerSavingCLI.h | 17 + src/helpers/radiolib/RadioLibWrappers.cpp | 202 +++++++- src/helpers/radiolib/RadioLibWrappers.h | 67 ++- .../test_rx_power_saving.cpp | 444 ++++++++++++++++++ 17 files changed, 1436 insertions(+), 40 deletions(-) create mode 100644 src/helpers/radiolib/RXPowerSaving.cpp create mode 100644 src/helpers/radiolib/RXPowerSaving.h create mode 100644 src/helpers/radiolib/RXPowerSavingCLI.cpp create mode 100644 src/helpers/radiolib/RXPowerSavingCLI.h create mode 100644 test/test_rx_power_saving/test_rx_power_saving.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 8772b929fe..f1e618def6 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -307,6 +307,72 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change RX power saving +**Usage:** +- `get radio.rxps` +- `set radio.rxps off` +- `set radio.rxps on` +- `set radio.rxps conservative` +- `set radio.rxps balanced` +- `set radio.rxps ` +- `set radio.rxps level ` +- `set radio.rxps level preamble ` +- `set radio.rxps ` + +**Parameters:** +- `level`: `1-10`; higher levels use shorter receive windows and longer sleep windows. +- `symbols`: `16` or `32` preamble symbols. +- `rx_us`: receive-window duration in microseconds, `1000-30000000`. +- `sleep_us`: radio sleep duration in microseconds, `1000-30000000`. + +**Repeater default:** `off` + +**Profiles:** +- `on` and `conservative`: level 1 with a 16-symbol preamble. +- `balanced`: level 5 with a 16-symbol preamble. +- A numeric level, or `level `, automatically uses 32 preamble symbols for SF5-SF8 and 16 for SF9-SF12. +- `level preamble ` explicitly fixes the preamble used in the calculation. +- Explicit `rx_us sleep_us` values select manual timing (`level=0`). + +Level-based settings are recalculated after SF or bandwidth changes. Manual timings are not recalculated. Settings are persisted in `/prefs.json`. Companion firmware does not expose this text command and applies its fixed level 5 / preamble 16 profile at startup and after radio-parameter changes. + +`get radio.rxps` reports: + +```text +desired=,effective=,supported=, +level=<0-10>,preamble=<0|16|32>,rx=,sleep=, +err=,fail=[,erx=,eslp=] +``` + +- `desired` is the saved user setting. +- `effective=armed` means receive duty-cycle is active. +- `effective=continuous` means RXPS is disabled, unsupported, or the last arm attempt fell back to continuous RX. +- `fail` counts failed arm operations; each one falls back to continuous RX. `clear stats` resets both this total and the consecutive-failure backoff, granting three fresh arm attempts. +- `erx` and `eslp` appear only when the driver had to clamp the requested periods, and report the effective periods after driver clamping. On LR1110 the RX window is stretched when `2*rx + sleep` would not cover the extended period Semtech requires, so the real duty cycle can be less economical than `rx`/`sleep` suggest. +- RXPS is currently supported by the SX1262 and LR1110 wrappers. Other radios remain in continuous RX and reject attempts to enable RXPS. +- There is intentionally no RXPS watchdog, watchdog command, or periodic recovery. Recovery is limited to the immediate continuous-RX fallback after an arm error. After 3 consecutive arm failures the node stops retrying on every RX restart and stays in continuous RX until the RXPS configuration is set again or `clear stats` grants a fresh set of attempts. +- On boards with a host-controlled RXEN pin, the RF switch is held in receive mode for the whole duty cycle (otherwise the node would be deaf). An external LNA on that pin therefore stays biased during the sleep windows, so the real power saving is smaller than the `rx`/`sleep` ratio implies. + +--- + +#### Disable the host-controlled RF receive switch during RX power saving +**Usage:** +- `get radio.rxps.rfrx_disabled` +- `set radio.rxps.rfrx_disabled ` + +**Parameters:** +- `state`: `on`|`off` + +**Default:** `off` + +**Notes:** +- This is a runtime-only diagnostic setting and resets to `off` after reboot. +- `on` reproduces the missing RF_RX assertion during SX1262 receive duty-cycle mode. +- Supported only on SX1262 targets with a host-controlled RX enable pin. +- Enabling it can significantly reduce receive sensitivity and make remote commands harder to receive. + +--- + ### System #### View or change this node's name diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2c33406632..65fefddc9b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2,6 +2,7 @@ #include // needed for PlatformIO #include +#include "helpers/radiolib/RXPowerSaving.h" #define CMD_APP_START 1 #define CMD_SEND_TXT_MSG 2 @@ -265,6 +266,33 @@ bool MyMesh::getCADEnabled() const { return false; // hardware CAD before TX (disabled by default, until configurable) } +static void applyCompanionRxPowerSaving(uint8_t sf, float bw) { +#ifdef WRAPPER_CLASS + RxPowerSavingControl* control = &radio_driver; + + // setRxPowerSaving() rejects out-of-range periods without touching the + // wrapper's state, so on any failure we must explicitly stand the duty cycle + // down. Otherwise it would stay armed with the *previous* SF/BW timings - + // e.g. SF5/BW500 yields rx=655us (below the 1ms minimum), and the radio would + // keep sleeping in windows sized for SF11, missing every preamble. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + bool ok = calcRxPowerSavingLevel(RX_POWERSAVING_BALANCED_LEVEL, sf, bw, + RX_POWERSAVING_PROFILE_PREAMBLE, &rx_us, &sleep_us) && + control->setRxPowerSaving(true, rx_us, sleep_us); + if (!ok) { + control->setRxPowerSaving(false, RX_POWERSAVING_DEFAULT_RX_US, + RX_POWERSAVING_DEFAULT_SLEEP_US); + } + MESH_DEBUG_PRINTLN("RX Power Saving: companion level=5,preamble=16,rx=%lu,sleep=%lu,%s", + (unsigned long)rx_us, (unsigned long)sleep_us, + ok ? "accepted" : "unavailable - continuous RX"); +#else + (void)sf; + (void)bw; +#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); @@ -980,6 +1008,7 @@ void MyMesh::begin(bool has_display) { board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); + applyCompanionRxPowerSaving(_prefs.sf, _prefs.bw); } const char *MyMesh::getNodeName() { @@ -1403,6 +1432,7 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + applyCompanionRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); @@ -2263,5 +2293,10 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; + bool calibration_active = false; +#ifdef WRAPPER_CLASS + const RxPowerSavingControl* rxps_control = &radio_driver; + calibration_active = rxps_control->isRxPowerSavingCalibrationActive(); +#endif + return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0 || calibration_active; } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7d0179f3ab..831f023fac 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,5 +1,28 @@ #include "MyMesh.h" #include +#include "helpers/radiolib/RXPowerSaving.h" + +static RxPowerSavingControl* getRxPowerSavingControl() { +#ifdef WRAPPER_CLASS + return &radio_driver; +#else + return nullptr; +#endif +} + +static void applyRxPowerSavingConfig(NodePrefs& prefs, uint8_t sf, float bw) { + normalizeRxPowerSavingConfig(&prefs.rxps, sf, bw); + RxPowerSavingControl* control = getRxPowerSavingControl(); + bool ok = control != nullptr + ? control->setRxPowerSaving( + prefs.rxps.enabled != 0, prefs.rxps.rx_us, prefs.rxps.sleep_us) + : prefs.rxps.enabled == 0; + MESH_DEBUG_PRINTLN("RX Power Saving: desired=%s, rx=%lu, sleep=%lu, %s", + prefs.rxps.enabled ? "on" : "off", + (unsigned long)prefs.rxps.rx_us, + (unsigned long)prefs.rxps.sleep_us, + ok ? "accepted" : "unsupported"); +} /* ------------------------------ Config -------------------------------- */ @@ -862,7 +885,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc mesh::RTCClock &rtc, mesh::MeshTables &tables) : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), region_map(key_store), temp_map(key_store), - _cli(board, rtc, sensors, region_map, acl, &_prefs, this), + _cli(board, rtc, sensors, region_map, acl, &_prefs, this, getRxPowerSavingControl()), telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes anon_limiter(4, 180) // max 4 every 3 minutes @@ -983,6 +1006,7 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); + applyRxPowerSavingConfig(_prefs, _prefs.sf, _prefs.bw); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -1308,12 +1332,14 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + applyRxPowerSavingConfig(_prefs, pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + applyRxPowerSavingConfig(_prefs, _prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1334,5 +1360,7 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif + const RxPowerSavingControl* control = getRxPowerSavingControl(); + if (control != nullptr && control->isRxPowerSavingCalibrationActive()) return true; return _mgr->getOutboundTotal() > 0; } diff --git a/platformio.ini b/platformio.ini index e78124a40b..b0af24a397 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,6 +170,8 @@ build_src_filter = +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/radiolib/RXPowerSaving.cpp> + +<../src/helpers/radiolib/RXPowerSavingCLI.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b318bb58e8..f40f4ce0bf 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3,6 +3,7 @@ #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" +#include "radiolib/RXPowerSavingCLI.h" #include #ifndef BRIDGE_MAX_BAUD @@ -585,6 +586,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error: state must be on or off"); } + } else if (strncmp(config, "radio.rxps.rfrx_disabled ", 25) == 0) { + RXPowerSavingCLI::setRfRxDisabled(&config[25], _rxps_control, reply, 160); + } else if (memcmp(config, "radio.rxps ", 11) == 0) { + if (RXPowerSavingCLI::set(&config[11], _prefs->sf, _prefs->bw, &_prefs->rxps, + _rxps_control, reply, 160)) { + savePrefs(); + } } else if (memcmp(config, "radio ", 6) == 0) { strcpy(tmp, &config[6]); const char *parts[4]; @@ -598,8 +606,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->cr = cr; _prefs->freq = freq; _prefs->bw = bw; + bool rxps_retuned = recalcRxPowerSavingFromLevel( + _prefs->rxps.level, _prefs->sf, _prefs->bw, _prefs->rxps.preamble, + &_prefs->rxps.rx_us, &_prefs->rxps.sleep_us); _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"); } @@ -856,6 +867,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "> %s", _board->isLoRaFemPaGainEnabled() ? "on" : "off"); } + } else if (strcmp(config, "radio.rxps.rfrx_disabled") == 0) { + RXPowerSavingCLI::getRfRxDisabled(_rxps_control, reply, 160); + } else if (strcmp(config, "radio.rxps") == 0) { + RXPowerSavingCLI::get(&_prefs->rxps, _rxps_control, reply, 160); } 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 237c758e9f..835c8c7fb0 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -6,6 +6,7 @@ #include #include #include +#include #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) #define WITH_BRIDGE @@ -70,6 +71,7 @@ class NodePrefs : public ConfigSerializer { uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) uint8_t extra_sf[4]; + RxPowerSavingConfig rxps; private: class RadioPrefs : public ConfigSerializer { @@ -93,6 +95,11 @@ class NodePrefs : public ConfigSerializer { def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); + def("rxps_en", _parent->rxps.enabled); + def("rxps_rx_us", _parent->rxps.rx_us); + def("rxps_sleep_us", _parent->rxps.sleep_us); + def("rxps_level", _parent->rxps.level); + def("rxps_preamble", _parent->rxps.preamble); } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } @@ -253,6 +260,7 @@ class CommonCLI { mesh::RTCClock* _rtc; NodePrefs* _prefs; CommonCLICallbacks* _callbacks; + RxPowerSavingControl* _rxps_control; mesh::MainBoard* _board; SensorManager* _sensors; RegionMap* _region_map; @@ -268,8 +276,11 @@ class CommonCLI { void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply); public: - CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks) - : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { } + CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, + RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, + CommonCLICallbacks* callbacks, RxPowerSavingControl* rxps_control = nullptr) + : _rtc(&rtc), _prefs(prefs), _callbacks(callbacks), _rxps_control(rxps_control), + _board(&board), _sensors(&sensors), _region_map(®ion_map), _acl(&acl) { } void loadPrefs(FILESYSTEM* _fs); bool savePrefs(FILESYSTEM* _fs); diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 75674a1dfd..d4fc31d344 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,6 +2,7 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" class CustomLR1110 : public LR1110 { uint32_t _preambleMillis = 66; @@ -28,6 +29,75 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + // Restores the LF clock configuration RadioLib's begin() installs. Arming + // the duty cycle switches it to RC + BUSY-release (required by Semtech for + // SetRxDutyCycle); without this the change would outlive RXPS being turned + // off, and it also alters what the BUSY pin means to isChipBusy(). + int16_t restoreLfClock() { + return configLfClock( + RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_XOSC); + } + + // effectiveRxPeriod reports the RX window actually programmed, which may be + // stretched above the requested value to satisfy the extended-period rule. + 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, + uint32_t* effectiveRxPeriod = nullptr) { + // Keep the conversion local until RadioLib uses 64-bit multiplication; + // its pinned implementation overflows for longer microsecond periods. + uint32_t symbolPeriod = (uint32_t)(((1000.0f * (float)(1UL << this->spreadingFactor)) / + this->bandwidthKhz) + 0.999f); + uint32_t transitionTime = this->tcxoDelay + 1000; + if (sleepPeriod <= transitionTime) return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + uint32_t programmedSleepPeriod = sleepPeriod - transitionTime; + + uint64_t requiredExtendedPeriod = + ((uint64_t)this->preambleLengthLoRa + 11ULL) * symbolPeriod + 1000ULL; + uint64_t extendedPeriod = 2ULL * rxPeriod + programmedSleepPeriod; + if (extendedPeriod < requiredExtendedPeriod) { + rxPeriod = (uint32_t)((requiredExtendedPeriod - programmedSleepPeriod + 1ULL) / 2ULL); + } + if (effectiveRxPeriod) *effectiveRxPeriod = rxPeriod; + + uint32_t rxPeriodRaw = (uint32_t)(((uint64_t)rxPeriod * 32768UL) / 1000000UL); + uint32_t sleepPeriodRaw = + (uint32_t)(((uint64_t)programmedSleepPeriod * 32768UL) / 1000000UL); + if ((rxPeriodRaw & 0xFF000000) || rxPeriodRaw == 0) return RADIOLIB_ERR_INVALID_RX_PERIOD; + if ((sleepPeriodRaw & 0xFF000000) || sleepPeriodRaw == 0) { + return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + } + + int16_t state = standby(RADIOLIB_LR11X0_STANDBY_RC); + RADIOLIB_ASSERT(state); + // Semtech requires the RC standby/RTC setup before SetRxDutyCycle. + RadioModeConfig_t cfg = { + .receive = { + .timeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF, + .irqFlags = irqFlags, + .irqMask = irqMask, + .len = 0, + } + }; + return runRxPowerSavingArmTransaction( + [this]() { + return configLfClock( + RADIOLIB_LR11X0_LF_CLK_RC | RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED); + }, + [this, &cfg]() { + return this->stageMode(RADIOLIB_RADIO_MODE_RX, &cfg); + }, + [this, rxPeriodRaw, sleepPeriodRaw]() { + return this->setRxDutyCycle( + rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + }, + [this]() { + // Preserve the arm error even if this best-effort rollback fails. + // A failed ConfigLfClock response may still mean the command ran. + restoreLfClock(); + }); + } + int16_t setRxBoostedGainMode(bool en) { _rx_boosted = en; return LR1110::setRxBoostedGainMode(en); @@ -40,6 +110,11 @@ class CustomLR1110 : public LR1110 { return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqStatus(); bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index e7aaeb937a..d93ccca9cf 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -9,6 +9,7 @@ class CustomLR1110Wrapper : public RadioLibWrapper { CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + prepareForRadioConfig(); ((CustomLR1110 *)_radio)->setFrequency(freq); ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); ((CustomLR1110 *)_radio)->setBandwidth(bw); @@ -20,8 +21,16 @@ class CustomLR1110Wrapper : public RadioLibWrapper { } bool isReceivingPacket() override { + // While duty-cycling, BUSY marks the sleep phase: probing IRQ status over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomLR1110 *)_radio)->isChipBusy()) return false; return ((CustomLR1110 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomLR1110 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { float rssi = -110; ((CustomLR1110 *)_radio)->getRssiInst(&rssi); @@ -38,12 +47,28 @@ class CustomLR1110Wrapper : public RadioLibWrapper { _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + *eff_sleep_us = _rx_ps_sleep_us; // only the RX window can get clamped + return ((CustomLR1110 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask, eff_rx_us); + } + + int16_t stopDutyCycleHardware() override { + int16_t standby_err = _radio->standby(); + int16_t lf_err = ((CustomLR1110 *)_radio)->restoreLfClock(); + return standby_err != RADIOLIB_ERR_NONE ? standby_err : lf_err; + } + +public: uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } bool setRxBoostedGainMode(bool en) override { + prepareForRadioConfig(); return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97aa..3f6977dba2 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -8,6 +8,7 @@ class CustomSX1262 : public SX1262 { uint32_t _maxPayloadMillis = 3934; uint32_t _activityAt = 0; bool _headerSeen = false; + bool _rx_ps_rf_rx_disabled = false; public: CustomSX1262(Module *mod) : SX1262(mod) { } @@ -105,6 +106,31 @@ class CustomSX1262 : public SX1262 { return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + 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) { + int16_t state = SX1262::startReceiveDutyCycle(rxPeriod, sleepPeriod, irqFlags, irqMask); + if (state == RADIOLIB_ERR_NONE && !_rx_ps_rf_rx_disabled) { + // RadioLib stages RX through standby, which leaves a host-controlled + // RXEN switch in IDLE. Set it back to RX for the whole duty-cycle; + // boards without an external RXEN table are unaffected. + this->mod->setRfSwitchState(Module::MODE_RX); + } + return state; + } + + void setRxPowerSavingRfRxDisabled(bool disabled) { + _rx_ps_rf_rx_disabled = disabled; + } + bool isRxPowerSavingRfRxDisabled() const { + return _rx_ps_rf_rx_disabled; + } + + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqFlags(); bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 @@ -148,6 +174,19 @@ class CustomSX1262 : public SX1262 { return false; } + 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; + return writeRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + } + void setPreambleMillis(uint32_t preambleMillis) { _preambleMillis = preambleMillis; MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); @@ -162,4 +201,4 @@ class CustomSX1262 : public SX1262 { readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716b..6d307a6c55 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -13,6 +13,7 @@ class CustomSX1262Wrapper : public RadioLibWrapper { CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + prepareForRadioConfig(); ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -23,14 +24,20 @@ class CustomSX1262Wrapper : public RadioLibWrapper { ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } - bool isReceivingPacket() override { + bool isReceivingPacket() override { + // While duty-cycling, BUSY marks the sleep phase: probing IRQ flags over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomSX1262 *)_radio)->isChipBusy()) return false; return ((CustomSX1262 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomSX1262 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1262 *)_radio)->spreadingFactor; @@ -38,10 +45,51 @@ class CustomSX1262Wrapper : public RadioLibWrapper { } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { + if (_rx_ps_armed) stopReceiveDutyCycle(); ((CustomSX1262 *)_radio)->sleep(false); } + bool supportsRxPowerSaving() const override { return true; } + + bool supportsRxPowerSavingRfRxDisable() const override { + #if defined(SX126X_RXEN) + return SX126X_RXEN != RADIOLIB_NC; + #else + return false; + #endif + } + + bool setRxPowerSavingRfRxDisabled(bool disabled) override { + if (!supportsRxPowerSavingRfRxDisable()) return false; + prepareForRadioConfig(); + ((CustomSX1262 *)_radio)->setRxPowerSavingRfRxDisabled(disabled); + return true; + } + + bool isRxPowerSavingRfRxDisabled() const override { + return ((CustomSX1262 *)_radio)->isRxPowerSavingRfRxDisabled(); + } + +protected: + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + // RadioLib programs the SX126x with exactly what we ask for (it only + // subtracts the wake-up transition from the sleep period internally). + *eff_rx_us = _rx_ps_rx_us; + *eff_sleep_us = _rx_ps_sleep_us; + return ((CustomSX1262 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask); + } + + int16_t stopDutyCycleHardware() override { + int16_t standby_err = _radio->standby(); + int16_t rtc_err = ((CustomSX1262 *)_radio)->stopRTC(); + return standby_err != RADIOLIB_ERR_NONE ? standby_err : rtc_err; + } + +public: bool setRxBoostedGainMode(bool en) override { + prepareForRadioConfig(); return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp new file mode 100644 index 0000000000..f11e07fce8 --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.cpp @@ -0,0 +1,81 @@ +#include "RXPowerSaving.h" + +static uint32_t ceilRxPowerSavingValue(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; +} + +bool isValidRxPowerSavingPeriod(uint32_t us) { + return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US; +} + +uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { + return sf <= 8 ? 32 : 16; +} + +bool isRxPowerSavingNumeric(const char* value) { + if (value == nullptr || *value == 0) return false; + while (*value) { + if (*value < '0' || *value > '9') return false; + value++; + } + return true; +} + +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us) { + if (rx_us == nullptr || sleep_us == nullptr || 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 = ceilRxPowerSavingValue(rx_symbols * symbol_us); + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + return true; +} + +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us) { + if (!isValidRxPowerSavingPeriod(*rx_us)) *rx_us = RX_POWERSAVING_DEFAULT_RX_US; + if (!isValidRxPowerSavingPeriod(*sleep_us)) *sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; +} + +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us) { + if (level < 1 || level > 10) return false; + if (preamble == 0) preamble = rxPowerSavingPreambleForSF(sf); + + uint32_t calculated_rx_us = 0; + uint32_t calculated_sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &calculated_rx_us, &calculated_sleep_us) || + !isValidRxPowerSavingPeriod(calculated_rx_us) || + !isValidRxPowerSavingPeriod(calculated_sleep_us)) { + return false; + } + + *rx_us = calculated_rx_us; + *sleep_us = calculated_sleep_us; + return true; +} + +void normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw) { + if (config == nullptr) return; + + config->enabled = config->enabled ? 1 : 0; + if (config->level > 10) config->level = RX_POWERSAVING_BALANCED_LEVEL; + if (config->preamble != 0 && config->preamble != 16 && config->preamble != 32) { + config->preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + } + ensureRxPowerSavingDefaults(&config->rx_us, &config->sleep_us); + recalcRxPowerSavingFromLevel(config->level, sf, bw, config->preamble, + &config->rx_us, &config->sleep_us); +} diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h new file mode 100644 index 0000000000..473e5f29fa --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -0,0 +1,102 @@ +#pragma once + +#include + +static constexpr uint32_t RX_POWERSAVING_DEFAULT_RX_US = 65625UL; +static constexpr uint32_t RX_POWERSAVING_DEFAULT_SLEEP_US = 60000UL; +static constexpr uint32_t RX_POWERSAVING_MIN_PERIOD_US = 1000UL; +static constexpr uint32_t RX_POWERSAVING_MAX_PERIOD_US = 30000000UL; + +static constexpr uint8_t RX_POWERSAVING_CONSERVATIVE_LEVEL = 1; +static constexpr uint8_t RX_POWERSAVING_BALANCED_LEVEL = 5; +static constexpr uint8_t RX_POWERSAVING_PROFILE_PREAMBLE = 16; +static constexpr uint8_t RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES = 3; + +class RxPowerSavingArmRetryState { + uint8_t _consecutive_failures = 0; + +public: + bool canAttempt() const { + return _consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; + } + void recordSuccess() { _consecutive_failures = 0; } + void recordFailure() { + if (_consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES) { + _consecutive_failures++; + } + } + void reset() { _consecutive_failures = 0; } + uint8_t consecutiveFailures() const { return _consecutive_failures; } +}; + +// Runs the part of a hardware arm sequence that starts by changing persistent +// radio configuration. Every failing step rolls that configuration back, while +// the original arm error remains the result returned to the caller. +template +int16_t runRxPowerSavingArmTransaction(Configure configure, Stage stage, + Arm arm, Rollback rollback) { + int16_t state = configure(); + if (state != 0) { + rollback(); + return state; + } + + state = stage(); + if (state != 0) { + rollback(); + return state; + } + + state = arm(); + if (state != 0) rollback(); + return state; +} + +struct RxPowerSavingConfig { + uint8_t enabled = 0; + uint32_t rx_us = RX_POWERSAVING_DEFAULT_RX_US; + uint32_t sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + uint8_t level = RX_POWERSAVING_BALANCED_LEVEL; + uint8_t preamble = RX_POWERSAVING_PROFILE_PREAMBLE; +}; + +struct RxPowerSavingStatus { + bool supported = false; + bool armed = false; + int16_t last_error = 0; + uint32_t arm_failures = 0; + // Periods the radio actually runs with. A driver may clamp the requested + // values to satisfy a hardware constraint, so these can differ from the + // configured rx_us/sleep_us. Zero means "nothing armed yet". + uint32_t effective_rx_us = 0; + uint32_t effective_sleep_us = 0; +}; + +class RxPowerSavingControl { +public: + virtual ~RxPowerSavingControl() = default; + + virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + (void)rx_us; + (void)sleep_us; + return !enabled; + } + virtual RxPowerSavingStatus getRxPowerSavingStatus() const { return {}; } + virtual bool supportsRxPowerSavingRfRxDisable() const { return false; } + virtual bool setRxPowerSavingRfRxDisabled(bool disabled) { + (void)disabled; + return false; + } + virtual bool isRxPowerSavingRfRxDisabled() const { return false; } + virtual bool isRxPowerSavingCalibrationActive() const { return false; } +}; + +bool isValidRxPowerSavingPeriod(uint32_t us); +uint8_t rxPowerSavingPreambleForSF(uint8_t sf); +bool isRxPowerSavingNumeric(const char* value); +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us); +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us); +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us); +void normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw); diff --git a/src/helpers/radiolib/RXPowerSavingCLI.cpp b/src/helpers/radiolib/RXPowerSavingCLI.cpp new file mode 100644 index 0000000000..bf32bf3001 --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.cpp @@ -0,0 +1,197 @@ +#include "RXPowerSavingCLI.h" + +#include +#include +#include +#include + +static bool parseRxPowerSavingUint32(const char* value, uint32_t* parsed) { + if (parsed == nullptr || !isRxPowerSavingNumeric(value)) return false; + + uint32_t result = 0; + while (*value) { + uint32_t digit = (uint32_t)(*value++ - '0'); + if (result > (UINT32_MAX - digit) / 10U) return false; + result = result * 10U + digit; + } + *parsed = result; + return true; +} + +bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, + RxPowerSavingConfig* config, RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (value == nullptr || config == nullptr || reply == nullptr || reply_size == 0) return false; + + RxPowerSavingConfig proposed = *config; + uint8_t level = 0; + uint8_t preamble = rxPowerSavingPreambleForSF(sf); + bool level_requested = false; + bool preamble_overridden = false; + + ensureRxPowerSavingDefaults(&proposed.rx_us, &proposed.sleep_us); + + if (strcmp(value, "off") == 0) { + proposed.enabled = 0; + } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_CONSERVATIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "balanced") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_BALANCED_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else { + char input[128]; + if (strlen(value) >= sizeof(input)) { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|level <1-10>| "); + return false; + } + strcpy(input, value); + + const char* parts[4]; + int count = mesh::Utils::parseTextParts(input, parts, 4, ' '); + uint32_t first = 0; + uint32_t second = 0; + if (count == 1 && parseRxPowerSavingUint32(parts[0], &first)) { + if (first < 1 || first > 10) { + snprintf(reply, reply_size, "ERROR: level range is 1-10; preamble is 16 or 32"); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count == 2 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first)) { + if (first < 1 || first > 10) { + snprintf(reply, reply_size, "ERROR: level range is 1-10; preamble is 16 or 32"); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count == 4 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first) && + strcmp(parts[2], "preamble") == 0 && + parseRxPowerSavingUint32(parts[3], &second)) { + if (first < 1 || first > 10 || (second != 16 && second != 32)) { + snprintf(reply, reply_size, "ERROR: level range is 1-10; preamble is 16 or 32"); + return false; + } + level = (uint8_t)first; + preamble = (uint8_t)second; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && parseRxPowerSavingUint32(parts[0], &first) && + parseRxPowerSavingUint32(parts[1], &second)) { + proposed.rx_us = first; + proposed.sleep_us = second; + proposed.enabled = 1; + } else { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|level <1-10>| "); + return false; + } + } + + if (level_requested && + !calcRxPowerSavingLevel(level, sf, bw, preamble, + &proposed.rx_us, &proposed.sleep_us)) { + snprintf(reply, reply_size, "ERROR: level range is 1-10; preamble is 16 or 32"); + return false; + } + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) { + snprintf(reply, reply_size, "ERROR: range is %lu-%lu us", + (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); + return false; + } + + bool applied = control != nullptr + ? control->setRxPowerSaving(proposed.enabled != 0, proposed.rx_us, proposed.sleep_us) + : proposed.enabled == 0; + if (!applied) { + snprintf(reply, reply_size, "ERROR: RX powersaving unsupported"); + return false; + } + + if (level_requested) { + proposed.level = level; + proposed.preamble = preamble_overridden ? preamble : 0; + } else if (strcmp(value, "off") != 0) { + proposed.level = 0; + proposed.preamble = 0; + } + + *config = proposed; + snprintf(reply, reply_size, "OK - %s,level=%lu,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", (unsigned long)config->level, + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + return true; +} + +void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, + const RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (config == nullptr || reply == nullptr || reply_size == 0) return; + + RxPowerSavingStatus status = control != nullptr + ? control->getRxPowerSavingStatus() + : RxPowerSavingStatus{}; + int len = snprintf(reply, reply_size, + "> desired=%s,effective=%s,supported=%s,level=%lu,preamble=%lu,rx=%lu,sleep=%lu,err=%d,fail=%lu", + config->enabled ? "on" : "off", status.armed ? "armed" : "continuous", + status.supported ? "yes" : "no", (unsigned long)config->level, + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us, (int)status.last_error, + (unsigned long)status.arm_failures); + + // Only report the periods the hardware really runs with when a driver had to + // clamp them - printing them always would not fit the 160 byte reply buffer. + if (len > 0 && (size_t)len < reply_size && status.armed && + (status.effective_rx_us != config->rx_us || + status.effective_sleep_us != config->sleep_us)) { + snprintf(reply + len, reply_size - (size_t)len, ",erx=%lu,eslp=%lu", + (unsigned long)status.effective_rx_us, + (unsigned long)status.effective_sleep_us); + } +} + +void RXPowerSavingCLI::setRfRxDisabled(const char* value, RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (value == nullptr || reply == nullptr || reply_size == 0) return; + + bool disabled; + if (strcmp(value, "on") == 0) { + disabled = true; + } else if (strcmp(value, "off") == 0) { + disabled = false; + } else { + snprintf(reply, reply_size, "Error: state must be on or off"); + return; + } + + if (control == nullptr || !control->setRxPowerSavingRfRxDisabled(disabled)) { + snprintf(reply, reply_size, "Error: unsupported"); + } else { + snprintf(reply, reply_size, "OK - radio.rxps.rfrx_disabled %s", disabled ? "on" : "off"); + } +} + +void RXPowerSavingCLI::getRfRxDisabled(const RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (reply == nullptr || reply_size == 0) return; + if (control == nullptr || !control->supportsRxPowerSavingRfRxDisable()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else { + snprintf(reply, reply_size, "> %s", control->isRxPowerSavingRfRxDisabled() ? "on" : "off"); + } +} diff --git a/src/helpers/radiolib/RXPowerSavingCLI.h b/src/helpers/radiolib/RXPowerSavingCLI.h new file mode 100644 index 0000000000..dfd0eb98ce --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "RXPowerSaving.h" + +class RXPowerSavingCLI { +public: + static bool set(const char* value, uint8_t sf, float bw, RxPowerSavingConfig* config, + RxPowerSavingControl* control, char* reply, size_t reply_size); + static void get(const RxPowerSavingConfig* config, const RxPowerSavingControl* control, + char* reply, size_t reply_size); + static void setRfRxDisabled(const char* value, RxPowerSavingControl* control, + char* reply, size_t reply_size); + static void getRfRxDisabled(const RxPowerSavingControl* control, + char* reply, size_t reply_size); +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b9c095ac40..0fdc19d49f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,10 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +#define NF_CALIB_INTERVAL_MS 60000UL +#define NF_CALIB_TIMEOUT_MS 5000UL +#define NF_CALIB_SETTLE_MS 20UL + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -48,14 +52,21 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + prepareForRadioConfig(); _radio->setOutputPower(dbm); } void RadioLibWrapper::idle() { + if (_rx_ps_armed) stopReceiveDutyCycle(); _radio->standby(); state = STATE_IDLE; // need another startReceive() } +void RadioLibWrapper::powerOff() { + if (_rx_ps_armed) stopReceiveDutyCycle(); + _radio->sleep(); +} + void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling @@ -69,8 +80,10 @@ void RadioLibWrapper::doResetAGC() { } void RadioLibWrapper::resetAGC() { - // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + // make sure we're not mid-receive or mid-transmit of a packet + if ((state & STATE_INT_READY) != 0 || isReceivingPacket() || state == STATE_TX_WAIT) return; + + if (_rx_ps_armed) stopReceiveDutyCycle(); doResetAGC(); state = STATE_IDLE; // trigger a startReceive() @@ -84,9 +97,48 @@ void RadioLibWrapper::resetAGC() { _floor_sample_sum = 0; } +// Clear the RX/idle state so the next loop calls startRecv() again, without +// dropping an STATE_INT_READY that the ISR may raise while we are in here. +// A plain `state = STATE_IDLE` loses that flag (and with it, a received +// packet) when setFlag() fires between the test and the store. +void RadioLibWrapper::requestRestartRecv() { + noInterrupts(); + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT) { + state &= STATE_INT_READY; // STATE_IDLE, but keep a pending interrupt + } + interrupts(); +} + +void RadioLibWrapper::noiseFloorCalibCheck() { + unsigned long now = millis(); + if (_nf_calib_active) { + if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) { + endNoiseFloorCalib(now); + } + } else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX && + (_nf_last_calib == 0 || now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) && + !isReceivingPacket()) { + _nf_calib_active = true; + _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; + _nf_sample_from = now + NF_CALIB_SETTLE_MS; + _num_floor_samples = 0; + _floor_sample_sum = 0; + requestRestartRecv(); + } +} + +void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { + _nf_calib_active = false; + _nf_last_calib = now; + requestRestartRecv(); +} + void RadioLibWrapper::loop() { + noiseFloorCalibCheck(); + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!isReceivingPacket()) { + 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++; @@ -103,6 +155,8 @@ void RadioLibWrapper::loop() { #ifdef MESH_DEBUG_NOISE_FLOOR MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); #endif + + if (_nf_calib_active) endNoiseFloorCalib(millis()); } } @@ -110,7 +164,7 @@ void RadioLibWrapper::startRecv() { #if defined(USE_LR2021) _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled #endif - int err = _radio->startReceive(); + int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; } else { @@ -118,6 +172,98 @@ void RadioLibWrapper::startRecv() { } } +int16_t RadioLibWrapper::startReceiveMode() { + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (!_rx_ps_enabled || _nf_calib_active) { + _rx_ps_armed = false; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + return _radio->startReceive(); + } + + if (!_rx_ps_arm_retry.canAttempt()) { + // arming has failed repeatedly; stop paying for a doomed SPI round-trip on + // every RX restart. A config change (setRxPowerSaving) re-enables retries. + _rx_ps_armed = false; + return _radio->startReceive(); + } + + const RadioLibIrqFlags_t irq_flags = + RADIOLIB_IRQ_RX_DEFAULT_FLAGS | + (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + const RadioLibIrqFlags_t irq_mask = + (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR); + + uint32_t eff_rx_us = _rx_ps_rx_us; + uint32_t eff_sleep_us = _rx_ps_sleep_us; + int16_t err = armDutyCycle(irq_flags, irq_mask, &eff_rx_us, &eff_sleep_us); + if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.recordSuccess(); + _rx_ps_eff_rx_us = eff_rx_us; + _rx_ps_eff_sleep_us = eff_sleep_us; + return err; + } + + _rx_ps_armed = false; + _rx_ps_last_error = err; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + n_rx_ps_arm_failures++; + _rx_ps_arm_retry.recordFailure(); + MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); + int16_t fallback_err = _radio->startReceive(); + if (fallback_err != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_err; + return fallback_err; +} + +void RadioLibWrapper::stopReceiveDutyCycle() { + int16_t err = stopDutyCycleHardware(); + _rx_ps_armed = false; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; +} + +bool RadioLibWrapper::isPacketReady() { + if (!_rx_ps_armed) return true; + return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; +} + +void RadioLibWrapper::prepareForRadioConfig() { + if (!_rx_ps_armed) return; + + stopReceiveDutyCycle(); + requestRestartRecv(); +} + +bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) return false; + if (enabled && !supportsRxPowerSaving()) return false; + + _rx_ps_enabled = enabled; + _rx_ps_rx_us = rx_us; + _rx_ps_sleep_us = sleep_us; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.reset(); // new config, give the hardware a fresh chance + requestRestartRecv(); + return true; +} + +RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { + RxPowerSavingStatus status; + status.supported = supportsRxPowerSaving(); + status.armed = _rx_ps_armed; + status.last_error = _rx_ps_last_error; + status.arm_failures = n_rx_ps_arm_failures; + status.effective_rx_us = _rx_ps_eff_rx_us; + status.effective_sleep_us = _rx_ps_eff_sleep_us; + return status; +} + bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } @@ -125,17 +271,23 @@ bool RadioLibWrapper::isInRecvMode() const { 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()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + len = _radio->getPacketLength(); + if (len > 0) { + if (len > sz) { len = sz; } + _last_snr = _radio->getSNR(); + _last_rssi = _radio->getRSSI(); + _last_metrics_valid = true; + 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 defined(USE_LR2021) @@ -146,12 +298,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; } @@ -161,6 +308,7 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + if (_rx_ps_armed) stopReceiveDutyCycle(); _board->onBeforeTransmit(); int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { @@ -194,10 +342,12 @@ 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; + if (_threshold != 0 && !(_rx_ps_armed && isChipBusy()) && + getCurrentRSSI() > _noise_floor + _threshold) return true; // cad: hardware channel activity detection if (_cad_enabled) { + if (_rx_ps_armed) stopReceiveDutyCycle(); int16_t result = performChannelScan(); // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't @@ -211,10 +361,12 @@ bool RadioLibWrapper::isChannelActive() { } float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); + if (_last_metrics_valid) return _last_rssi; + return _rx_ps_armed ? 0 : _radio->getRSSI(); } float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); + if (_last_metrics_valid) return _last_snr; + return _rx_ps_armed ? 0 : _radio->getSNR(); } // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) @@ -254,4 +406,4 @@ PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t if (cr >= 5 && cr < 8) { payload_us = (payload_us * 8) / cr; } return PacketMillis {(preamble_us + 999) / 1000, (payload_us + 999) / 1000}; -} \ No newline at end of file +} diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b..0e2142f70e 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,7 @@ #include #include +#include "RXPowerSaving.h" #ifdef USE_CC310_HW_CRYPTO #include @@ -11,28 +12,74 @@ struct PacketMillis { uint32_t payloadMillis; // header-valid -> rx-done deadline }; -class RadioLibWrapper : public mesh::Radio { +class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + float _last_rssi, _last_snr; + bool _last_metrics_valid; bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; + bool _rx_ps_enabled; + bool _rx_ps_armed; + uint32_t _rx_ps_rx_us; + uint32_t _rx_ps_sleep_us; + uint32_t _rx_ps_eff_rx_us; // periods the driver actually armed with + uint32_t _rx_ps_eff_sleep_us; + int16_t _rx_ps_last_error; + uint32_t n_rx_ps_arm_failures; + RxPowerSavingArmRetryState _rx_ps_arm_retry; + + bool _nf_calib_active; + unsigned long _nf_last_calib; + unsigned long _nf_calib_deadline; + unsigned long _nf_sample_from; + void idle(); void startRecv(); + void requestRestartRecv(); + void prepareForRadioConfig(); + void noiseFloorCalibCheck(); + void endNoiseFloorCalib(unsigned long now); + int16_t startReceiveMode(); + void stopReceiveDutyCycle(); + // eff_rx_us/eff_sleep_us report the periods the driver really programmed, + // which may be clamped to satisfy a hardware constraint. + virtual int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, + RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) { + (void)irq_flags; + (void)irq_mask; + (void)eff_rx_us; + (void)eff_sleep_us; + return RADIOLIB_ERR_UNSUPPORTED; + } + virtual int16_t stopDutyCycleHardware() { return _radio->standby(); } + virtual bool isPacketReady(); + virtual bool isChipBusy() { 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 = 0; } + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) + : _radio(&radio), _board(&board), _last_rssi(0), _last_snr(0), _last_metrics_valid(false), + _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), + _rx_ps_rx_us(RX_POWERSAVING_DEFAULT_RX_US), + _rx_ps_sleep_us(RX_POWERSAVING_DEFAULT_SLEEP_US), _rx_ps_eff_rx_us(0), + _rx_ps_eff_sleep_us(0), _rx_ps_last_error(RADIOLIB_ERR_NONE), + n_rx_ps_arm_failures(0), _nf_calib_active(false), + _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) { + n_recv = n_sent = n_recv_errors = 0; + } void begin() override; - virtual void powerOff() { _radio->sleep(); } + virtual void powerOff(); int recvRaw(uint8_t* bytes, int sz) override; uint32_t getEstAirtimeFor(int len_bytes) override; bool startSendRaw(const uint8_t* bytes, int len) override; @@ -40,6 +87,13 @@ class RadioLibWrapper : public mesh::Radio { void onSendFinished() override; bool isInRecvMode() const override; bool isChannelActive(); + virtual bool supportsRxPowerSaving() const { return false; } + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; + RxPowerSavingStatus getRxPowerSavingStatus() const override; + virtual bool supportsRxPowerSavingRfRxDisable() const override { return false; } + virtual bool setRxPowerSavingRfRxDisabled(bool) override { return false; } + virtual bool isRxPowerSavingRfRxDisabled() const override { return false; } + bool isRxPowerSavingCalibrationActive() const override { return _nf_calib_active; } bool isReceiving() override { if (isReceivingPacket()) return true; @@ -68,7 +122,12 @@ class RadioLibWrapper : public mesh::Radio { uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + void resetStats() { + n_recv = n_sent = n_recv_errors = n_rx_ps_arm_failures = 0; + // Clearing diagnostics also grants a fresh set of arm attempts. If they + // fail again, the normal three-failure continuous-RX fallback applies. + _rx_ps_arm_retry.reset(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() const override; diff --git a/test/test_rx_power_saving/test_rx_power_saving.cpp b/test/test_rx_power_saving/test_rx_power_saving.cpp new file mode 100644 index 0000000000..3c769e0ac6 --- /dev/null +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -0,0 +1,444 @@ +#include + +#include + +#include "helpers/radiolib/RXPowerSaving.h" +#include "helpers/radiolib/RXPowerSavingCLI.h" + +class FakeRxPowerSavingControl : public RxPowerSavingControl { +public: + bool accept = true; + bool set_called = false; + bool requested_enabled = false; + uint32_t requested_rx_us = 0; + uint32_t requested_sleep_us = 0; + bool rf_rx_supported = false; + bool rf_rx_disabled = false; + RxPowerSavingStatus status; + + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override { + set_called = true; + requested_enabled = enabled; + requested_rx_us = rx_us; + requested_sleep_us = sleep_us; + return accept; + } + + RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; } + bool supportsRxPowerSavingRfRxDisable() const override { return rf_rx_supported; } + bool setRxPowerSavingRfRxDisabled(bool disabled) override { + if (!rf_rx_supported) return false; + rf_rx_disabled = disabled; + return true; + } + bool isRxPowerSavingRfRxDisabled() const override { return rf_rx_disabled; } +}; + +TEST(RxPowerSaving, DefaultsKeepRepeaterDisabledWithBalancedIntent) { + const RxPowerSavingConfig config; + + EXPECT_EQ(config.enabled, 0); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, RX_POWERSAVING_DEFAULT_RX_US); + EXPECT_EQ(config.sleep_us, RX_POWERSAVING_DEFAULT_SLEEP_US); +} + +TEST(RxPowerSaving, BaseControlRejectsEnableAndAcceptsContinuousRx) { + RxPowerSavingControl control; + + EXPECT_FALSE(control.setRxPowerSaving(true, 12345, 23456)); + EXPECT_TRUE(control.setRxPowerSaving(false, 12345, 23456)); + + const RxPowerSavingStatus status = control.getRxPowerSavingStatus(); + EXPECT_FALSE(status.supported); + EXPECT_FALSE(status.armed); + EXPECT_EQ(status.arm_failures, 0U); + EXPECT_EQ(status.effective_rx_us, 0U); + EXPECT_EQ(status.effective_sleep_us, 0U); +} + +TEST(RxPowerSaving, ArmRetryStopsAfterThreeFailuresAndSuccessResetsIt) { + RxPowerSavingArmRetryState retry; + + EXPECT_TRUE(retry.canAttempt()); + for (uint8_t expected = 1; expected <= RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; expected++) { + retry.recordFailure(); + EXPECT_EQ(retry.consecutiveFailures(), expected); + } + EXPECT_FALSE(retry.canAttempt()); + + // Saturate instead of wrapping if a caller records another failure. + retry.recordFailure(); + EXPECT_EQ(retry.consecutiveFailures(), RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES); + + retry.recordSuccess(); + EXPECT_EQ(retry.consecutiveFailures(), 0); + EXPECT_TRUE(retry.canAttempt()); +} + +TEST(RxPowerSaving, ClearingRetryStateGrantsThreeFreshAttempts) { + RxPowerSavingArmRetryState retry; + for (uint8_t i = 0; i < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; i++) { + retry.recordFailure(); + } + ASSERT_FALSE(retry.canAttempt()); + + retry.reset(); + EXPECT_TRUE(retry.canAttempt()); + EXPECT_EQ(retry.consecutiveFailures(), 0); +} + +TEST(RxPowerSaving, ArmTransactionRollsBackEveryFailureAndPreservesItsError) { + for (int failing_step = 0; failing_step < 3; failing_step++) { + int calls[3] = {0, 0, 0}; + int rollback_calls = 0; + auto step = [&](int index) -> int16_t { + calls[index]++; + return index == failing_step ? (int16_t)(-700 - index) : (int16_t)0; + }; + + int16_t result = runRxPowerSavingArmTransaction( + [&]() { return step(0); }, + [&]() { return step(1); }, + [&]() { return step(2); }, + [&]() { rollback_calls++; }); + + EXPECT_EQ(result, -700 - failing_step); + EXPECT_EQ(rollback_calls, 1); + for (int index = 0; index < 3; index++) { + EXPECT_EQ(calls[index], index <= failing_step ? 1 : 0); + } + } +} + +TEST(RxPowerSaving, ArmTransactionDoesNotRollBackSuccessfulArm) { + int step_calls = 0; + int rollback_calls = 0; + + int16_t result = runRxPowerSavingArmTransaction( + [&]() -> int16_t { step_calls++; return 0; }, + [&]() -> int16_t { step_calls++; return 0; }, + [&]() -> int16_t { step_calls++; return 0; }, + [&]() { rollback_calls++; }); + + EXPECT_EQ(result, 0); + EXPECT_EQ(step_calls, 3); + EXPECT_EQ(rollback_calls, 0); +} + +TEST(RxPowerSaving, RejectsInvalidProfileInputs) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + EXPECT_FALSE(calcRxPowerSavingLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(11, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 4, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 10, 0.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 10, 250.0f, 24, &rx_us, &sleep_us)); +} + +TEST(RxPowerSaving, AcceptsPeriodBoundariesOnly) { + EXPECT_FALSE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MIN_PERIOD_US - 1)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MIN_PERIOD_US)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MAX_PERIOD_US)); + EXPECT_FALSE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MAX_PERIOD_US + 1)); +} + +TEST(RxPowerSaving, RepairsInvalidPersistedPeriodsIndependently) { + uint32_t rx_us = 0; + uint32_t sleep_us = 12345; + + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); + EXPECT_EQ(rx_us, RX_POWERSAVING_DEFAULT_RX_US); + EXPECT_EQ(sleep_us, 12345U); + + rx_us = 23456; + sleep_us = RX_POWERSAVING_MAX_PERIOD_US + 1; + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); + EXPECT_EQ(rx_us, 23456U); + EXPECT_EQ(sleep_us, RX_POWERSAVING_DEFAULT_SLEEP_US); +} + +TEST(RxPowerSaving, NormalizesPersistedConfigBeforeApplying) { + RxPowerSavingConfig config; + config.enabled = 7; + config.level = 255; + config.preamble = 24; + config.rx_us = 0; + config.sleep_us = 0; + + normalizeRxPowerSavingConfig(&config, 10, 250.0f); + + EXPECT_EQ(config.enabled, 1); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, 41871U); + EXPECT_EQ(config.sleep_us, 26851U); +} + +TEST(RxPowerSaving, NumericInputIsStrictDecimal) { + EXPECT_TRUE(isRxPowerSavingNumeric("0")); + EXPECT_TRUE(isRxPowerSavingNumeric("123456")); + EXPECT_FALSE(isRxPowerSavingNumeric(nullptr)); + EXPECT_FALSE(isRxPowerSavingNumeric("")); + EXPECT_FALSE(isRxPowerSavingNumeric("-1")); + EXPECT_FALSE(isRxPowerSavingNumeric("1.5")); + EXPECT_FALSE(isRxPowerSavingNumeric("12x")); + EXPECT_FALSE(isRxPowerSavingNumeric(" 12")); +} + +TEST(RxPowerSaving, CompanionProfileIsLevelFivePreambleSixteen) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + ASSERT_TRUE(calcRxPowerSavingLevel(5, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 41871U); + EXPECT_EQ(sleep_us, 26851U); +} + +TEST(RxPowerSaving, NamedProfileConstantsRemainStable) { + EXPECT_EQ(RX_POWERSAVING_CONSERVATIVE_LEVEL, 1); + EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, 5); + EXPECT_EQ(RX_POWERSAVING_PROFILE_PREAMBLE, 16); +} + +TEST(RxPowerSaving, HigherLevelTradesListenTimeForSleepTime) { + uint32_t conservative_rx_us = 0; + uint32_t conservative_sleep_us = 0; + uint32_t aggressive_rx_us = 0; + uint32_t aggressive_sleep_us = 0; + + ASSERT_TRUE(calcRxPowerSavingLevel(1, 10, 250.0f, 16, + &conservative_rx_us, &conservative_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(10, 10, 250.0f, 16, + &aggressive_rx_us, &aggressive_sleep_us)); + EXPECT_GT(conservative_rx_us, aggressive_rx_us); + EXPECT_LT(conservative_sleep_us, aggressive_sleep_us); +} + +TEST(RxPowerSaving, AutoPreambleTracksSpreadingFactor) { + EXPECT_EQ(rxPowerSavingPreambleForSF(7), 32); + EXPECT_EQ(rxPowerSavingPreambleForSF(8), 32); + EXPECT_EQ(rxPowerSavingPreambleForSF(9), 16); + EXPECT_EQ(rxPowerSavingPreambleForSF(12), 16); +} + +TEST(RxPowerSaving, LevelIntentRetunesAfterRadioChange) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 8, 62.5f, 16, &rx_us, &sleep_us)); + uint32_t old_rx_us = rx_us; + uint32_t old_sleep_us = sleep_us; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 62.5f, 16, &rx_us, &sleep_us)); + EXPECT_NEAR((double)rx_us, (double)old_rx_us * 2.0, 1.0); + EXPECT_NEAR((double)sleep_us, (double)old_sleep_us * 2.0, 1.0); +} + +TEST(RxPowerSaving, AutomaticPreambleRetunesAcrossSfBoundary) { + uint32_t automatic_rx_us = 0; + uint32_t automatic_sleep_us = 0; + uint32_t explicit_rx_us = 0; + uint32_t explicit_sleep_us = 0; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 8, 250.0f, 0, + &automatic_rx_us, &automatic_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(5, 8, 250.0f, 32, + &explicit_rx_us, &explicit_sleep_us)); + EXPECT_EQ(automatic_rx_us, explicit_rx_us); + EXPECT_EQ(automatic_sleep_us, explicit_sleep_us); + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 250.0f, 0, + &automatic_rx_us, &automatic_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(5, 9, 250.0f, 16, + &explicit_rx_us, &explicit_sleep_us)); + EXPECT_EQ(automatic_rx_us, explicit_rx_us); + EXPECT_EQ(automatic_sleep_us, explicit_sleep_us); +} + +TEST(RxPowerSaving, ManualTimingsAreNotRetuned) { + uint32_t rx_us = 12345; + uint32_t sleep_us = 23456; + + EXPECT_FALSE(recalcRxPowerSavingFromLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 12345U); + EXPECT_EQ(sleep_us, 23456U); +} + +TEST(RxPowerSavingCLI, AppliesNamedAndManualProfiles) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_TRUE(control.set_called); + EXPECT_TRUE(control.requested_enabled); + EXPECT_EQ(config.enabled, 1); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, 41871U); + EXPECT_EQ(config.sleep_us, 26851U); + + control.set_called = false; + ASSERT_TRUE(RXPowerSavingCLI::set("12345 23456", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_TRUE(control.set_called); + EXPECT_EQ(config.level, 0); + EXPECT_EQ(config.preamble, 0); + EXPECT_EQ(config.rx_us, 12345U); + EXPECT_EQ(config.sleep_us, 23456U); +} + +TEST(RxPowerSavingCLI, StoresAutomaticPreambleIntentForLevel) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("level 5", 8, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, 5); + EXPECT_EQ(config.preamble, 0); + + uint32_t expected_rx_us = 0; + uint32_t expected_sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(5, 8, 250.0f, 32, + &expected_rx_us, &expected_sleep_us)); + EXPECT_EQ(config.rx_us, expected_rx_us); + EXPECT_EQ(config.sleep_us, expected_sleep_us); +} + +TEST(RxPowerSavingCLI, DoesNotPersistRejectedOrInvalidChanges) { + RxPowerSavingConfig config; + RxPowerSavingConfig original = config; + FakeRxPowerSavingControl control; + char reply[192]; + + EXPECT_FALSE(RXPowerSavingCLI::set("level 0", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.enabled, original.enabled); + EXPECT_EQ(config.level, original.level); + EXPECT_STREQ(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + + control.accept = false; + EXPECT_FALSE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.enabled, original.enabled); + EXPECT_EQ(config.level, original.level); + EXPECT_STREQ(reply, "ERROR: RX powersaving unsupported"); +} + +TEST(RxPowerSavingCLI, RejectsValuesBeforeNarrowingOrOverflow) { + RxPowerSavingConfig config; + const RxPowerSavingConfig original = config; + FakeRxPowerSavingControl control; + char reply[192]; + + const char* invalid_values[] = { + "261", // used to alias to level 5 after uint8_t truncation + "level 266", // used to alias to level 10 + "level 5 preamble 272", // used to alias to preamble 16 + "4294967296 1000", // one above UINT32_MAX + "999999999999999999999999999", // must not wrap during parsing + }; + + for (const char* value : invalid_values) { + control.set_called = false; + EXPECT_FALSE(RXPowerSavingCLI::set(value, 10, 250.0f, &config, &control, + reply, sizeof(reply))) << value; + EXPECT_FALSE(control.set_called) << value; + EXPECT_EQ(config.enabled, original.enabled) << value; + EXPECT_EQ(config.level, original.level) << value; + EXPECT_EQ(config.preamble, original.preamble) << value; + EXPECT_EQ(config.rx_us, original.rx_us) << value; + EXPECT_EQ(config.sleep_us, original.sleep_us) << value; + } +} + +TEST(RxPowerSavingCLI, DisablingKeepsTimingIntent) { + RxPowerSavingConfig config; + config.enabled = 1; + config.level = 7; + config.preamble = 32; + config.rx_us = 34567; + config.sleep_us = 45678; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("off", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(config.enabled, 0); + EXPECT_EQ(config.level, 7); + EXPECT_EQ(config.preamble, 32); + EXPECT_EQ(config.rx_us, 34567U); + EXPECT_EQ(config.sleep_us, 45678U); +} + +TEST(RxPowerSavingCLI, FormatsDesiredAndEffectiveStateSeparately) { + RxPowerSavingConfig config; + config.enabled = 1; + FakeRxPowerSavingControl control; + control.status = {true, false, -706, 2, 0, 0}; + char reply[192]; + + RXPowerSavingCLI::get(&config, &control, reply, sizeof(reply)); + + EXPECT_NE(std::strstr(reply, "desired=on,effective=continuous,supported=yes"), nullptr); + EXPECT_NE(std::strstr(reply, "err=-706,fail=2"), nullptr); + EXPECT_EQ(std::strstr(reply, "erx="), nullptr); // nothing armed, nothing to report +} + +TEST(RxPowerSavingCLI, ReportsClampedPeriodsOnlyWhenTheyDiffer) { + RxPowerSavingConfig config; + config.enabled = 1; + FakeRxPowerSavingControl control; + char reply[192]; + + // driver armed exactly what was asked for -> no extra fields + control.status = {true, true, 0, 0, config.rx_us, config.sleep_us}; + RXPowerSavingCLI::get(&config, &control, reply, sizeof(reply)); + EXPECT_NE(std::strstr(reply, "effective=armed"), nullptr); + EXPECT_EQ(std::strstr(reply, "erx="), nullptr); + + // driver had to stretch the RX window -> surface the real values + control.status = {true, true, 0, 0, config.rx_us + 4000, config.sleep_us}; + RXPowerSavingCLI::get(&config, &control, reply, sizeof(reply)); + EXPECT_NE(std::strstr(reply, "erx=69625,eslp=60000"), nullptr); +} + +TEST(RxPowerSavingCLI, GetDoesNotMutateStoredConfig) { + RxPowerSavingConfig config; + config.rx_us = 0; // invalid, e.g. from an older persisted prefs file + config.sleep_us = 999; + FakeRxPowerSavingControl control; + char reply[192]; + + RXPowerSavingCLI::get(&config, &control, reply, sizeof(reply)); + + EXPECT_EQ(config.rx_us, 0U); + EXPECT_EQ(config.sleep_us, 999U); +} + +TEST(RxPowerSavingCLI, RfRxDiagnosticIsRuntimeOnlyAndCapabilityGated) { + FakeRxPowerSavingControl control; + char reply[192]; + + RXPowerSavingCLI::setRfRxDisabled("on", &control, reply, sizeof(reply)); + EXPECT_STREQ(reply, "Error: unsupported"); + + control.rf_rx_supported = true; + RXPowerSavingCLI::setRfRxDisabled("on", &control, reply, sizeof(reply)); + EXPECT_TRUE(control.rf_rx_disabled); + EXPECT_STREQ(reply, "OK - radio.rxps.rfrx_disabled on"); + + RXPowerSavingCLI::getRfRxDisabled(&control, reply, sizeof(reply)); + EXPECT_STREQ(reply, "> on"); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}