feat(power): CAD-windowed receive (battery saver) — phase 1

Add an optional low-power RX mode: instead of listening continuously, the
radio periodically runs a Channel Activity Detection scan and sits in standby
between scans; on detected activity it drops into a full receive to catch the
packet. Cuts average RX current several-fold (the dominant draw on this node).

- RadioLibWrapper: CAD state machine (scan → sleep window → rx) driven from
  loop(), which runs before recvRaw each cycle. `state` stays STATE_RX through
  all phases so the dispatcher's not-in-RX watchdog never trips. Re-arm routes
  through armRecv() in both startRecv() and recvRaw(); toggling on/off switches
  mode safely at the next idle. Falls back to continuous RX if CAD is
  unsupported. setPowerSaving()/getPowerSaving() control it.
- Software CSMA is already disabled on this firmware (interference threshold 0),
  so duty-cycling doesn't affect listen-before-talk; airtime budget still gates
  TX, and isReceivingPacket() still defers TX while a packet is mid-air.
- Companion: new rx_powersave pref (schema 0xC0DE0008), Settings › Radio ›
  "Pwr save" toggle, applied at boot (MyMesh) and on change (UITask).

CAD window = 45 ms (< the 16-symbol preamble at SF8/BW62.5) and CAD params use
RadioLib defaults — both will want on-hardware tuning + a before/after current
measurement to confirm reception is intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-04 18:54:52 +02:00
parent baaac3179c
commit 90ca21432c
8 changed files with 141 additions and 9 deletions

View File

