From 6bbd34a2964f682c45f024fe0044882d06bcbaf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Sat, 5 Sep 2026 00:02:13 +0200 Subject: [PATCH] RXPS: use measured preamble-symbol capture model --- docs/cli_commands.md | 126 +++-- examples/companion_radio/MyMesh.cpp | 4 +- examples/simple_repeater/MyMesh.cpp | 3 +- platformio.ini | 9 + src/helpers/CommonCLI.cpp | 17 +- src/helpers/radiolib/CustomLR1110.h | 21 + src/helpers/radiolib/CustomLR1110Wrapper.h | 14 + src/helpers/radiolib/CustomSX1262.h | 41 +- src/helpers/radiolib/CustomSX1262Wrapper.h | 28 +- src/helpers/radiolib/RXPowerSaving.cpp | 175 ++++++- src/helpers/radiolib/RXPowerSaving.h | 212 +++++++- src/helpers/radiolib/RXPowerSavingCLI.cpp | 140 ++++-- src/helpers/radiolib/RXPowerSavingCLI.h | 8 +- src/helpers/radiolib/RadioLibWrappers.h | 3 - .../test_rx_power_saving.cpp | 474 ++++++++++++++++-- 15 files changed, 1088 insertions(+), 187 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 376b4e175b..f3176df352 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -280,13 +280,16 @@ This document provides an overview of CLI commands that can be sent to MeshCore #### View or change RX duty-cycle power saving **Usage:** - `get radio.rxps` -- `get radio.rxps.rfrx_disabled` -- `get rxps.wd` -- `set radio.rxps.rfrx_disabled ` - `set radio.rxps off` - `set radio.rxps on` - `set radio.rxps conservative` - `set radio.rxps balanced` +- `set radio.rxps max` +- `set radio.rxps max preamble <16|32>` +- `set radio.rxps overdrive` +- `set radio.rxps overdrive preamble <16|32>` +- `set radio.rxps riskyWorkingMax` +- `set radio.rxps riskyWorkingMax preamble <16|32>` - `set radio.rxps <1-10>` - `set radio.rxps level <1-10>` - `set radio.rxps level <1-10> preamble <16|32>` @@ -294,16 +297,14 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `rx_us`, `sleep_us`: Receive and sleep durations in microseconds (`1000`-`30000000`). -- `level`: A power-saving level from `1` (most conservative) to `10` (least power saving). +- `level`: `1`-`8`, expressed as **symbols of the sender's preamble the receiver catches** - how much of it lands inside an open RX window. Level `1` catches the most (15 symbols on the 16-symbol profile, 24 on the 32-symbol one) and costs the most power; level `8` catches 8 on both profiles and costs the least. Eight is the floor because that is what an LR11x0 needs to latch, so a level means the same geometry on either radio family. `9` (`overdrive`) and `10` (`riskyWorkingMax`) go below that floor and outside the datasheet timer condition; see below. - `preamble`: LoRa preamble length in symbols; `16` or `32`. - `state`: `on` or `off`. **Notes:** -- `get rxps.wd` reports the RXPS watchdog's soft and hard recovery counts. -- `radio.rxps.rfrx_disabled` is a runtime-only diagnostic setting and resets to `off` after reboot. -- Its default `off` state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to `on` reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive. -- `radio.rxps.rfrx_disabled` is supported only on SX1262 targets with a host-controlled RX enable pin. -- `on` and `conservative` select level `1` with a 16-symbol preamble; `balanced` selects level `5` with a 16-symbol preamble. +- `on` and `conservative` select level `3` (catches 13 symbols), `balanced` selects level `6` (catches 10), and `max` selects level `8` (catches 8); all three use a 16-symbol preamble. +- Levels `1`-`8` satisfy the SX1261/2 duty-cycle timer condition; `max` is the top of that range. `overdrive` (level `9`) and `riskyWorkingMax` (level `10`) trade that guarantee for lower measured duty cycles; see the fuller entries below. +- Like the other named profiles, `max`, `overdrive`, and `riskyWorkingMax` assume a 16-symbol sender preamble. At SF5-SF8 that is markedly less economical than the equivalent numeric level, which follows the SF onto the 32-symbol profile. Use the explicit `preamble 32` form to select the 32-symbol profile. - Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom ` ` timings remain fixed. - The selected mode is applied immediately, persisted, and restored after reboot. @@ -346,13 +347,19 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set radio.rxps on` - `set radio.rxps conservative` - `set radio.rxps balanced` +- `set radio.rxps max` +- `set radio.rxps max preamble ` +- `set radio.rxps overdrive` +- `set radio.rxps overdrive preamble ` +- `set radio.rxps riskyWorkingMax` +- `set radio.rxps riskyWorkingMax preamble ` - `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. +- `level`: `1-10`. Levels `1`-`8` are the guarded scale and are expressed in **symbols of the sender's preamble caught**: 15, 14, 13, 12, 11, 10, 9, 8 on the 16-symbol profile and 24, 20, 16, 14, 12, 10, 9, 8 on the 32-symbol one. Level `8` is named `max`. `9` (`overdrive`) and `10` (`riskyWorkingMax`) drop below the 8-symbol floor and outside the datasheet timer condition; only `10` is expected to lose packets. - `symbols`: `16` or `32` preamble symbols. - `rx_us`: receive-window duration in microseconds, `1000-30000000`. - `sleep_us`: radio sleep duration in microseconds, `1000-30000000`. @@ -360,23 +367,97 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Repeater default:** `off` **Profiles:** -- `on` and `conservative`: level 1 with a 16-symbol preamble. -- `balanced`: level 5 with a 16-symbol preamble. +- `on` and `conservative`: level 3, catches 13 symbols, with a 16-symbol preamble. +- `balanced`: level 6, catches 10 symbols, with a 16-symbol preamble. +- `max`: level 8, catches the 8-symbol floor, the cheapest guarded setting that still works on both radio families, with a 16-symbol preamble. +- `overdrive`: level 9, with a 16-symbol preamble. Outside the datasheet timer condition; see below. +- `riskyWorkingMax`: level 10, with a 16-symbol preamble. The measured edge of what still works, and the only setting here that is expected to drop packets; see below. +- Every named profile assumes a 16-symbol sender, which is the worst case a mixed network can present. At SF5-SF8 this is less economical than a numeric level, because a 32-symbol profile cannot catch a 16-symbol preamble. `max preamble 32`, `overdrive preamble 32`, and `riskyWorkingMax preamble 32` select the 32-symbol profile explicitly. - 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. +**How the timings are derived:** + +```text +sleep = (preamble - caught symbols) symbols +listen = whatever the duty-cycle timer condition needs at that sleep +``` + +The capture cost - 6 symbols on SX126x, 8 on LR11x0, both measured on the bench - +no longer enters this arithmetic; it is only a validation, since a level that +catches fewer symbols than the radio needs to latch is rejected. That is why the +same level produces the same periods on either family. The listen window is not +a free parameter: +it follows from the timer condition below. Three constraints are applied on top, +all of them measured rather than assumed: + +- **Sleep floor.** The driver subtracts the sleep-to-RX transition (`tcxoDelay + 1000 us`) from the requested sleep before writing the register. Below that the arm call fails outright; just above it the SX1262 arms with no error and then detects no preambles at all. Levels whose own sleep falls under the floor are raised to it, and collapse onto the same timing as a result. If even the floor would break preamble capture - a short symbol leaves no room between the two - the level is rejected with `ERROR: RXPS does not fit this SF/BW with preamble ` rather than silently falling back to continuous RX. +- **Timer condition.** Semtech requires `Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod`, because the radio restarts its receive timer with that value when it detects a preamble. Guarded levels `1`-`8` satisfy it; `overdrive` and `riskyWorkingMax` deliberately do not. When it is broken, an SX1262 usually still receives normally, but isolated `rxPeriod` register values one tick wide lose most of the packets they have already latched, and nothing in the value predicts which. LR11x0 has always enforced an equivalent rule in its driver. +- **Register granularity.** Both radio families program the duty cycle in 15.625 us ticks and truncate on the way in, so the reported periods are snapped to whole ticks and are the ones the hardware actually runs. + +**`overdrive` (level 9):** + +`overdrive` is the geometry that predates the timer condition being enforced: an +8-symbol receive window with the sleep at the capture limit. It breaks the +condition deliberately, in exchange for the lowest duty cycle available. The +name is meant in the overclocking sense - measured to work, outside the vendor's +stated envelope - rather than as a warning that it drops packets. It does not. + +| Assumed sender preamble | `overdrive` | Level 10 | Saving | +|---|---|---|---| +| 32 symbols | 23.5% | 27.5% (SF8) - 33.4% (SF6) | 4.0 - 9.9 pp | +| 16 symbols | 44.4% | 49.7% (SF8) - 56.6% (SF6) | 5.3 - 12.2 pp | + +Both figures are dimensionless in symbols, so they hold at any spreading factor. +The sleep floor and tick rounding still apply, and the receive window is moved +one tick if it lands on a register value measured to misbehave. That list of +values is not exhaustive, which is the whole of the residual risk: levels +`1`-`8` are safe on any register value, `overdrive` is safe on every value that +has been measured. It is a text-CLI setting only and is never selected by +companion firmware. + +**`riskyWorkingMax` (level 10):** + +The end of the road: the profile extrapolated past `overdrive` until packet +delivery started to fall, then stepped back to the last setting that still held. The +sleep goes past the capture budget on purpose, so a preamble sometimes arrives +while the radio is asleep - which is exactly why delivery falls. Measured at SF8 +with an LR1110 receiver witnessing every transmission on the same link: + +| Assumed sender preamble | Delivery | Sleep share | vs `overdrive` | +|---|---|---|---| +| 32 symbols | 196/200 (98.0%) | 79.3% | +2.8 pp sleep, -2.0 pp delivery | +| 16 symbols | 197/200 (98.5%) | 56.4% | +0.9 pp sleep, -1.0 pp delivery | + +`overdrive` on the same link and in the same run read 200/200 and 199/200. So +this level buys a little sleep and pays for it in packets, which is the whole +trade and the reason the name is what it is. The command is case-sensitive and +has no short form. On a mesh that relies on retries the cost may be acceptable; +on a link that matters it is not. + +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 `balanced` profile (level 6, catches 10 symbols, preamble 16) at startup and after radio-parameter changes. + +`set radio.rxps` echoes the timings it applied, so the periods the radio really +runs are visible immediately: + +```text +OK - on,level=9(overdrive),preamble=16,rx=32782,sleep=40954 +OK - on,level=10,preamble=32,rx=40375,sleep=106500 +OK - on,level=5,preamble=16,rx=49485,sleep=22750 +``` `get radio.rxps` reports: ```text desired=,effective=,supported=, -level=<0-10>,preamble=<0|16|32>,rx=,sleep=, +level=<0-10>[(overdrive)|(riskyWorkingMax)][,catch=],preamble=<0|16|32>,rx=,sleep=, err=,fail=[,erx=,eslp=] ``` - `desired` is the saved user setting. +- `level=9(overdrive)` and `level=10(riskyWorkingMax)` mark profiles that run outside the datasheet timer condition. Any other level is guarded. +- `catch` is how many symbols of a sender's preamble the node actually catches, and appears only for guarded levels. It is not simply the number the level asks for: the sleep floor can shorten the sleep further, and at SF6 with a 16-symbol profile that collapses levels 1-6 onto the same point. Reporting the requested value there would be a lie, so the effective one is reported instead. - `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. @@ -387,23 +468,6 @@ err=,fail=[,erx=,eslp=] --- -#### 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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 65fefddc9b..da07e3594d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -278,7 +278,9 @@ static void applyCompanionRxPowerSaving(uint8_t sf, float bw) { 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) && + RX_POWERSAVING_PROFILE_PREAMBLE, &rx_us, &sleep_us, + rxPowerSavingCaptureCost(control), + rxPowerSavingTransition(control)) && control->setRxPowerSaving(true, rx_us, sleep_us); if (!ok) { control->setRxPowerSaving(false, RX_POWERSAVING_DEFAULT_RX_US, diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4581545ef6..a50219eb06 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -11,8 +11,9 @@ static RxPowerSavingControl* getRxPowerSavingControl() { } static void applyRxPowerSavingConfig(NodePrefs& prefs, uint8_t sf, float bw) { - normalizeRxPowerSavingConfig(&prefs.rxps, sf, bw); RxPowerSavingControl* control = getRxPowerSavingControl(); + normalizeRxPowerSavingConfig(&prefs.rxps, sf, bw, rxPowerSavingCaptureCost(control), + rxPowerSavingTransition(control)); bool ok = control != nullptr ? control->setRxPowerSaving( prefs.rxps.enabled != 0, prefs.rxps.rx_us, prefs.rxps.sleep_us) diff --git a/platformio.ini b/platformio.ini index 0763f64c00..28311ed7b8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,15 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 -D LORA_FREQ=869.618 -D LORA_BW=62.5 -D LORA_SF=8 + ; TCXO startup delay in microseconds, written by CustomSX1262/CustomLR1110 + ; after begin(). RadioLib defaults to 5000 and nothing used to override it, so + ; every board paid 5 ms on every duty-cycle wake. Lowest working value measured + ; on five modules: T096 400, Tracker V2 300, Waveshare 150, ThinkNode M3 200, + ; T1000-E 200 us - 1600 keeps 4x margin over the worst of them. It also feeds + ; the RXPS sleep floor and timer guard (transition = this + 1000), so lowering + ; it makes low-SF profiles armable that were not. A board needing its own value + ; puts -D MC_TCXO_DELAY_US=... in its own build_flags; the later -D wins. + -D MC_TCXO_DELAY_US=1600 -D ENABLE_ADVERT_ON_BOOT=1 -D ENABLE_PRIVATE_KEY_IMPORT=1 ; NOTE: comment these out for more secure firmware -D ENABLE_PRIVATE_KEY_EXPORT=1 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index fe9a7e603b..ebda24201e 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -655,8 +655,6 @@ 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)) { @@ -677,7 +675,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _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); + &_prefs->rxps.rx_us, &_prefs->rxps.sleep_us, + rxPowerSavingCaptureCost(_rxps_control), rxPowerSavingTransition(_rxps_control)); _callbacks->savePrefs(); strcpy(reply, rxps_retuned ? "OK - reboot to apply (rxps retuned)" : "OK - reboot to apply"); } else { @@ -949,10 +948,14 @@ 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.rxps", 10) == 0 && + (config[10] == 0 || config[10] == ' ')) { + // Exact match plus an optional trailing space, the same shape as the `tx` + // key below. A plain strcmp() looks stricter but is a trap here: `get + // radio.rxps ` fails it, falls through to the `radio` prefix branch further + // down and cheerfully answers with the frequency and bandwidth. Every other + // key in this chain is a prefix match and so never noticed the space. + RXPowerSavingCLI::get(&_prefs->rxps, _rxps_control, _prefs->sf, _prefs->bw, 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/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index a49eee8352..0cfdb17af9 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -14,6 +14,23 @@ class CustomLR1110 : public LR1110 { public: CustomLR1110(Module *mod) : LR1110(mod) { } + // See CustomSX1262::begin - same reasoning, LR11x0 flags and calibration. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { + int16_t state = LR1110::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage); + if (state == RADIOLIB_ERR_NONE) applyMeshCoreTcxoDelay(); + return state; + } + + void applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return; + setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + calibrate(0x3F); // all blocks; setTCXO moved the gating window + delay(50); + } + size_t getPacketLength(bool update) override { size_t len = LR1110::getPacketLength(update); if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) { @@ -29,6 +46,10 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + // 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 diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 36faeae366..073eba0193 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -6,6 +6,20 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: + // Measured on ThinkNode M3 and T1000-E: this family needs roughly one symbol + // more margin than SX126x before a duty-cycled receiver latches a preamble. + float rxPowerSavingCaptureCostSymbols() const override { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0; + } + + // Same reasoning as the SX126x wrapper. This family never showed either + // duty-cycle failure on the bench, which is unsurprising: its driver has + // always enforced an extended-period rule equivalent to the timer guard the + // SX126x side only just gained. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomLR1110 *)_radio)->getTcxoDelay() + 1000; + } + CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 3f6977dba2..9036c8c3b0 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -2,17 +2,47 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" // MC_TCXO_DELAY_US class CustomSX1262 : public SX1262 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; uint32_t _activityAt = 0; bool _headerSeen = false; - bool _rx_ps_rf_rx_disabled = false; public: + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + CustomSX1262(Module *mod) : SX1262(mod) { } + // RadioLib's begin() installs its own 5000 us TCXO startup delay and + // setTCXO() does not recalibrate, so MeshCore's value has to be written + // afterwards and the RC calibrations redone against the new clock-gating + // window. Overriding begin() puts that in one place: every variant reaches + // the radio through this class, whether it calls std_init() or begin() + // directly, so no target.cpp or variant .ini has to know about it. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6, + bool useRegulatorLDO = false) { + int16_t state = SX1262::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage, useRegulatorLDO); + if (state == RADIOLIB_ERR_NONE) applyMeshCoreTcxoDelay(); + return state; + } + + // Read tcxoVoltage back from the chip object rather than from the argument: + // begin() zeroes it when it falls back to an XTAL, and re-asserting a TCXO + // supply on DIO3 for a board that has none would be worse than a long delay. + void applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return; + setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + calibrate(RADIOLIB_SX126X_CALIBRATE_ALL); + delay(50); + } + #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) #else @@ -110,7 +140,7 @@ class CustomSX1262 : public SX1262 { 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) { + if (state == RADIOLIB_ERR_NONE) { // 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. @@ -119,13 +149,6 @@ class CustomSX1262 : public SX1262 { 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); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 6d307a6c55..84fe32cf37 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -10,6 +10,15 @@ class CustomSX1262Wrapper : public RadioLibWrapper { public: + // Ask the radio rather than assume RadioLib's 5000 us default: startReceive- + // DutyCycle subtracts tcxoDelay + 1000 from the sleep and the register + // underflows below that, so the floor is a property of this board's TCXO. A + // board without one can duty cycle with a far shorter sleep. The 100 us on + // top clears the 15.625 us register tick that must remain. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomSX1262 *)_radio)->getTcxoDelay() + 1000; + } + CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { @@ -51,25 +60,6 @@ class CustomSX1262Wrapper : public RadioLibWrapper { 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 { diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp index f11e07fce8..913cb0b747 100644 --- a/src/helpers/radiolib/RXPowerSaving.cpp +++ b/src/helpers/radiolib/RXPowerSaving.cpp @@ -5,6 +5,29 @@ static uint32_t ceilRxPowerSavingValue(float value) { return value > (float)rounded ? rounded + 1 : rounded; } +// Both radios program their duty cycle in 15.625 us ticks and truncate the +// microsecond value on the way in, so a period of 10013 us is really 10000 us +// on air while the CLI happily reports 10013. Snapping to the nearest tick and +// returning the smallest microsecond value that still lands on it makes the +// reported periods the ones the hardware runs - which matters here, because the +// register value turned out to be what a duty-cycle defect keys on, not the +// microseconds we asked for. +static uint32_t rxPowerSavingTickToUs(uint64_t tick) { + if (tick == 0) tick = 1; + return (uint32_t)((tick * 125 + 7) / 8); // ceil(tick * 15.625) +} + +static uint32_t snapRxPowerSavingToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 62) / 125); // nearest +} + +// Rounding the listen window to the *nearest* tick can shave a few microseconds +// off, and the timer guard below has no room to give: it must hold with the +// register values the radio actually runs. So that one rounds up. +static uint32_t snapRxPowerSavingUpToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 124) / 125); // ceil +} + bool isValidRxPowerSavingPeriod(uint32_t us) { return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US; } @@ -22,25 +45,139 @@ bool isRxPowerSavingNumeric(const char* value) { return true; } +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingCaptureCostSymbols() + : RX_POWERSAVING_CAPTURE_COST_SYMBOLS; +} + +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingTransitionUs() + : RX_POWERSAVING_TRANSITION_US; +} + +// rxPeriod register values measured to break SetRxDutyCycle on SX1262: the +// radio latches the preamble and then never validates a header, losing 35% of +// packets at SF6 and all of them at SF7. Both were reproduced on two boards +// with an LR1110 witnessing 100% of the same transmissions, and both bands are +// exactly one tick wide - 639 and 641 are lossless. +// +// This list is certainly incomplete: about thirty values were sampled out of +// the thousands a profile can generate. Levels 1-10 do not need it because the +// timer guard makes any value safe; it exists only for the unguarded maximum, +// where it is the one protection left. +static const uint32_t RX_POWERSAVING_BAD_RX_TICKS[] = {320, 640}; + +static uint32_t avoidBadRxPowerSavingTick(uint32_t rx_us) { + const uint32_t tick = (rx_us * 8) / 125; + for (uint32_t bad : RX_POWERSAVING_BAD_RX_TICKS) { + // One tick longer costs 15.625 us of listening and steps clear of the band. + if (tick == bad) return (uint32_t)((((uint64_t)tick + 1) * 125 + 7) / 8); + } + return rx_us; +} + 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 || + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (rx_us == nullptr || sleep_us == nullptr || level < 1 || + level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 || bw <= 0.0f || (preamble != 16 && preamble != 32)) { return false; } + const bool unguarded = isRxPowerSavingUnguardedLevel(level); 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; + // Worst case a preamble starts the instant an RX window closes, so the part + // that survives into the next window is `preamble - sleep`. The absolute + // limit is therefore reached when that equals the chip's capture cost; the + // unguarded levels are pinned to geometries measured at or past that point, + // which is why they still need it. + const float sleep_edge_symbols = (float)preamble - capture_cost_symbols; - 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); + float sleep_symbols; + float rx_symbols = 0.0f; // only used by the unguarded levels + if (unguarded) { + // Kept on the old interpolation because that is the geometry the bench + // measured: overdrive is the top of the profile, and `riskyWorkingMax` is + // the point past it where delivery started to fall. + float amount = 1.0f; + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) { + const float virtual_level = preamble == 16 ? RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 + : RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32; + amount = (virtual_level - 1.0f) / 9.0f; + } + const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; + rx_symbols = rx_start_symbols + amount * (8.0f - rx_start_symbols); + sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + } else { + // Guarded levels state their catch directly, so the sleep is just what is + // left of the preamble - no capture cost involved. The pad makes the node + // catch a fifth of a symbol more than promised, which is what keeps the + // generated register pairs off the defective ones. + const float catch_symbols = rxPowerSavingLevelCatch(level, preamble); + // A level that undertakes to catch less than this radio needs to latch is + // not usable: say so rather than return a geometry that cannot work. + if (catch_symbols < capture_cost_symbols) return false; + sleep_symbols = (float)preamble - catch_symbols - RX_POWERSAVING_MARGIN_PAD_SYMBOLS; + if (sleep_symbols < 0.0f) sleep_symbols = 0.0f; + } - *rx_us = ceilRxPowerSavingValue(rx_symbols * symbol_us); *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + + // Below the hardware floor the duty cycle cannot be armed at all: the driver + // returns -708 and the wrapper quietly runs continuous RX, so the node keeps + // reporting power saving while spending full RX current. Just above that the + // SX1262 arms with ERR_NONE and then detects no preambles at all, which is + // worse. Measured floor: 6050-6100 us dead, 6200 us and up lossless. + const uint32_t min_sleep_us = transition_us + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + if (*sleep_us < min_sleep_us) { + if ((float)min_sleep_us > sleep_edge_symbols * symbol_us) { + // Even zero margin would not fit above the floor: no level of this + // profile can duty cycle at this SF/BW. Say so instead of returning + // something that will silently fall back. + return false; + } + // The floor eats into the margin. Levels below it collapse onto one point; + // the reply still reports the periods, so the loss of margin is visible. + *sleep_us = min_sleep_us; + } + *sleep_us = transition_us + snapRxPowerSavingToTick(*sleep_us - transition_us); + + // SetRxDutyCycle timer guard, straight out of the SX1261/2 datasheet: on + // preamble detection the radio restarts its timer with 2*rxPeriod + + // sleepPeriod (register values), and the packet dies if the header does not + // arrive inside it. The datasheet states the requirement as + // Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod + // Measured: when it holds, no period value causes trouble. When it is broken + // the SX1262 usually gets away with it - but not always, and the exceptions + // are a single register tick wide. rxPeriod 640 (exactly 10.000 ms) loses + // 60-65% of packets at SF6 and everything at SF7, on two different boards, + // while 639 and 641 are lossless; rxPeriod 320 behaves the same way. Nothing + // in the register value predicts which ticks misbehave, so satisfy the + // condition instead of dodging the values. The listen window is therefore not + // a free parameter on the guarded scale - it is whatever the condition needs. + const float sync_symbols = + sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF : RX_POWERSAVING_SYNC_SYMBOLS; + const float need_us = + ((float)preamble + sync_symbols + RX_POWERSAVING_HEADER_SYMBOLS + + RX_POWERSAVING_TIMER_SLACK_SYMBOLS) * symbol_us; + // Work in the radio's own units: the driver truncates (sleep - transition) to + // 15.625 us ticks, so the microsecond difference overstates the programmed + // sleep by up to one tick - enough to leave the condition short on every + // level of a profile. + const uint32_t sleep_ticks = ((*sleep_us - transition_us) * 8) / 125; + const float programmed_sleep_us = (float)sleep_ticks * RX_POWERSAVING_TICK_US; + + if (unguarded) { + *rx_us = ceilRxPowerSavingValue(rx_symbols * symbol_us); + } else { + const float min_rx_us = (need_us - programmed_sleep_us) / 2.0f; + *rx_us = ceilRxPowerSavingValue(min_rx_us > 0.0f ? min_rx_us : symbol_us); + } + + *rx_us = snapRxPowerSavingUpToTick(*rx_us); + if (unguarded) *rx_us = avoidBadRxPowerSavingTick(*rx_us); return true; } @@ -50,13 +187,15 @@ 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) { - if (level < 1 || level > 10) return false; + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (level < 1 || level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) 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) || + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &calculated_rx_us, &calculated_sleep_us, + capture_cost_symbols, transition_us) || !isValidRxPowerSavingPeriod(calculated_rx_us) || !isValidRxPowerSavingPeriod(calculated_sleep_us)) { return false; @@ -67,15 +206,19 @@ bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t p return true; } -void normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw) { +void normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols, uint32_t transition_us) { if (config == nullptr) return; config->enabled = config->enabled ? 1 : 0; - if (config->level > 10) config->level = RX_POWERSAVING_BALANCED_LEVEL; + if (config->level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + 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); + &config->rx_us, &config->sleep_us, capture_cost_symbols, + transition_us); } diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h index 7768c189b8..24ea528516 100644 --- a/src/helpers/radiolib/RXPowerSaving.h +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -7,11 +7,158 @@ 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; +// The level scale is a catch dial, in symbols of the sender's preamble. +// +// Level N means: "at least this many symbols of the sender's preamble land +// inside an open RX window." That is the quantity the radio actually needs - +// a preamble is latched once enough of it has been heard - so the level states +// the requirement directly instead of stating a distance from an edge. +// +// The arithmetic follows from that in one step. Worst case, a preamble starts +// the instant an RX window closes, so what survives into the next window is +// `preamble - sleep`. Requiring that to be at least the level's catch gives +// +// sleep = preamble - catch +// +// and the chip's capture cost never enters the geometry. It becomes a +// validation instead: a level is usable when its catch is at least the cost. +// The floor of the scale is 8 symbols precisely because that is what the +// slower of the two families needs (LR11x0 8, SX126x 6), so **every level +// means the same geometry on both radios** - which the previous margin-based +// scale could not do, because the cost sat inside its formula. +// +// The two profiles get their own ladders. On a 16-symbol preamble there are +// only 8 symbols to spend between the floor and the whole preamble, so the +// steps are single symbols; on a 32-symbol preamble there are 24, so the low +// levels can take much larger strides where reception is guaranteed anyway. +static constexpr uint8_t RX_POWERSAVING_GUARDED_LEVELS = 8; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P16[RX_POWERSAVING_GUARDED_LEVELS] = { + 15.0f, 14.0f, 13.0f, 12.0f, 11.0f, 10.0f, 9.0f, 8.0f +}; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P32[RX_POWERSAVING_GUARDED_LEVELS] = { + 24.0f, 20.0f, 16.0f, 14.0f, 12.0f, 10.0f, 9.0f, 8.0f +}; + +// Smallest catch any guarded level asks for, and the reason it is 8: the +// LR11x0 needs 8 symbols to latch where the SX126x needs 6. +static constexpr float RX_POWERSAVING_MIN_CATCH_SYMBOLS = 8.0f; + +// On top of the ladder, every guarded level sleeps this much less than the +// arithmetic demands - so it catches 0.2 symbols more than its level promises. +// The reason is not capture safety: it is that specific register pairs the +// calculation lands on are defective (preamble latched, header never +// validated) in the same way isolated rxPeriod ticks 320 and 640 are. Nudging +// every generated geometry off those points cost 0.05 mA and took the worst +// measured cell from 3.33% packet loss to 0.11% across the whole ladder. +// Levels 9 and 10 are exempt - their geometries are pinned to bench +// measurements and shifting them would invalidate that data. +static constexpr float RX_POWERSAVING_MARGIN_PAD_SYMBOLS = 0.2f; + +// Past the guarded scale sit two settings that break the datasheet's +// SetRxDutyCycle timer condition on purpose. Overdrive is lossless and 4-12 +// percentage points cheaper in duty cycle; `riskyWorkingMax` is the measured +// working edge and drops ~2% of packets by design. +static constexpr uint8_t RX_POWERSAVING_OVERDRIVE_LEVEL = 9; +static constexpr uint8_t RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL = 10; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32 = 11.0f; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 = 10.25f; + +// Top of the guarded scale: catches the ladder floor of 8 symbols (8.2 with +// the pad), the cheapest setting that still meets the datasheet condition and +// still works on both radio families. The CLI calls it `max`. +static constexpr uint8_t RX_POWERSAVING_MAX_LEVEL = RX_POWERSAVING_GUARDED_LEVELS; + +static constexpr uint8_t RX_POWERSAVING_CONSERVATIVE_LEVEL = 3; // catches 13 (P16) / 16 (P32) +static constexpr uint8_t RX_POWERSAVING_BALANCED_LEVEL = 6; // catches 10 (P16) / 10 (P32) static constexpr uint8_t RX_POWERSAVING_PROFILE_PREAMBLE = 16; static constexpr uint8_t RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES = 3; +// Symbols of a sender's preamble consumed before a packet can be latched: +// ~2 to latch the preamble plus ~2 lost to RX-window startup, measured on +// SX1262 at SF8/BW62.5. Capture needs sleep <= sender_preamble - this. +// Note this is well below the 8 symbols RadioLib quotes (SX126x.h) - that +// figure is the datasheet's margin for *reliable* latching, not the floor. +// +// Chip dependent, so it belongs with the driver rather than with each board: +// a wrapper overrides rxPowerSavingCaptureCostSymbols() and every variant using +// that radio picks it up. LR1110 needs about one symbol more - on the bench it +// held 300/300 at a margin of 7.78 symbols and started dropping packets at +// 6.89, for both profiles, where SX1262 is clean down to 6. +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS = 6.0f; +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0 = 8.0f; + +// TCXO startup delay handed to SetDIO3AsTCXOCtrl (SX126x DS 13.3.6) / +// SetTcxoMode (LR11x0 UM 6.3.2). RadioLib defaults it to 5000 us and nothing +// overrode it, so every board paid 5 ms on every duty-cycle wake whatever +// crystal was fitted. Measured lowest working value on five modules: T096 400, +// Tracker V2 300, Waveshare 150, ThinkNode M3 200, T1000-E 200 us - so 1600 +// keeps 4x margin over the worst of them. +// +// One knob for the whole tree, in `arduino_base`, because it is a property of +// the parts MeshCore is built with rather than of any one board. A variant that +// needs its own value defines it in its own build_flags and the later -D wins. +#ifndef MC_TCXO_DELAY_US + #define MC_TCXO_DELAY_US 1600 +#endif + +// Sleep -> RX transition: RadioLib subtracts tcxoDelay + 1000 us from the +// requested sleep before writing the register, and the hardware spends that +// long saving context, restarting the XTAL and locking the PLL. Everything +// below derives from it, so a board that knows its real TCXO delay overrides +// one method and both the sleep floor and the timer guard follow. +static constexpr uint32_t RX_POWERSAVING_TRANSITION_US = MC_TCXO_DELAY_US + 1000; + +// Slack the timer guard leaves on top of the datasheet condition, in symbols. +// +// Closing the condition exactly is not enough. The calculator lands on register +// pairs that satisfy it by a hair and still lose packets: rx=2476/sleep=2455 +// ticks clears it by 22 us at 0.12 symbol and drops 2-6% on every SX1262 tried, +// while moving any one component clears the fault. It is the same family as the +// isolated bad ticks 320 and 640 - the condition does not predict which pairs +// misbehave, so the answer is to stop generating geometries that sit on its +// edge. One whole symbol of slack is what the bench evidence supports. +#ifndef MC_RXPS_TIMER_SLACK_SYMBOLS + #define MC_RXPS_TIMER_SLACK_SYMBOLS 1.0f +#endif +static constexpr float RX_POWERSAVING_TIMER_SLACK_SYMBOLS = MC_RXPS_TIMER_SLACK_SYMBOLS; + +// Both radio families program their duty cycle in these units, and truncate the +// microsecond value on the way in. Everything the timer guard compares has to +// be expressed in whole ticks or it comes out short by a fraction of one. +static constexpr float RX_POWERSAVING_TICK_US = 15.625f; + +// Margin above the transition before a duty cycle actually works; see below. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_MARGIN_US = 250; + +// Symbols between the end of the sender's preamble and the end of the header: +// the LoRa sync word plus the 8-symbol explicit header. SF5/SF6 use a longer +// sync word than SF7 and above. Used by the SetRxDutyCycle timer guard. +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF = 6.25f; +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS = 4.25f; +static constexpr float RX_POWERSAVING_HEADER_SYMBOLS = 8.0f; + +// Shortest sleep that actually produces a working duty cycle. +// +// RadioLib subtracts tcxoDelay + 1000 us from the requested sleep before +// writing the register. At or below that the register underflows and arming +// fails with RADIOLIB_ERR_INVALID_SLEEP_PERIOD (-708), after which the wrapper +// falls back to continuous RX while the config still says power saving is on. +// Measured on SX1262: the P16 profile fails to arm on levels 1-5 at SF6 and on +// levels 1-2 at SF7, exactly where that bound predicts. +// +// But arming successfully is NOT the boundary. Just above it the SX1262 accepts +// the command, returns ERR_NONE, and then detects **zero** preambles - a +// silently deaf receiver, which is worse than the honest -708. Measured twice, +// on two different SF and RX windows: sleep 6050-6100 us is dead, 6200 us and +// everything above it is lossless (40/40 at every value up to 21617 us). The +// chip needs roughly 200 us of *programmed* sleep, i.e. ~12 register ticks, so +// this leaves 250 us of margin on top of the transition time. +// +// The default assumes the 5000 us TCXO delay RadioLib installs; a wrapper that +// can read its radio overrides this with the board's real value. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_US = + RX_POWERSAVING_TRANSITION_US + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + class RxPowerSavingArmRetryState { uint8_t _consecutive_failures = 0; @@ -59,21 +206,64 @@ class RxPowerSavingControl { 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; } + + // Symbols of the sender's preamble consumed before a packet can be latched. + // Overridden per radio family; see RX_POWERSAVING_CAPTURE_COST_SYMBOLS. + virtual float rxPowerSavingCaptureCostSymbols() const { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS; + } + + // Sleep -> RX transition of this radio, in microseconds. The sleep floor and + // the SetRxDutyCycle timer guard are both derived from it, so a wrapper that + // can read its own tcxoDelay only needs to override this one method. + virtual uint32_t rxPowerSavingTransitionUs() const { + return RX_POWERSAVING_TRANSITION_US; + } }; +// Margin this level asks for, in symbols. Only meaningful on the guarded scale. +// Symbols of the sender's preamble this level undertakes to catch. The two +// profiles have separate ladders, so the profile has to be passed in; anything +// other than 16 or 32 is treated as the 16-symbol profile, matching the +// validation in calcRxPowerSavingLevel(). +inline float rxPowerSavingLevelCatch(uint8_t level, uint8_t preamble) { + if (level < 1 || level > RX_POWERSAVING_GUARDED_LEVELS) return 0.0f; + return preamble == 32 ? RX_POWERSAVING_LEVEL_CATCH_P32[level - 1] + : RX_POWERSAVING_LEVEL_CATCH_P16[level - 1]; +} + +inline bool isRxPowerSavingOverdriveLevel(uint8_t level) { + return level == RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +inline bool isRxPowerSavingRiskyWorkingMaxLevel(uint8_t level) { + return level == RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; +} + +// Both levels past the guarded scale run outside the datasheet's timer +// condition. Worth surfacing wherever a level is reported. +inline bool isRxPowerSavingUnguardedLevel(uint8_t level) { + return level >= RX_POWERSAVING_OVERDRIVE_LEVEL; +} + bool isValidRxPowerSavingPeriod(uint32_t us); uint8_t rxPowerSavingPreambleForSF(uint8_t sf); bool isRxPowerSavingNumeric(const char* value); +// capture_cost_symbols defaults to the SX126x figure so callers without a radio +// handy still compile; anything holding a RxPowerSavingControl should pass +// rxPowerSavingCaptureCost(control) instead. +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control); +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control); bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, - uint32_t* rx_us, uint32_t* sleep_us); + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_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); + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +void normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); diff --git a/src/helpers/radiolib/RXPowerSavingCLI.cpp b/src/helpers/radiolib/RXPowerSavingCLI.cpp index bf32bf3001..61bb1e0010 100644 --- a/src/helpers/radiolib/RXPowerSavingCLI.cpp +++ b/src/helpers/radiolib/RXPowerSavingCLI.cpp @@ -5,6 +5,14 @@ #include #include +// Suffix printed after the level so an unguarded profile is never mistaken for +// an ordinary one, in both the `set` echo and the `get` report. +static const char* rxPowerSavingLevelTag(uint8_t level) { + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) return "(riskyWorkingMax)"; + if (isRxPowerSavingOverdriveLevel(level)) return "(overdrive)"; + return ""; +} + static bool parseRxPowerSavingUint32(const char* value, uint32_t* parsed) { if (parsed == nullptr || !isRxPowerSavingNumeric(value)) return false; @@ -39,6 +47,32 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, preamble = RX_POWERSAVING_PROFILE_PREAMBLE; level_requested = true; preamble_overridden = true; + } else if (strcmp(value, "max") == 0) { + // Top of the guarded scale. Like every named preset it assumes the worst + // case a mixed network can present - a sender using a 16-symbol preamble - + // so at SF<=8 it is markedly less economical than `level 10`, which follows + // the SF and picks the 32-symbol profile. `max preamble 32` gets that back. + proposed.enabled = 1; + level = RX_POWERSAVING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "overdrive") == 0) { + // One step past the guarded scale: measured lossless, outside the + // datasheet's timer condition. Same worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_OVERDRIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "riskyWorkingMax") == 0) { + // This one is not lossless, so the risk is explicit in its name. Same + // worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_RISKY_WORKING_MAX_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; @@ -49,7 +83,7 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, char input[128]; if (strlen(value) >= sizeof(input)) { snprintf(reply, reply_size, - "ERROR: use off|on|conservative|balanced|level <1-10>| "); + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); return false; } strcpy(input, value); @@ -59,17 +93,34 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, 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"); + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); return false; } level = (uint8_t)first; level_requested = true; proposed.enabled = 1; + } else if (count >= 2 && strcmp(parts[1], "preamble") == 0 && + (strcmp(parts[0], "max") == 0 || strcmp(parts[0], "overdrive") == 0 || + strcmp(parts[0], "riskyWorkingMax") == 0)) { + if (count != 3 || !parseRxPowerSavingUint32(parts[2], &first) || + (first != 16 && first != 32)) { + snprintf(reply, reply_size, "ERROR: use %s preamble <16|32>", parts[0]); + return false; + } + level = strcmp(parts[0], "overdrive") == 0 ? RX_POWERSAVING_OVERDRIVE_LEVEL + : strcmp(parts[0], "max") == 0 ? RX_POWERSAVING_MAX_LEVEL + : RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = (uint8_t)first; + level_requested = true; + preamble_overridden = 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"); + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); return false; } level = (uint8_t)first; @@ -79,8 +130,10 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, 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"); + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || + (second != 16 && second != 32)) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); return false; } level = (uint8_t)first; @@ -95,15 +148,23 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, proposed.enabled = 1; } else { snprintf(reply, reply_size, - "ERROR: use off|on|conservative|balanced|level <1-10>| "); + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|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"); + &proposed.rx_us, &proposed.sleep_us, + rxPowerSavingCaptureCost(control), + rxPowerSavingTransition(control))) { + // Level and preamble were validated above, so the only way to get here is + // that no sleep exists which both arms the hardware and still lets a + // preamble of this length be caught - i.e. the symbol is too short for RXPS + // at this SF/BW. Saying "level range is 1-10" would send the user hunting + // for the wrong thing. + snprintf(reply, reply_size, "ERROR: RXPS does not fit this SF/BW with preamble %lu", + (unsigned long)preamble); return false; } if (!isValidRxPowerSavingPeriod(proposed.rx_us) || @@ -131,8 +192,9 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, } *config = proposed; - snprintf(reply, reply_size, "OK - %s,level=%lu,preamble=%lu,rx=%lu,sleep=%lu", + snprintf(reply, reply_size, "OK - %s,level=%lu%s,preamble=%lu,rx=%lu,sleep=%lu", config->enabled ? "on" : "off", (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), (unsigned long)config->preamble, (unsigned long)config->rx_us, (unsigned long)config->sleep_us); return true; @@ -140,16 +202,37 @@ bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, const RxPowerSavingControl* control, - char* reply, size_t reply_size) { + uint8_t sf, float bw, char* reply, size_t reply_size) { if (config == nullptr || reply == nullptr || reply_size == 0) return; RxPowerSavingStatus status = control != nullptr ? control->getRxPowerSavingStatus() : RxPowerSavingStatus{}; + // Preamble symbols the node actually catches. Not simply the number the level + // asked for: the sleep floor can shorten the sleep further, and at SF6 with + // the 16-symbol profile that collapses the first few levels onto the same + // point. Reporting the requested value there would be a lie. + char margin[24]; + margin[0] = 0; + if (config->level >= 1 && config->level <= RX_POWERSAVING_GUARDED_LEVELS && + sf >= 5 && sf <= 12 && bw > 0.0f) { + const uint8_t preamble = config->preamble ? config->preamble : rxPowerSavingPreambleForSF(sf); + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float effective = (float)preamble - (float)config->sleep_us / symbol_us; + // No %f on this platform's printf, and rounding the whole and fractional + // parts separately overflows: 4.999 came out as "4.10". + const int tenths = (int)(effective * 10.0f + (effective < 0.0f ? -0.5f : 0.5f)); + snprintf(margin, sizeof(margin), ",catch=%d.%d", tenths / 10, + tenths < 0 ? -(tenths % 10) : tenths % 10); + } + + // Overdrive has to be visible here, otherwise it reads as an ordinary level + // and nobody remembers it runs outside the datasheet rule. int len = snprintf(reply, reply_size, - "> desired=%s,effective=%s,supported=%s,level=%lu,preamble=%lu,rx=%lu,sleep=%lu,err=%d,fail=%lu", + "> desired=%s,effective=%s,supported=%s,level=%lu%s%s,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, + rxPowerSavingLevelTag(config->level), margin, (unsigned long)config->preamble, (unsigned long)config->rx_us, (unsigned long)config->sleep_us, (int)status.last_error, (unsigned long)status.arm_failures); @@ -164,34 +247,3 @@ void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, (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 index dfd0eb98ce..62acfb95ac 100644 --- a/src/helpers/radiolib/RXPowerSavingCLI.h +++ b/src/helpers/radiolib/RXPowerSavingCLI.h @@ -8,10 +8,8 @@ class RXPowerSavingCLI { public: static bool set(const char* value, uint8_t sf, float bw, RxPowerSavingConfig* config, RxPowerSavingControl* control, char* reply, size_t reply_size); + // sf/bw are needed to turn the stored periods back into symbols, which is + // what the level scale is expressed in. 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); + uint8_t sf, float bw, char* reply, size_t reply_size); }; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 107d59b184..f7cdeafde6 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -93,9 +93,6 @@ class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { 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 { diff --git a/test/test_rx_power_saving/test_rx_power_saving.cpp b/test/test_rx_power_saving/test_rx_power_saving.cpp index f699e3bd9a..99a3b40974 100644 --- a/test/test_rx_power_saving/test_rx_power_saving.cpp +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -12,8 +12,6 @@ class FakeRxPowerSavingControl : public RxPowerSavingControl { 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 { @@ -25,13 +23,6 @@ class FakeRxPowerSavingControl : public RxPowerSavingControl { } 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) { @@ -94,6 +85,10 @@ TEST(RxPowerSaving, RejectsInvalidProfileInputs) { uint32_t sleep_us = 0; EXPECT_FALSE(calcRxPowerSavingLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + // 8 is the top of the guarded scale, 9 overdrive, 10 the practical maximum. + EXPECT_TRUE(calcRxPowerSavingLevel(8, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(9, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(10, 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)); @@ -135,8 +130,8 @@ TEST(RxPowerSaving, NormalizesPersistedConfigBeforeApplying) { 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); + EXPECT_EQ(config.rx_us, 49329U); + EXPECT_EQ(config.sleep_us, 23757U); } TEST(RxPowerSaving, NumericInputIsStrictDecimal) { @@ -150,19 +145,39 @@ TEST(RxPowerSaving, NumericInputIsStrictDecimal) { EXPECT_FALSE(isRxPowerSavingNumeric(" 12")); } -TEST(RxPowerSaving, CompanionProfileIsLevelFivePreambleSixteen) { +TEST(RxPowerSaving, CompanionProfileIsBalancedPreambleSixteen) { 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); + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_BALANCED_LEVEL, 10, 250.0f, 16, + &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 49329U); + EXPECT_EQ(sleep_us, 23757U); } -TEST(RxPowerSaving, NamedProfileConstantsRemainStable) { - EXPECT_EQ(RX_POWERSAVING_CONSERVATIVE_LEVEL, 1); - EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, 5); +TEST(RxPowerSaving, NamedProfilesSitWhereTheScaleSaysTheyDo) { + EXPECT_EQ(RX_POWERSAVING_CONSERVATIVE_LEVEL, 3); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_CONSERVATIVE_LEVEL, 16), 13.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_CONSERVATIVE_LEVEL, 32), 16.0f); + EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, 6); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_BALANCED_LEVEL, 16), 10.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_BALANCED_LEVEL, 32), 10.0f); + // The floor is shared by both profiles, and is what makes a level mean the + // same geometry on an SX126x and an LR11x0. + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_MAX_LEVEL, 16), + RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_MAX_LEVEL, 32), + RX_POWERSAVING_MIN_CATCH_SYMBOLS); EXPECT_EQ(RX_POWERSAVING_PROFILE_PREAMBLE, 16); + + // Margins fall monotonically and end at zero; nothing outside the guarded + // scale reports one. + for (uint8_t lv = 2; lv <= RX_POWERSAVING_GUARDED_LEVELS; lv++) { + EXPECT_LT(rxPowerSavingLevelCatch(lv, 16), rxPowerSavingLevelCatch(lv - 1, 16)); + EXPECT_LT(rxPowerSavingLevelCatch(lv, 32), rxPowerSavingLevelCatch(lv - 1, 32)); + } + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_OVERDRIVE_LEVEL, 16), 0.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(0, 16), 0.0f); } TEST(RxPowerSaving, HigherLevelTradesListenTimeForSleepTime) { @@ -195,8 +210,13 @@ TEST(RxPowerSaving, LevelIntentRetunesAfterRadioChange) { 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); + // Sleep is pure symbols, so it doubles with the symbol time. The listen + // window no longer does: the SetRxDutyCycle timer guard adds half of the + // sleep->RX transition, which is a fixed number of microseconds and does not + // scale with SF. So the window grows, but by less than a factor of two. + EXPECT_NEAR((double)sleep_us, (double)old_sleep_us * 2.0, 32.0); + EXPECT_GT(rx_us, old_rx_us); + EXPECT_LT((double)rx_us, (double)old_rx_us * 2.0); } TEST(RxPowerSaving, AutomaticPreambleRetunesAcrossSfBoundary) { @@ -234,15 +254,21 @@ TEST(RxPowerSavingCLI, AppliesNamedAndManualProfiles) { FakeRxPowerSavingControl control; char reply[192]; + ASSERT_TRUE(RXPowerSavingCLI::set("conservative", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, 3); + EXPECT_EQ(config.preamble, 16); + 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.level, 6); EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); - EXPECT_EQ(config.rx_us, 41871U); - EXPECT_EQ(config.sleep_us, 26851U); + EXPECT_EQ(config.rx_us, 49329U); + EXPECT_EQ(config.sleep_us, 23757U); control.set_called = false; ASSERT_TRUE(RXPowerSavingCLI::set("12345 23456", 10, 250.0f, &config, &control, @@ -282,7 +308,7 @@ TEST(RxPowerSavingCLI, DoesNotPersistRejectedOrInvalidChanges) { 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"); + EXPECT_STREQ(reply, "ERROR: level range is 1-10 (or max|overdrive|riskyWorkingMax); preamble is 16 or 32"); control.accept = false; EXPECT_FALSE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, @@ -346,7 +372,7 @@ TEST(RxPowerSavingCLI, FormatsDesiredAndEffectiveStateSeparately) { control.status = {true, false, -706, 2, 0, 0}; char reply[192]; - RXPowerSavingCLI::get(&config, &control, reply, sizeof(reply)); + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, 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); @@ -361,13 +387,13 @@ TEST(RxPowerSavingCLI, ReportsClampedPeriodsOnlyWhenTheyDiffer) { // 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)); + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, 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)); + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); EXPECT_NE(std::strstr(reply, "erx=69625,eslp=60000"), nullptr); } @@ -378,29 +404,397 @@ TEST(RxPowerSavingCLI, GetDoesNotMutateStoredConfig) { FakeRxPowerSavingControl control; char reply[192]; - RXPowerSavingCLI::get(&config, &control, reply, sizeof(reply)); + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); EXPECT_EQ(config.rx_us, 0U); EXPECT_EQ(config.sleep_us, 999U); } -TEST(RxPowerSavingCLI, RfRxDiagnosticIsRuntimeOnlyAndCapabilityGated) { + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +// Measured on SX1262 at SF6/BW62.5: the P16 profile's own sleep is below the +// driver's arming floor on levels 1-5 (and on levels 1-2 at SF7), where +// startReceiveDutyCycle answers -708 and the wrapper falls back to continuous +// RX while the config still reports power saving as on. +TEST(RxPowerSaving, ShortSleepIsRaisedToTheArmingFloor) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + // SF6/BW62.5: level 1 of the P16 profile asks for 2 symbols = 2048 us. + ASSERT_TRUE(calcRxPowerSavingLevel(1, 6, 62.5f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(sleep_us, RX_POWERSAVING_MIN_SLEEP_US); + // Raising it must not break capture: the budget is preamble - cost symbols. + EXPECT_LE(sleep_us, (uint32_t)((16.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS) * 1024.0f)); + + // Only level 2 still lands under the floor. Before MC_TCXO_DELAY_US dropped + // the transition from 6000 to 2600 us the floor was 6250 us and swallowed + // levels 2-6 as well, so most of the 16-symbol ladder collapsed onto one + // point at SF6 - the node quietly slept longer, and caught fewer symbols, + // than the level it was set to promised. + for (uint8_t lv = 2; lv <= 2; lv++) { + uint32_t rx_n = 0; + uint32_t sleep_n = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(lv, 6, 62.5f, 16, &rx_n, &sleep_n)) << (int)lv; + EXPECT_EQ(sleep_n, RX_POWERSAVING_MIN_SLEEP_US) << (int)lv; + EXPECT_EQ(rx_n, rx_us) << (int)lv; + } + + // Level 4 sleeps 3.8 symbols, which now fits above the floor, so it gets its + // own point and saves more power than the collapsed ones. + uint32_t rx5_us = 0; + uint32_t sleep5_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(4, 6, 62.5f, 16, &rx5_us, &sleep5_us)); + EXPECT_GT(sleep5_us, RX_POWERSAVING_MIN_SLEEP_US); + EXPECT_LT((double)rx5_us / (rx5_us + sleep5_us), + (double)rx_us / (rx_us + sleep_us)); +} + +TEST(RxPowerSaving, RejectsProfilesWhoseFloorWouldBreakCapture) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + // SF6/BW250: the symbol is 256 us, so the whole P16 capture budget is + // 10 symbols = 2560 us - under the arming floor. No level can duty cycle + // here, and the caller has to hear that rather than get a silent fallback. + // + // This used to be SF7/BW250, which the lower transition has since brought + // within reach: its budget is 5120 us against a floor that fell from 6250 to + // 2850, so the whole P16 ladder became armable there. The refusal path still + // needs a setting that is genuinely out of reach, so the example moved down + // one SF rather than away. + EXPECT_FALSE(calcRxPowerSavingLevel(1, 6, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 6, 250.0f, 16, + &rx_us, &sleep_us)); + + // The P32 profile has 26 symbols of budget at the same setting and survives. + EXPECT_TRUE(calcRxPowerSavingLevel(1, 6, 250.0f, 32, &rx_us, &sleep_us)); + EXPECT_GE(sleep_us, RX_POWERSAVING_MIN_SLEEP_US); + + // And the setting that moved: SF7/BW250 with P16 now arms on every level. + EXPECT_TRUE(calcRxPowerSavingLevel(1, 7, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 7, 250.0f, 16, + &rx_us, &sleep_us)); +} + +TEST(RxPowerSaving, TransitionTimeComesFromTheRadioLikeCaptureCost) { + class SlowTcxoControl : public RxPowerSavingControl { + public: + uint32_t rxPowerSavingTransitionUs() const override { return 11750; } + } slow; + + EXPECT_EQ(rxPowerSavingTransition(nullptr), RX_POWERSAVING_TRANSITION_US); + EXPECT_EQ(rxPowerSavingTransition(&slow), 11750u); + + // SF7 rather than SF6: a 12 ms floor does not fit inside the 10-symbol + // capture budget of a 1024 us symbol, which is the rejection case above. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(1, 7, 62.5f, 16, &rx_us, &sleep_us, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + rxPowerSavingTransition(&slow))); + // Floor is transition + margin, snapped to a whole 15.625 us tick. + EXPECT_GE(sleep_us, 12000u); + EXPECT_LT(sleep_us, 12020u); +} + +TEST(RxPowerSavingCLI, ReportsWhenNoLevelCanDutyCycleAtThisRadioSetting) { + RxPowerSavingConfig config; FakeRxPowerSavingControl control; - char reply[192]; + char reply[160]; - RXPowerSavingCLI::setRfRxDisabled("on", &control, reply, sizeof(reply)); - EXPECT_STREQ(reply, "Error: unsupported"); + // SF6/BW250 with the P16 profile: nothing between the arming floor and the + // capture budget, so the level must be refused with a message that names the + // real reason instead of blaming the level range. + EXPECT_FALSE(RXPowerSavingCLI::set("level 5 preamble 16", 6, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_NE(strstr(reply, "SF/BW"), nullptr); + EXPECT_FALSE(control.set_called); + EXPECT_EQ(config.enabled, 0); +} - 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"); +// SX1261/2 datasheet, SetRxDutyCycle: on preamble detection the radio restarts +// its timer with 2*rxPeriod + sleepPeriod and requires +// Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod +// Measured on two SX1262 boards: when that holds nothing goes wrong, and when +// it is broken the chip usually gets away with it - except at isolated register +// values one tick wide (rxPeriod 320 and 640) where it loses 35-100% of the +// packets it has already latched. Satisfying the condition is the only defence +// that does not depend on knowing every bad tick. +TEST(RxPowerSaving, EveryLevelSatisfiesTheDutyCycleTimerCondition) { + const uint8_t sfs[] = {6, 7, 8, 10, 12}; + const float bws[] = {62.5f, 250.0f}; + const uint8_t preambles[] = {16, 32}; + + for (uint8_t sf : sfs) { + for (float bw : bws) { + for (uint8_t preamble : preambles) { + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &rx_us, &sleep_us)) { + continue; // rejected outright; nothing is armed, nothing to check + } + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float sync = sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + : RX_POWERSAVING_SYNC_SYMBOLS; + const float need = + ((float)preamble + sync + RX_POWERSAVING_HEADER_SYMBOLS) * symbol_us; + // Compare on the register values the radio really runs, not on the + // microseconds we asked for: the driver truncates to 15.625 us ticks. + const uint32_t rx_ticks = (rx_us * 8) / 125; + const uint32_t sleep_ticks = + ((sleep_us - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + const float restarted = (2.0f * rx_ticks + sleep_ticks) * 15.625f; + EXPECT_GE(restarted, need) + << "SF" << (int)sf << " BW" << bw << " P" << (int)preamble + << " level " << (int)level; + // And the periods handed out must themselves be whole ticks, so the + // CLI reports what the hardware runs. + EXPECT_EQ((rx_ticks * 125 + 7) / 8, rx_us); + } + } + } + } +} - RXPowerSavingCLI::getRfRxDisabled(&control, reply, sizeof(reply)); - EXPECT_STREQ(reply, "> on"); +// Level 11 is the measured maximum: the geometry the bench ran before the timer +// guard existed, kept because the guard costs 4 to 12 percentage points of duty +// cycle. 8 symbols of listening, sleep right at the capture budget. +TEST(RxPowerSaving, OverdriveIsTheMeasuredGeometryAtEverySf) { + const uint8_t sfs[] = {6, 7, 8, 10}; + for (uint8_t sf : sfs) { + for (uint8_t preamble : {16, 32}) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, sf, 62.5f, + (uint8_t)preamble, &rx_us, &sleep_us)) + << "SF" << (int)sf << " P" << preamble; + const float symbol_us = (1000.0f * (float)(1UL << sf)) / 62.5f; + EXPECT_NEAR(rx_us / symbol_us, 8.0f /* rx_edge_symbols */, 0.02f); + EXPECT_NEAR(sleep_us / symbol_us, + preamble - RX_POWERSAVING_CAPTURE_COST_SYMBOLS, 0.02f); + // The whole point: 23.5% duty on P32, 44.4% on P16, at every SF. + const double duty = 100.0 * rx_us / (rx_us + sleep_us); + EXPECT_NEAR(duty, preamble == 32 ? 23.5 : 44.4, 0.2); + } + } } -int main(int argc, char** argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); +TEST(RxPowerSaving, OverdriveDeliberatelyBreaksTheTimerCondition) { + // Stated as a test so nobody later "fixes" it into compliance by accident: + // level 11 exists precisely because it sits outside the datasheet rule, and + // it was measured lossless there on three boards across SF6, SF7 and SF8. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 6, 62.5f, 32, + &rx_us, &sleep_us)); + const float symbol_us = (1000.0f * 64.0f) / 62.5f; + const float need = (32.0f + RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + + RX_POWERSAVING_HEADER_SYMBOLS) * symbol_us; + const uint32_t rx_ticks = (rx_us * 8) / 125; + const uint32_t sleep_ticks = ((sleep_us - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + EXPECT_LT((2.0f * rx_ticks + sleep_ticks) * RX_POWERSAVING_TICK_US, need); + + // and the top of the guarded scale must still satisfy it + uint32_t g_rx = 0; + uint32_t g_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 6, 62.5f, 32, &g_rx, &g_sleep)); + const uint32_t g_rx_ticks = (g_rx * 8) / 125; + const uint32_t g_sleep_ticks = ((g_sleep - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + EXPECT_GE((2.0f * g_rx_ticks + g_sleep_ticks) * RX_POWERSAVING_TICK_US, need); + EXPECT_LT(rx_us, g_rx); // and it really does listen less +} + +TEST(RxPowerSaving, OverdriveStepsOffTheKnownBadRegisterTicks) { + // 8 symbols land on rxPeriod tick 320 when the symbol is 625 us. No standard + // bandwidth produces that, which is why production never hit it - but the + // unguarded path has no other protection, so the check has to work. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 6, 102.4f, 32, + &rx_us, &sleep_us)); + EXPECT_EQ((rx_us * 8) / 125, 321u); // nudged one tick clear of 320 + EXPECT_EQ(rx_us, 5016u); + + // The guarded scale needs no such help and must be left alone. + uint32_t g_rx = 0; + uint32_t g_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(10, 6, 102.4f, 32, &g_rx, &g_sleep)); + EXPECT_NE((g_rx * 8) / 125, 320u); +} + +TEST(RxPowerSavingCLI, OverdriveAndMaxPresetsSelectTheRightLevels) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + // `max` is the top of the guarded scale, and like every named preset it + // assumes a 16-symbol sender, so at SF<=8 it is deliberately less economical + // than `level 8`, which follows the SF onto the 32-symbol profile. + ASSERT_TRUE(RXPowerSavingCLI::set("max", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_EQ(config.level, 8); + EXPECT_EQ(rxPowerSavingLevelCatch(config.level, 16), RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(config.preamble, 16); + EXPECT_EQ(strstr(reply, "overdrive"), nullptr); + const uint32_t max_rx = config.rx_us; + const uint32_t max_sleep = config.sleep_us; + + ASSERT_TRUE(RXPowerSavingCLI::set("level 8", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.preamble, 0); + EXPECT_LT((double)config.rx_us / (config.rx_us + config.sleep_us), + (double)max_rx / (max_rx + max_sleep)); + + // `overdrive` is one step past it, on the same worst-case preamble. + ASSERT_TRUE(RXPowerSavingCLI::set("overdrive", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_OVERDRIVE_LEVEL); + EXPECT_EQ(config.preamble, 16); + EXPECT_NE(strstr(reply, "overdrive"), nullptr); + EXPECT_LT(config.rx_us, max_rx); // and really does listen less + + control.status.supported = true; + control.status.armed = true; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(strstr(reply, "(overdrive)"), nullptr); + + // Both names take an explicit preamble, which is how the 32-symbol profile + // is reached without giving up the preset. + ASSERT_TRUE(RXPowerSavingCLI::set("overdrive preamble 32", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_OVERDRIVE_LEVEL); + EXPECT_EQ(config.preamble, 32); + ASSERT_TRUE(RXPowerSavingCLI::set("max preamble 32", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_EQ(config.preamble, 32); + + EXPECT_FALSE(RXPowerSavingCLI::set("overdrive preamble 24", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_FALSE(RXPowerSavingCLI::set("max preamble", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_FALSE(RXPowerSavingCLI::set("level 11", 8, 62.5f, &config, &control, + reply, sizeof(reply))); +} + +TEST(RxPowerSaving, RiskyWorkingMaxIsTheMeasuredEdgeAndCostsDelivery) { + // The bench walked the profile past level 10 until delivery came back: + // virtual 11.0 for P32, 10.25 for P16. Both put the sleep beyond the capture + // budget, which is the whole reason they lose packets - 196/200 and 197/200 + // at SF8 with an LR1110 witnessing every transmission. + const float symbol_us = (1000.0f * 256.0f) / 62.5f; // SF8 / BW62.5 + + uint32_t rx32 = 0; + uint32_t sleep32 = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL, 8, 62.5f, 32, + &rx32, &sleep32)); + EXPECT_NEAR(rx32 / symbol_us, 7.111f, 0.02f); + EXPECT_NEAR(sleep32 / symbol_us, 27.222f, 0.02f); + EXPECT_GT(sleep32 / symbol_us, 32.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS); + + uint32_t rx16 = 0; + uint32_t sleep16 = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL, 8, 62.5f, 16, + &rx16, &sleep16)); + EXPECT_NEAR(rx16 / symbol_us, 7.889f, 0.02f); + EXPECT_NEAR(sleep16 / symbol_us, 10.222f, 0.02f); + EXPECT_GT(sleep16 / symbol_us, 16.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS); + + // It must sleep more than overdrive, or it would have no reason to exist. + uint32_t o_rx = 0; + uint32_t o_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 8, 62.5f, 32, + &o_rx, &o_sleep)); + EXPECT_GT((double)sleep32 / (rx32 + sleep32), (double)o_sleep / (o_rx + o_sleep)); + + // Both levels past the guarded scale skip the timer guard. + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL)); + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_OVERDRIVE_LEVEL)); + EXPECT_FALSE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_MAX_LEVEL)); +} + +TEST(RxPowerSavingCLI, RiskyWorkingMaxUsesOneNameEverywhere) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + ASSERT_TRUE(RXPowerSavingCLI::set("riskyWorkingMax", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + EXPECT_EQ(config.preamble, 16); + EXPECT_NE(strstr(reply, "(riskyWorkingMax)"), nullptr); + + ASSERT_TRUE(RXPowerSavingCLI::set("riskyWorkingMax preamble 32", 8, 62.5f, + &config, &control, reply, sizeof(reply))); + EXPECT_EQ(config.preamble, 32); + + control.status.supported = true; + control.status.armed = true; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(strstr(reply, "(riskyWorkingMax)"), nullptr); + + // The name remains case-sensitive and has no shortcut. + EXPECT_FALSE(RXPowerSavingCLI::set("riskyworkingmax", 8, 62.5f, &config, &control, + reply, sizeof(reply))); +} + +// The whole point of the scale: level N means "a sender's preamble may be N +// symbols shorter than this profile assumes and still be caught". That has to +// hold on every SF, bandwidth and profile, because being dimensionless is the +// reason the scale is expressed this way rather than in microseconds or in +// milliamperes - both of which differ per board. +TEST(RxPowerSaving, EveryGuardedLevelDeliversTheMarginItPromises) { + const uint8_t sfs[] = {7, 8, 9, 10, 12}; + const float bws[] = {62.5f, 125.0f, 250.0f}; + const uint8_t preambles[] = {16, 32}; + + for (uint8_t sf : sfs) { + for (float bw : bws) { + for (uint8_t preamble : preambles) { + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &rx_us, &sleep_us)) { + continue; // rejected outright, nothing to check + } + if (sleep_us == RX_POWERSAVING_MIN_SLEEP_US) { + continue; // the floor ate the margin; reported by the CLI, not a bug + } + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float caught = (float)preamble - (float)sleep_us / symbol_us; + // The delivered catch is the ladder value plus the pad that keeps + // every generated geometry off the defective register pairs. + EXPECT_NEAR(caught, + rxPowerSavingLevelCatch(level, preamble) + RX_POWERSAVING_MARGIN_PAD_SYMBOLS, + 0.05f) + << "SF" << (int)sf << " BW" << bw << " P" << (int)preamble + << " level " << (int)level; + } + } + } + } +} + +TEST(RxPowerSaving, ALevelMeansTheSameGeometryOnBothRadioFamilies) { + // This is what the catch scale buys that the old margin scale could not: the + // capture cost is no longer part of the arithmetic, so a level produces the + // same periods whether the driver declares 6 symbols (SX126x) or 8 (LR11x0). + // Before, the same level number meant two different sleeps on the two chips. + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + for (uint8_t preamble : {16, 32}) { + uint32_t rx_sx = 0, sleep_sx = 0, rx_lr = 0, sleep_lr = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, preamble, &rx_sx, &sleep_sx, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS)); + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, preamble, &rx_lr, &sleep_lr, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0)); + EXPECT_EQ(sleep_sx, sleep_lr) << "level " << (int)level << " P" << (int)preamble; + EXPECT_EQ(rx_sx, rx_lr) << "level " << (int)level << " P" << (int)preamble; + } + } }