feat(power): battery saving — hardware duty-cycle RX + adaptive TX power

Two independent, default-off toggles under Settings › Radio.

Pwr save: hardware RX duty-cycle (SX126x SetRxDutyCycle via
startReceiveDutyCycleAuto). The chip cycles RX↔sleep and wakes on a preamble —
no MCU state machine; recvRaw reads the packet exactly as in continuous RX.
Falls back to continuous RX on non-SX126x. (Replaces an earlier software-CAD
state machine that fought the hardware: polling a warm-sleeping chip gave a
phantom-busy channel that stalled TX ~4 s and dropped ACKs in the scan gaps.)

Auto pwr: Adaptive Power Control. tx_power_dbm becomes a ceiling; actual TX
power tracks the reverse-link SNR margin (measured above the per-SF demod floor,
EWMA-smoothed, proportional step with a deadband). Feedback comes from direct /
room-server ACKs and, for channels (no ACK), from hearing a repeater rebroadcast
our own flood; a lost confirmation ramps power back up so channel sends can't get
stranded below what the repeaters can hear.

Prefs schema 0xC0DE0009 (rx_powersave, tx_apc). Radio page / name bar show the
live TX power; noise floor reads n/a while duty-cycling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-11 08:15:42 +02:00
co-authored by Claude Opus 4.8
parent baaac3179c
commit 5b58049139
13 changed files with 346 additions and 42 deletions
@@ -54,6 +54,8 @@ class SettingsScreen : public UIScreen {
// Radio section
SECTION_RADIO,
TX_POWER,
POWER_SAVE,
TX_APC,
// System section
SECTION_SYSTEM,
TIMEZONE,
@@ -479,6 +481,14 @@ class SettingsScreen : public UIScreen {
snprintf(buf, sizeof(buf),"%ddBm", p ? p->tx_power_dbm : 0);
display.setCursor(display.valCol(), y);
display.print(buf);
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(display.valCol(), y);
display.print((p && p->rx_powersave) ? "ON" : "OFF");
} else if (item == TX_APC) {
display.print("Auto pwr");
display.setCursor(display.valCol(), y);
display.print((p && p->tx_apc) ? "ON" : "OFF");
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("AutoOff");
@@ -736,6 +746,18 @@ public:
if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
}
if (_selected == POWER_SAVE && p && (left || right || enter)) {
p->rx_powersave ^= 1;
_task->applyPowerSave();
_dirty = true;
return true;
}
if (_selected == TX_APC && p && (left || right || enter)) {
p->tx_apc ^= 1;
_task->applyApc();
_dirty = true;
return true;
}
#if AUTO_OFF_MILLIS > 0
if (_selected == AUTO_OFF && p) {
int idx = autoOffIndex();