mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
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:
@@ -323,14 +323,15 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
rd(&_prefs.units_imperial, sizeof(_prefs.units_imperial));
|
rd(&_prefs.units_imperial, sizeof(_prefs.units_imperial));
|
||||||
rd(&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
|
rd(&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
|
||||||
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
|
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
|
||||||
// These fields were appended after ch_fav_only; an older file can leave
|
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
|
||||||
// stray bytes here (the old tail sentinel gets partly consumed), so clamp
|
// These fields were appended over successive schema bumps; an older file
|
||||||
// out-of-range values back to defaults.
|
// 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.
|
// 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.notif_melody_ad > 3) _prefs.notif_melody_ad = 0;
|
||||||
if (_prefs.units_imperial > 1) _prefs.units_imperial = 0;
|
if (_prefs.units_imperial > 1) _prefs.units_imperial = 0;
|
||||||
if (_prefs.trail_show_pace > 1) _prefs.trail_show_pace = 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.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
|
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
||||||
// (or a different schema); rd() already zero-inits any fields not present,
|
// (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.units_imperial, sizeof(_prefs.units_imperial));
|
||||||
file.write((uint8_t *)&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
|
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.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.
|
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
|
||||||
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
|
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
|
||||||
|
|||||||
@@ -1203,6 +1203,7 @@ void MyMesh::begin(bool has_display) {
|
|||||||
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||||
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
|
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",
|
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
|
||||||
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
|
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
// 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.
|
// The km-vs-mi choice now comes from units_imperial, so this is just the mode.
|
||||||
uint8_t trail_show_pace;
|
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
|
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
|
||||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class SettingsScreen : public UIScreen {
|
|||||||
// Radio section
|
// Radio section
|
||||||
SECTION_RADIO,
|
SECTION_RADIO,
|
||||||
TX_POWER,
|
TX_POWER,
|
||||||
|
POWER_SAVE,
|
||||||
// System section
|
// System section
|
||||||
SECTION_SYSTEM,
|
SECTION_SYSTEM,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
@@ -479,6 +480,10 @@ class SettingsScreen : public UIScreen {
|
|||||||
snprintf(buf, sizeof(buf),"%ddBm", p ? p->tx_power_dbm : 0);
|
snprintf(buf, sizeof(buf),"%ddBm", p ? p->tx_power_dbm : 0);
|
||||||
display.setCursor(display.valCol(), y);
|
display.setCursor(display.valCol(), y);
|
||||||
display.print(buf);
|
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
|
#if AUTO_OFF_MILLIS > 0
|
||||||
} else if (item == AUTO_OFF) {
|
} else if (item == AUTO_OFF) {
|
||||||
display.print("AutoOff");
|
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 (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 (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 AUTO_OFF_MILLIS > 0
|
||||||
if (_selected == AUTO_OFF && p) {
|
if (_selected == AUTO_OFF && p) {
|
||||||
int idx = autoOffIndex();
|
int idx = autoOffIndex();
|
||||||
|
|||||||
@@ -2066,6 +2066,11 @@ void UITask::applyTxPower() {
|
|||||||
radio_driver.setTxPower(_node_prefs->tx_power_dbm);
|
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() {
|
void UITask::applyBrightness() {
|
||||||
if (_display != NULL && _node_prefs != NULL) {
|
if (_display != NULL && _node_prefs != NULL) {
|
||||||
_display->setBrightness(_node_prefs->display_brightness);
|
_display->setBrightness(_node_prefs->display_brightness);
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ public:
|
|||||||
void setBuzzerVolumeLevel(uint8_t level);
|
void setBuzzerVolumeLevel(uint8_t level);
|
||||||
uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; }
|
uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; }
|
||||||
void applyTxPower();
|
void applyTxPower();
|
||||||
|
void applyPowerSave(); // CAD-windowed RX on/off from prefs
|
||||||
void applyFont();
|
void applyFont();
|
||||||
void applyRotation();
|
void applyRotation();
|
||||||
void applyFullRefreshInterval();
|
void applyFullRefreshInterval();
|
||||||
|
|||||||
@@ -11,6 +11,22 @@
|
|||||||
#define NUM_NOISE_FLOOR_SAMPLES 64
|
#define NUM_NOISE_FLOOR_SAMPLES 64
|
||||||
#define SAMPLING_THRESHOLD 14
|
#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;
|
static volatile uint8_t state = STATE_IDLE;
|
||||||
|
|
||||||
// this function is called when a complete packet
|
// this function is called when a complete packet
|
||||||
@@ -26,6 +42,8 @@ void setFlag(void) {
|
|||||||
|
|
||||||
void RadioLibWrapper::begin() {
|
void RadioLibWrapper::begin() {
|
||||||
_radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt
|
_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();
|
_preamble_sf = getSpreadingFactor();
|
||||||
_radio->setPreambleLength(preambleLengthForSF(_preamble_sf)); // longer preamble for lower SF improves reliability
|
_radio->setPreambleLength(preambleLengthForSF(_preamble_sf)); // longer preamble for lower SF improves reliability
|
||||||
state = STATE_IDLE;
|
state = STATE_IDLE;
|
||||||
@@ -84,6 +102,20 @@ void RadioLibWrapper::resetAGC() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RadioLibWrapper::loop() {
|
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 (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) {
|
||||||
if (!isReceivingPacket()) {
|
if (!isReceivingPacket()) {
|
||||||
int rssi = getCurrentRSSI();
|
int rssi = getCurrentRSSI();
|
||||||
@@ -104,6 +136,20 @@ void RadioLibWrapper::loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RadioLibWrapper::startRecv() {
|
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();
|
int err = _radio->startReceive();
|
||||||
if (err == RADIOLIB_ERR_NONE) {
|
if (err == RADIOLIB_ERR_NONE) {
|
||||||
state = STATE_RX;
|
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 {
|
bool RadioLibWrapper::isInRecvMode() const {
|
||||||
return (state & ~STATE_INT_READY) == STATE_RX;
|
return (state & ~STATE_INT_READY) == STATE_RX;
|
||||||
}
|
}
|
||||||
@@ -136,12 +233,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state != STATE_RX) {
|
if (state != STATE_RX) {
|
||||||
int err = _radio->startReceive();
|
armRecv(); // continuous RX, or re-arm the CAD window in power-save mode
|
||||||
if (err == RADIOLIB_ERR_NONE) {
|
|
||||||
state = STATE_RX;
|
|
||||||
} else {
|
|
||||||
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return len;
|
return len;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,25 @@ protected:
|
|||||||
virtual bool isReceivingPacket() =0;
|
virtual bool isReceivingPacket() =0;
|
||||||
virtual void doResetAGC();
|
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:
|
public:
|
||||||
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; }
|
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; }
|
||||||
|
|
||||||
void begin() override;
|
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(); }
|
virtual void powerOff() { _radio->sleep(); }
|
||||||
int recvRaw(uint8_t* bytes, int sz) override;
|
int recvRaw(uint8_t* bytes, int sz) override;
|
||||||
uint32_t getEstAirtimeFor(int len_bytes) override;
|
uint32_t getEstAirtimeFor(int len_bytes) override;
|
||||||
|
|||||||
Reference in New Issue
Block a user