fix(radio): disable Pwr save (RX duty-cycle) — unsafe across mixed firmware

Field report: a user on the stock SF8 "EU/UK (Narrow)" preset 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, closed as inherent chip behaviour):
the SX126x's duty-cycle preamble detection needs the sender's actual
preamble to closely match what we've configured our receiver to expect.
A mismatch — e.g. a repeater still on pre-v1.16 firmware sending 16 symbols
against our 32 — doesn't cost a little sensitivity, it silently drops every
packet from that sender regardless of signal strength. There's no local
parameter fix: shortening minSymbols to tolerate shorter preambles directly
shortens the wake-window's correlator dwell time, trading the preamble
mismatch failure mode for a marginal-signal one instead. Checked whether
IoTThinks' MeshCore fork solved this differently — it doesn't hit the
problem at all, because its "power saving" only light-sleeps the MCU on the
radio's own GPIO interrupt and never touches RX duty-cycle.

examples/companion_radio/MyMesh.h now defines FEAT_RX_POWERSAVE 0, gating
out the Settings row, the Diagnostics RXPS watchdog row, and every call site
that would apply rx_powersave to the radio or CAD auto-enable — including
forcing setPowerSaving(false) unconditionally so a stale rx_powersave=1 byte
from before this change can't do anything either. The real duty-cycle
implementation in RadioLibWrapper/CustomSX1262Wrapper is left in place,
unneutered, for if a network-wide compatibility mechanism (e.g. the
still-unused ADV_FEAT1/FEAT2 advert bits) is ever built to make it safe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-22 20:04:42 +02:00
co-authored by Claude Sonnet 5
parent f8cd24dd4d
commit 26e6bc877a
10 changed files with 119 additions and 14 deletions
+15
View File
@@ -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);
+14
View File
@@ -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
@@ -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() {
@@ -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;
@@ -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() {