diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index ae1531df..183d7f3e 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0a763641..6787a0f1 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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"); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 0586d5fe..0684fea5 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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 diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index a3f99acc..a6feed07 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -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(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c1fa7cab..95dc6892 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index f83ebbac..c33fb491 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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(); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b6519aef..45b266ef 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -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; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index efd3e179..ea98f627 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -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;