@@ -323,14 +323,15 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.units_imperial, sizeof(_prefs.units_imperial));
rd(&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
// These fields were appended after ch_fav_only; an older file can leave
// stray bytes here (the old tail sentinel gets partly consumed), so clamp
// out-of-range values back to defaults.
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
// These fields were appended over successive schema bumps; an older file
// can leave stray bytes here, so clamp out-of-range values back to defaults.
// Values for notif_melody_ad: 0=built-in, 1=melody1, 2=melody2, 3=none.
if (_prefs.notif_melody_ad > 3) _prefs.notif_melody_ad = 0;
if (_prefs.units_imperial > 1) _prefs.units_imperial = 0;
if (_prefs.trail_show_pace > 1) _prefs.trail_show_pace = 0;
if (_prefs.advert_sound_scope > 1) _prefs.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
if (_prefs.rx_powersave > 1) _prefs.rx_powersave = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
@@ -450,6 +451,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.units_imperial, sizeof(_prefs.units_imperial));
file.write((uint8_t *)&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
file.write((uint8_t *)&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
file.write((uint8_t *)&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -1203,6 +1203,7 @@ void MyMesh::begin(bool has_display) {
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.setTxPower(_prefs.tx_power_dbm);
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
radio_driver.setPowerSaving(_prefs.rx_powersave); // CAD-windowed RX (battery saver)
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
}

View File

@@ -120,6 +120,11 @@ struct NodePrefs { // persisted to file
// Trail Summary readout: 0=speed (km/h or mph), 1=pace (min/km or min/mi).
// The km-vs-mi choice now comes from units_imperial, so this is just the mode.
uint8_t trail_show_pace;
// CAD-windowed receive (battery saver): 0=continuous RX (default), 1=on.
// Periodically scans for activity and idles between scans instead of
// listening continuously — cuts average RX current at the cost of a little
// receive latency. See RadioLibWrapper power-save.
uint8_t rx_powersave;
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so

View File

@@ -54,6 +54,7 @@ class SettingsScreen : public UIScreen {
// Radio section
SECTION_RADIO,
TX_POWER,
POWER_SAVE,
// System section
SECTION_SYSTEM,
TIMEZONE,
@@ -479,6 +480,10 @@ 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");
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("AutoOff");
@@ -736,6 +741,12 @@ 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 AUTO_OFF_MILLIS > 0
if (_selected == AUTO_OFF && p) {
int idx = autoOffIndex();

View File

@@ -2066,6 +2066,11 @@ void UITask::applyTxPower() {
radio_driver.setTxPower(_node_prefs->tx_power_dbm);
}
void UITask::applyPowerSave() {
if (_node_prefs == NULL) return;
radio_driver.setPowerSaving(_node_prefs->rx_powersave);
}
void UITask::applyBrightness() {
if (_display != NULL && _node_prefs != NULL) {
_display->setBrightness(_node_prefs->display_brightness);

View File

@@ -249,6 +249,7 @@ public:
void setBuzzerVolumeLevel(uint8_t level);
uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; }
void applyTxPower();
void applyPowerSave(); // CAD-windowed RX on/off from prefs
void applyFont();
void applyRotation();
void applyFullRefreshInterval();

View File

@@ -11,6 +11,22 @@
#define NUM_NOISE_FLOOR_SAMPLES 64
#define SAMPLING_THRESHOLD 14
// Power-save CAD-windowing sub-states (tracked in _ps_phase; `state` stays
// STATE_RX throughout so isInRecvMode() reports "receiving" and the dispatcher
// never trips its 8 s not-in-RX watchdog while we sit in standby between scans).
#define PS_OFF 0
#define PS_SCANNING 1 // CAD armed, waiting for the CAD-done interrupt
#define PS_SLEEPING 2 // standby between scans, waiting for _ps_timer
#define PS_RXING 3 // CAD hit → full receive armed, waiting for the packet
// Time between CAD scans. Must be shorter than the on-air preamble so a scan
// always lands inside it (16-symbol preamble ≈ 66 ms at SF8/BW62.5). Tuned for
// this firmware's fixed LoRa params — revisit if SF/BW change.
#define PS_CAD_INTERVAL_MS 45
// If CAD detects activity but no packet actually lands, fall back to scanning
// instead of sitting in continuous RX forever.
#define PS_RX_TIMEOUT_MS 1500
static volatile uint8_t state = STATE_IDLE;
// this function is called when a complete packet
@@ -26,6 +42,8 @@ void setFlag(void) {
void RadioLibWrapper::begin() {
_radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt
_radio->setChannelScanAction(setFlag); // CAD-done shares the same DIO1 flag
_ps_phase = PS_OFF;
_preamble_sf = getSpreadingFactor();
_radio->setPreambleLength(preambleLengthForSF(_preamble_sf)); // longer preamble for lower SF improves reliability
state = STATE_IDLE;
@@ -84,6 +102,20 @@ void RadioLibWrapper::resetAGC() {
}
void RadioLibWrapper::loop() {
if (_power_save) {
// Just enabled while in continuous RX: drop into the CAD window once idle
// (don't interrupt an in-flight packet or an unread RX-done).
if (_ps_phase == PS_OFF) {
if (!(state & STATE_INT_READY) && !isReceivingPacket()) armRecv();
return;
}
powerSaveLoop(); // CAD windowing replaces continuous-RX noise sampling
return;
}
// Just disabled while in a CAD phase: return to continuous RX immediately
// (otherwise the radio would stay parked in standby between scans).
if (_ps_phase != PS_OFF) { armRecv(); return; }
if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) {
if (!isReceivingPacket()) {
int rssi = getCurrentRSSI();
@@ -104,6 +136,20 @@ void RadioLibWrapper::loop() {
}
void RadioLibWrapper::startRecv() {
armRecv();
}
// Arm the receiver. In power-save mode this starts a CAD scan (and the CAD
// state machine in powerSaveLoop() takes over); otherwise a continuous RX.
// If CAD isn't supported by the module, permanently fall back to continuous RX.
void RadioLibWrapper::armRecv() {
if (_power_save) {
int16_t e = _radio->startChannelScan();
if (e == RADIOLIB_ERR_NONE) { _ps_phase = PS_SCANNING; state = STATE_RX; return; }
MESH_DEBUG_PRINTLN("RadioLibWrapper: CAD unsupported (%d) — power-save off", (int)e);
_power_save = false;
}
_ps_phase = PS_OFF;
int err = _radio->startReceive();
if (err == RADIOLIB_ERR_NONE) {
state = STATE_RX;
@@ -112,6 +158,57 @@ void RadioLibWrapper::startRecv() {
}
}
void RadioLibWrapper::enterCadSleep() {
_radio->standby(); // low-power between scans (TODO: warm sleep for more savings)
_ps_phase = PS_SLEEPING;
_ps_timer = millis() + PS_CAD_INTERVAL_MS;
state = STATE_RX; // keep isInRecvMode() true so the dispatcher doesn't flag a stuck radio
}
// CAD-windowed receive state machine. Runs each loop() (before recvRaw):
// SLEEPING : wait out the inter-scan window, then re-arm a CAD scan.
// SCANNING : on CAD-done, either drop into full RX (activity) or sleep (free).
// RXING : a real RX-done is consumed by recvRaw; here we only guard against
// a false CAD that never yields a packet.
void RadioLibWrapper::powerSaveLoop() {
if (state == STATE_TX_WAIT) return; // never touch the radio mid-transmit
switch (_ps_phase) {
case PS_SLEEPING:
if ((int32_t)(millis() - _ps_timer) >= 0) armRecv(); // → PS_SCANNING
break;
case PS_SCANNING:
if (state & STATE_INT_READY) {
state &= ~STATE_INT_READY; // consume CAD-done
int16_t r = _radio->getChannelScanResult();
if (r == RADIOLIB_LORA_DETECTED || r == RADIOLIB_PREAMBLE_DETECTED) {
if (_radio->startReceive() == RADIOLIB_ERR_NONE) {
_ps_phase = PS_RXING; state = STATE_RX;
_ps_timer = millis() + PS_RX_TIMEOUT_MS;
} else {
enterCadSleep();
}
} else {
enterCadSleep(); // channel free → sleep window
}
}
break;
case PS_RXING:
// The packet (RX-done → STATE_INT_READY) is read by recvRaw, which then
// re-arms via armRecv(). If nothing arrives, the CAD was a false hit:
// return to scanning rather than burning current in continuous RX.
if (!(state & STATE_INT_READY) && (int32_t)(millis() - _ps_timer) >= 0) {
enterCadSleep();
}
break;
default:
break;
}
}
bool RadioLibWrapper::isInRecvMode() const {
return (state & ~STATE_INT_READY) == STATE_RX;
}
@@ -136,12 +233,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
}
if (state != STATE_RX) {
int err = _radio->startReceive();
if (err == RADIOLIB_ERR_NONE) {
state = STATE_RX;
} else {
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err);
}
armRecv(); // continuous RX, or re-arm the CAD window in power-save mode
}
return len;
}

View File

@@ -19,10 +19,25 @@ protected:
virtual bool isReceivingPacket() =0;
virtual void doResetAGC();
// Power-save (CAD-windowed RX): instead of continuous receive, periodically
// run a Channel Activity Detection scan and sit in standby between scans; on
// detected activity switch to a full receive to catch the packet. Cuts the
// average RX current several-fold. Driven from loop() (which runs before
// recvRaw each cycle); falls back to continuous RX if CAD is unsupported.
bool _power_save = false;
uint8_t _ps_phase = 0; // PS_* (see .cpp): scanning / sleeping / rxing
uint32_t _ps_timer = 0; // next-scan time, or RX false-detect deadline
void armRecv(); // arm RX honouring power-save (CAD) or continuous
void powerSaveLoop();
void enterCadSleep();
public:
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; }
void begin() override;
// Enable/disable CAD-windowed receive. Takes effect on the next RX re-arm.
void setPowerSaving(bool en) { _power_save = en; }
bool getPowerSaving() const { return _power_save; }
virtual void powerOff() { _radio->sleep(); }
int recvRaw(uint8_t* bytes, int sz) override;
uint32_t getEstAirtimeFor(int len_bytes) override;