diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md index 9ed28369..b6b3699b 100644 --- a/docs/development/roadmap.md +++ b/docs/development/roadmap.md @@ -344,7 +344,7 @@ if pursued. **On branch `feat/power-saving`** — two independent toggles under Settings › Radio, both **default OFF**. Under field testing; not yet merged. -**✅ Done — hardware duty-cycle RX ("Pwr save")** +**⛔ Disabled (2026-09-22) — hardware duty-cycle RX ("Pwr save")** - Uses the SX126x's own **RX duty-cycle** (`SetRxDutyCycle`, datasheet 13.1.7) via RadioLib `startReceiveDutyCycleAuto(preamble, 8)`: the chip's sequencer cycles RX↔sleep, latches a preamble and stays in RX to receive the packet (RX_DONE on @@ -352,20 +352,60 @@ Radio, both **default OFF**. Under field testing; not yet merged. continuous RX. `armRecv()` arms duty-cycle when power-save is on, else a normal `startReceive()`; `loop()` re-arms only on a toggle. Falls back to continuous RX if the modem doesn't support duty-cycle (non-SX126x). `state` stays `STATE_RX` - so the dispatcher's not-in-RX watchdog never trips. -- Duty-cycle engages when the configured preamble ≥ 2·8+1 symbols. At SF≤8 the - preamble is 32 → full duty-cycle; at SF9–12 it is 16 → RadioLib transparently - stays on continuous RX (no power saving on the slow SFs). -- Companion: `rx_powersave` pref (schema `0xC0DE0009`), **Settings › Radio › - "Pwr save"**, applied at boot (MyMesh) and on change (UITask). Noise-floor - sampling is skipped while on (chip is asleep most of the time) — the radio page - shows "Noise floor: n/a". + so the dispatcher's not-in-RX watchdog never trips. Duty-cycle engages when the + *assumed sender* preamble ≥ 2·8+1 symbols; using our own outgoing preamble + (`preambleLengthForSF(sf)`: 32 at SF≤8, 16 at SF9-12) as that assumption meant + duty-cycle only ever actually engaged at SF≤8. +- **Field report + investigation:** a user on the stock "EU/UK (Narrow)" preset + (SF8) saw reception drop from ~1-5 msg/min to ~1/3h with Pwr save on, unaffected + by a better antenna. Root cause, confirmed against the SX1262 datasheet and + RadioLib's own maintainers ([jgromes/RadioLib#1597](https://github.com/jgromes/RadioLib/issues/1597), + closed as inherent chip behaviour, not a library bug): the SX126x's duty-cycle + preamble-detection state machine restarts every sleep/wake cycle and needs the + *actual transmitted* preamble to closely match what we've configured our + receiver to expect — tolerance in that issue's own testing was only 1-2 symbols + either way, well short of covering e.g. a repeater still on pre-v1.16 firmware + (16 symbols vs. our 32). A mismatch isn't a gradual sensitivity hit, it + deterministically drops every packet from that sender no matter the signal + strength — and a lone node mostly hears repeater rebroadcasts, exactly the + nodes least likely to be freshly updated. There is no software workaround: + RadioLib's own parameters only trade which senders you're blind to (shortening + `minSymbols` to tolerate a shorter assumed preamble directly shortens the + wake-window's correlator dwell time, trading the preamble-mismatch failure mode + for a marginal-signal one instead). A network-wide capability negotiation (e.g. + via the still-unused `ADV_FEAT1_MASK`/`ADV_FEAT2_MASK` fields already reserved + in `AdvertDataHelpers.h`) could plausibly gate this safely, but is a real + feature, not a quick fix — rejected for now as out of scope. Checked whether + IoTThinks' MeshCore fork (github.com/IoTThinks/MeshCore) had solved this: it + hasn't, and doesn't hit the problem at all, because its "power saving" never + touches the radio — `ESP32Board::sleep()`/NRF52 `board.sleep(0)` only light-sleep + the **MCU**, waking on the radio's own DIO1 GPIO interrupt while the radio itself + stays in plain continuous RX the whole time. That's the same MCU-idle mechanism + already noted below (native NRF52 companion power-saving from the v1.16 + upstream merge) — safe, but doesn't touch the dominant power draw (the radio in + continuous RX), unlike a real duty-cycle. +- **Resolution:** `examples/companion_radio/MyMesh.h` now defines + `FEAT_RX_POWERSAVE 0`, gating out the Settings row (`SettingsScreen.h`), the + Diagnostics RXPS watchdog row (`DiagnosticsScreen.h`), and every call site that + would apply `_prefs.rx_powersave` to the radio or to CAD auto-enable + (`MyMesh.cpp`, `UITask::applyPowerSave()`) — including forcing + `setPowerSaving(false)` unconditionally so a *stale* `rx_powersave=1` byte left + over in an existing prefs file from before this change can't do anything either. + The real duty-cycle implementation itself + (`RadioLibWrapper`/`CustomSX1262Wrapper::startPowerSaveRecv()`) is left in place, + unneutered, matching our own preamble convention — it's simply unreachable now. + Flipping `FEAT_RX_POWERSAVE` back on requires solving the network-compatibility + problem above first, not just re-adding the toggle. +- Companion: `rx_powersave` pref (schema `0xC0DE0009`) still exists in + `NodePrefs`/`DataStore` purely for file-format stability; nothing reads it + anywhere behavior-relevant while `FEAT_RX_POWERSAVE` is 0. > **History:** an earlier attempt used a *software* CAD state machine (scan → > warm-sleep window → on-detect full RX, with `standbyXOSC`/burst windows). It > fought the hardware — querying a warm-sleeping chip from `checkSend()` gave a > phantom-busy channel that stalled TX for ~4 s, and ACKs dropped in the scan - > gaps. Replaced wholesale by the hardware duty-cycle above, which fixed both. + > gaps. Replaced wholesale by the hardware duty-cycle above, which turned out to + > have its own, deeper problem (see above). **✅ Done — Adaptive Power Control ("Auto pwr")** - `tx_power_dbm` becomes a *ceiling*; APC drives the radio's actual power within diff --git a/docs/solo_features/settings_screen/settings_screen.md b/docs/solo_features/settings_screen/settings_screen.md index 5d1cae0b..8d3a0d91 100644 --- a/docs/solo_features/settings_screen/settings_screen.md +++ b/docs/solo_features/settings_screen/settings_screen.md @@ -71,8 +71,9 @@ Lists all available home screen pages. For each entry: | SF | 5–12 | LEFT/RIGHT. Spreading factor. | | BW | 7.8–500 kHz | LEFT/RIGHT cycles the standard LoRa bandwidths. | | CR | 5–8 | LEFT/RIGHT. Coding rate (4/5–4/8). | -| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive (SX126x only): cycles RX↔sleep, wakes on preamble, cuts average RX current at the cost of some latency. **Forced off (`--`) while the repeater is on** — restored once it's switched off. A background watchdog auto-recovers if the sequencer gets stuck (soft re-arm, then a full reset) — see Tools › Diagnostics for the counts. | | Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers TX power on strong links, ramps back to the **TX Pwr** ceiling on weak/lost ones. Link quality from DM ACK SNR, or — for channels (no ACK) — a repeater's rebroadcast. Live power shown on the radio page/name bar. Default OFF. **Suppressed (`--`) while the repeater is on** — restored once it's switched off. | + +There is no "Pwr save" row: hardware RX duty-cycle receive was tried and disabled (`FEAT_RX_POWERSAVE 0` in `examples/companion_radio/MyMesh.h`) — the SX126x's duty-cycle preamble detection needs the sender's actual preamble to exactly match what we configure, which a mixed-firmware mesh can't guarantee (silently drops every packet from a mismatched sender, no matter the signal strength). See `docs/development/roadmap.md` for the full writeup. | Scope | list | Shows the list's **default** scope. **Enter** opens the **SCOPE** list: `*` (wildcard = unscoped, always first, can't be renamed or deleted) plus up to 8 named scopes of your own (e.g. `pl`). Typing a name derives a shared key the same way on every device, so any device that types the same name lands on the same scope automatically, no key exchange needed. **Enter** on a row opens **Set as default** / **Rename** / **Delete** (delete confirms first); **+ Add scope** at the bottom opens the keyboard. The **default** scope (marked `[default]`) governs **DMs** and the **repeater's own relay slot**; each **channel** carries its own pick — set from the channel's context menu (see [Message Screen](../message_screen/message_screen.md)) — and a channel left on `*` sends unscoped. Scopes tag flood traffic so repeaters can tell your community's messages apart from others sharing the same frequency; paired with **Tools › Repeater › Scope only** it's also what this device relays for in repeater mode. The default also syncs both ways with the phone app's default-scope setting. Not encryption — message content is unaffected either way. Upgrading from a build with the old single Scope field carries it over as the default entry and seeds every existing channel with it, so nothing changes on the air. | | OLED | E-Ink | diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 83891d4e..a07bf4d6 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -498,9 +498,10 @@ A circular tab carousel of live device and mesh stats, refreshed once a second ( | Pool free | Free entries in the packet pool | | Queue | Packets waiting in the outbound queue | | Errors | Radio error flags since boot/reset — `OK`, or tokens `F` (queue full), `C` (CAD timeout), `R` (RX-start timeout) | -| RXPS wd s/h | RX duty-cycle watchdog recovery count, `soft/hard` — how many times the background watchdog has re-armed (soft) or fully reset (hard) a stuck duty-cycle sequencer. Stays `0/0` unless Settings › Radio › **Pwr save** is on and something actually went wrong. | -The packet counters, **Forwarded**, **Errors** and **RXPS wd s/h** are cumulative since boot. On the **Live** tab, **Hold Enter** opens a *Reset counters?* confirm (defaults to Cancel); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list. +The packet counters, **Forwarded** and **Errors**, are cumulative since boot. On the **Live** tab, **Hold Enter** opens a *Reset counters?* confirm (defaults to Cancel); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list. + +There is no "RXPS wd s/h" row: it belongs to hardware RX duty-cycle receive, which is currently disabled (see the Settings screen doc and `docs/development/roadmap.md`) — nothing to watchdog. The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools › Repeater** for the relaying options. @@ -551,7 +552,7 @@ The flood filters (**Skip advert** through **Scope only**) are **opt-in** (defau **Same network vs. separate network.** With **Network = Current** (or a Custom profile matching your companion settings) the repeater stays on your own network — you keep messaging while relaying. A *different* Custom profile moves the device entirely onto that network while relaying (one radio can't be on two at once), returning to your own network when switched off. The profile re-applies after a reboot if the repeater was left on. -While on, a **»** indicator appears in the status bar (same blink convention as auto-advert/trail markers). Two radio settings are overridden and restored afterwards: **Pwr save** forced off (must listen continuously) and **Auto pwr** forced off (full TX power for relay reach). Both show `--` in Settings while active. +While on, a **»** indicator appears in the status bar (same blink convention as auto-advert/trail markers). **Auto pwr** is overridden off and restored afterwards (full TX power for relay reach) — shows `--` in Settings while active. (Pwr save, hardware RX duty-cycle, is currently disabled outright — see above — so there's nothing left for repeater mode to override there.) Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown on **Tools › Diagnostics** (this screen is config-only). diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b321c47b..395f448d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -287,7 +287,14 @@ bool MyMesh::getCADEnabled() const { // _prefs.cad_enabled itself has no UI/CLI exposure yet on companion_radio // (unlike simple_repeater's CommonCLI `cad` command) — it's wired and // persisted for a future manual override, but always 0 today. + // rx_powersave is never actually applied to the radio while FEAT_RX_POWERSAVE + // is 0 (see MyMesh.h) -- don't let a stale persisted byte from before that + // still auto-enable CAD here for a duty-cycle mode that isn't running. +#if FEAT_RX_POWERSAVE return _prefs.cad_enabled || (_prefs.rx_powersave && !_prefs.client_repeat); +#else + return _prefs.cad_enabled; +#endif } int MyMesh::calcRxDelay(float score, uint32_t air_time) const { @@ -1991,7 +1998,11 @@ void MyMesh::begin(bool has_display) { applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set applyApc(); // sets TX power to the ceiling and arms APC if enabled radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); +#if FEAT_RX_POWERSAVE radio_driver.setPowerSaving(_prefs.rx_powersave && !_prefs.client_repeat); // duty-cycle RX off while repeating (must hear all traffic) +#else + radio_driver.setPowerSaving(false); // see MyMesh.h FEAT_RX_POWERSAVE -- ignore any stale persisted rx_powersave byte +#endif board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", @@ -2482,7 +2493,11 @@ void MyMesh::handleCmdFrame(size_t len) { // Keep the "repeating ⇒ continuous RX, full TX power" invariants when repeat // is toggled via the app, mirroring the on-device path (a repeater must hear // all traffic and relay at consistent power). +#if FEAT_RX_POWERSAVE radio_driver.setPowerSaving(_prefs.rx_powersave && !_prefs.client_repeat); +#else + radio_driver.setPowerSaving(false); // see MyMesh.h FEAT_RX_POWERSAVE -- ignore any stale persisted rx_powersave byte +#endif applyApc(); // pins power to the ceiling; apcActive() keeps it there while repeating MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index af20a432..050d75b1 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -11,6 +11,20 @@ class UITask; /*------------ Frame Protocol --------------*/ #define FIRMWARE_VER_CODE 13 +// Hardware RX duty-cycle ("Pwr save") is disabled: the SX126x's duty-cycle +// preamble detection needs the *actual transmitted* preamble to exactly match +// what we're configured to expect (confirmed hardware behaviour, see SX1262 +// datasheet 6.1.3 and https://github.com/jgromes/RadioLib/issues/1597) -- +// something we can't guarantee across a mesh with mixed firmware. A mismatch +// doesn't cost a little sensitivity, it silently drops every packet from that +// sender regardless of signal strength (2026-09-22 field report: near-total +// reception loss, unaffected by antenna). The real duty-cycle implementation +// (RadioLibWrapper::armRecv()/CustomSX1262Wrapper::startPowerSaveRecv()) is +// left in place for if a network-wide compatibility mechanism ever lands -- +// flipping this back to 1 requires solving that first, not just re-adding the +// Settings toggle. See docs/development/roadmap.md for the full writeup. +#define FEAT_RX_POWERSAVE 0 + // Fallback only -- every real build (local or CI) goes through build.sh, which // always injects its own FIRMWARE_BUILD_DATE (today's date at build time). // __DATE__ is the compiler's own "Mmm dd yyyy" build-date macro, so a diff --git a/examples/companion_radio/ui-new/DiagnosticsScreen.h b/examples/companion_radio/ui-new/DiagnosticsScreen.h index d35919b0..6e113b93 100644 --- a/examples/companion_radio/ui-new/DiagnosticsScreen.h +++ b/examples/companion_radio/ui-new/DiagnosticsScreen.h @@ -144,12 +144,14 @@ class DiagnosticsScreen : public UIScreen { } addRow("Errors", buf); +#if FEAT_RX_POWERSAVE // RX duty-cycle watchdog recovery counts since boot/reset (soft re-arm / // hard chip reset). Always 0/0 on radios or profiles that never arm // duty-cycle power-save (repeaters force it off — see applyPowerSave()). snprintf(buf, sizeof(buf), "%lu/%lu", (unsigned long)radio_driver.getRxPsWatchdogSoftCount(), (unsigned long)radio_driver.getRxPsWatchdogHardCount()); addRow("RXPS wd s/h", buf); +#endif } void buildSystemLines() { diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index ee282b31..9bb7bb14 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -61,7 +61,9 @@ class SettingsScreen : public UIScreen { TX_POWER, RADIO_PRESET, CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR, +#if FEAT_RX_POWERSAVE POWER_SAVE, +#endif TX_APC, SCOPE_NAME, // System section @@ -556,12 +558,14 @@ class SettingsScreen : public UIScreen { snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0); display.setCursor(valCol(display), y); display.print(buf); +#if FEAT_RX_POWERSAVE } else if (item == POWER_SAVE) { display.print("Pwr save"); display.setCursor(valCol(display), y); // Forced off (and locked) while the repeater is on — it must hear all traffic. if (p && p->client_repeat) display.print("--"); else display.print((p && p->rx_powersave) ? "ON" : "OFF"); +#endif } else if (item == TX_APC) { display.print("Auto pwr"); display.setCursor(valCol(display), y); @@ -1078,6 +1082,7 @@ public: if (_selected == CUSTOM_SF && p && dir && RadioParamsEditor::stepSF(p->sf, dir)) { _task->applyRadioParams(); _dirty = true; return true; } if (_selected == CUSTOM_BW && p && dir && RadioParamsEditor::stepBW(p->bw, dir)) { _task->applyRadioParams(); _dirty = true; return true; } if (_selected == CUSTOM_CR && p && dir && RadioParamsEditor::stepCR(p->cr, dir)) { _task->applyRadioParams(); _dirty = true; return true; } +#if FEAT_RX_POWERSAVE if (_selected == POWER_SAVE && p && (left || right || enter)) { if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; } p->rx_powersave ^= 1; @@ -1085,6 +1090,7 @@ public: _dirty = true; return true; } +#endif if (_selected == TX_APC && p && (left || right || enter)) { if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; } p->tx_apc ^= 1; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c1a720ef..12fd862e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3805,10 +3805,14 @@ void UITask::applyTxPower() { void UITask::applyPowerSave() { if (_node_prefs == NULL) return; +#if FEAT_RX_POWERSAVE // A repeater must hear every packet to relay it, so duty-cycle RX (which sleeps // between preamble checks) is forced off while repeating — the user's pref is // kept and restored when the repeater is switched off. radio_driver.setPowerSaving(_node_prefs->rx_powersave && !_node_prefs->client_repeat); +#else + radio_driver.setPowerSaving(false); // see MyMesh.h FEAT_RX_POWERSAVE -- ignore any stale persisted rx_powersave byte +#endif } void UITask::applyApc() { diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 82005ad6..13edda03 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -61,6 +61,21 @@ public: // minSymbols=8 is the reliable preamble-latch count for SF7-12. If the // configured preamble is too short for a real duty-cycle (senderPreamble < // 2*minSymbols+1), RadioLib transparently falls back to a continuous receive. + // + // NOT CURRENTLY REACHABLE: FEAT_RX_POWERSAVE (MyMesh.h) is 0, so nothing ever + // calls setPowerSaving(true) and this never runs. Left implemented (matching + // our own preambleLengthForSF(sf) convention, so it's correct for nodes on + // this same convention) rather than removed, because the reason it's disabled + // isn't a bug here: the SX126x's duty-cycle preamble detection needs the + // *actual transmitted* preamble to exactly match what we assume here, a + // confirmed hardware behaviour (SX1262 datasheet 6.1.3; RadioLib issue + // https://github.com/jgromes/RadioLib/issues/1597) that no local parameter + // choice can work around -- it requires a network-wide compatibility + // guarantee we can't currently make. A mismatch doesn't cost a little + // sensitivity, it silently drops every packet from that sender regardless of + // signal strength (2026-09-22 field report: near-total reception loss on a + // stock SF8 preset, unaffected by antenna gain). See MyMesh.h and + // docs/development/roadmap.md before ever flipping FEAT_RX_POWERSAVE back on. int16_t startPowerSaveRecv() override { return ((SX126x *)_radio)->startReceiveDutyCycleAuto(preambleLengthForSF(_preamble_sf), 8); } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 67aa2bbc..2302bec2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -46,6 +46,13 @@ protected: // stays in RX to receive the packet (RX_DONE on DIO1) — no MCU state machine, // average RX current cut several-fold. Driven from armRecv()/loop(); falls back // to continuous RX if the modem doesn't support it. + // Companion-side gating: FEAT_RX_POWERSAVE (examples/companion_radio/MyMesh.h) + // is 0, so nothing on that target ever calls setPowerSaving(true) and this + // path never actually runs there -- the SX126x duty-cycle's preamble + // detection needs the sender's actual preamble to exactly match what we + // configure (confirmed hardware behaviour, not a bug here), which can't be + // guaranteed across a mesh with mixed firmware. See that file before + // re-enabling it anywhere. bool _power_save = false; bool _ps_active = false; // is the radio currently armed in duty-cycle mode int8_t _tx_dbm = 0; // last TX power applied (tracks APC's live value)