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-10 23:30:43 +02:00
parent baaac3179c
commit 5b58049139
13 changed files with 346 additions and 42 deletions

View File

@@ -323,14 +323,17 @@ 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));
rd(&_prefs.tx_apc, sizeof(_prefs.tx_apc));
// 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;
if (_prefs.tx_apc > 1) _prefs.tx_apc = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
@@ -358,6 +361,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
// tail (notif_melody_ad + units_imperial + trail_show_pace). Older files
// leave stray/old bytes in these fields; they're clamped above, so
// upgraders fall back to built-in advert sound + metric + speed + All.
// → 0xC0DE0009: append tx_apc after rx_powersave. Clamped above, so
// upgraders fall back to APC off (fixed tx power).
}
file.close();
@@ -450,6 +455,8 @@ 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));
file.write((uint8_t *)&_prefs.tx_apc, sizeof(_prefs.tx_apc));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -487,6 +487,18 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
}
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
// APC: a channel/flood packet we originated, heard back from a repeater, is
// positive feedback on the repeater link — sample its SNR. This is the only
// confirmation a channel send gets (no ACK), and is what lets APC recover power
// after it has trimmed too far for the repeaters to hear.
if (_prefs.tx_apc && _apc_flood_pending && packet->payload_len == _apc_flood_len) {
uint8_t h[MAX_HASH_SIZE];
packet->calculatePacketHash(h);
if (memcmp(h, _apc_flood_hash, MAX_HASH_SIZE) == 0) {
_apc_flood_pending = false;
apcSampleSnr(packet->getSNR());
}
}
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
return false;
@@ -521,6 +533,7 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
// TODO: have per-channel send_scope
if (_prefs.tx_apc) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
if (send_unscoped) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
} else {
@@ -1056,7 +1069,101 @@ uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t
(path_hash_count + 1));
}
void MyMesh::onSendTimeout() {}
// Adaptive Power Control. tx_power_dbm is the user-set ceiling; APC drives the
// radio's *actual* TX power within [APC_MIN_DBM, ceiling] to hold the link margin
// near a target. The link-quality signal is the SNR of a returning confirmation —
// either a direct-message ACK or our own channel/flood packet heard rebroadcast by
// a repeater (both are the *reverse* link, a good proxy on a roughly symmetric RF
// neighbourhood). No protocol change required.
//
// The margin is measured *above this SF's demodulation floor* (not an absolute
// SNR), so the same target works across SF7SF12. A single SNR sample is noisy, so
// we smooth it with an EWMA and step proportionally to the error (capped), with a
// deadband to avoid hunting. A lost confirmation is an ambiguous signal for power,
// so we step up gradually and only jump to the ceiling after a short streak.
#define APC_MIN_DBM -9 // lower bound (SX126x PA floor)
#define APC_TARGET_MARGIN_DB 6.0f // desired SNR margin above the SF demod floor
#define APC_DEADBAND_DB 2.0f // hold while the smoothed margin is within ±this
#define APC_EWMA_ALPHA 0.4f // smoothing weight for each SNR sample
#define APC_MAX_STEP_DB 2 // cap on a single power adjustment (stability)
#define APC_FAIL_STEP_DB 4 // power bump on one lost confirmation
#define APC_FAIL_CEIL_HITS 2 // consecutive losses → jump straight to the ceiling
// How long to wait for a repeater to rebroadcast our channel/flood packet before
// treating it as un-heard (flood retransmit delays are randomised over a few s).
#define APC_FLOOD_ECHO_WINDOW_MS 6000
void MyMesh::applyApc() {
_apc_cur_dbm = _prefs.tx_power_dbm; // start at the ceiling
_apc_margin_ewma = APC_TARGET_MARGIN_DB; // assume on-target until samples say otherwise
_apc_fail_count = 0;
_apc_flood_pending = false;
radio_driver.setTxPower(_apc_cur_dbm);
}
// Feed one reverse-link SNR sample (ACK or heard flood echo) into the controller.
void MyMesh::apcSampleSnr(float snr) {
_apc_fail_count = 0; // a confirmation clears the failure streak
float margin = snr - radio_driver.snrFloorForSF(_prefs.sf);
_apc_margin_ewma += APC_EWMA_ALPHA * (margin - _apc_margin_ewma); // smooth
float err = _apc_margin_ewma - APC_TARGET_MARGIN_DB; // +ve = more margin than needed
if (fabsf(err) <= APC_DEADBAND_DB) return; // inside deadband → hold
int step = (int)lroundf(err * 0.5f); // proportional (~half the error)
if (step > APC_MAX_STEP_DB) step = APC_MAX_STEP_DB;
if (step < -APC_MAX_STEP_DB) step = -APC_MAX_STEP_DB;
if (step == 0) return;
int8_t target = _apc_cur_dbm - step; // surplus margin → lower power
if (target < APC_MIN_DBM) target = APC_MIN_DBM;
if (target > _prefs.tx_power_dbm) target = _prefs.tx_power_dbm;
if (target == _apc_cur_dbm) return;
int8_t delta = target - _apc_cur_dbm;
_apc_cur_dbm = target;
radio_driver.setTxPower(_apc_cur_dbm);
_apc_margin_ewma += (float)delta; // anticipate the margin shift (≈1 dB TX → 1 dB SNR)
}
// A send got no confirmation (missed ACK, or a flood no repeater echoed). Ramp up.
void MyMesh::apcOnFailure() {
if (_apc_cur_dbm >= _prefs.tx_power_dbm) return; // already at the ceiling
int8_t target;
if (++_apc_fail_count >= APC_FAIL_CEIL_HITS) {
target = _prefs.tx_power_dbm; // repeated losses → restore full reliability
} else {
target = _apc_cur_dbm + APC_FAIL_STEP_DB;
if (target > _prefs.tx_power_dbm) target = _prefs.tx_power_dbm;
}
_apc_cur_dbm = target;
_apc_margin_ewma = APC_TARGET_MARGIN_DB; // reset so the next sample doesn't trim straight back
radio_driver.setTxPower(_apc_cur_dbm);
}
// Remember a channel/flood packet we just originated so a heard rebroadcast can be
// matched as positive feedback (and its absence as a failure). The hash excludes
// the mutable path, so it matches across repeater rebroadcasts.
void MyMesh::apcTrackFloodSend(const mesh::Packet* pkt) {
pkt->calculatePacketHash(_apc_flood_hash);
_apc_flood_len = pkt->payload_len;
_apc_flood_deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
_apc_flood_pending = true;
}
void MyMesh::onSendTimeout() {
if (_prefs.tx_apc) apcOnFailure();
}
void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
// onAckRecv also fires for ACKs we merely route or overhear (see Mesh.cpp), whose
// SNR belongs to an unrelated link — only feed APC for ACKs to our own sends.
// Capture the match before the base handler's processAck() clears the entry.
bool ours = _prefs.tx_apc && isAckPending(ack_crc);
BaseChatMesh::onAckRecv(packet, ack_crc);
if (ours) apcSampleSnr(radio_driver.getLastSNR());
}
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
@@ -1201,8 +1308,9 @@ void MyMesh::begin(bool has_display) {
_store->loadChannels(this);
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.setTxPower(_prefs.tx_power_dbm);
applyApc(); // sets TX power to the ceiling and arms APC if enabled
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
radio_driver.setPowerSaving(_prefs.rx_powersave); // hardware duty-cycle RX (battery saver)
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
}
@@ -2536,6 +2644,13 @@ void MyMesh::checkSerialInterface() {
void MyMesh::loop() {
BaseChatMesh::loop();
// APC: a tracked channel/flood send that no repeater echoed within the window is
// treated as a lost confirmation → ramp power up (lets channel sends recover).
if (_apc_flood_pending && millisHasNowPassed(_apc_flood_deadline)) {
_apc_flood_pending = false;
if (_prefs.tx_apc) apcOnFailure();
}
if (_cli_rescue) {
checkCLIRescueCmd();
} else {

View File

@@ -194,6 +194,13 @@ protected:
uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override;
uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override;
void onSendTimeout() override;
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; // APC: ACK SNR sample
// APC internals (see MyMesh.cpp): one reverse-link SNR sample, a lost-confirmation
// ramp-up, and tracking an originated flood so its echo (or absence) can be scored.
// The heard-echo sampling itself lives in filterRecvFloodPacket() (declared below).
void apcSampleSnr(float snr);
void apcOnFailure();
void apcTrackFloodSend(const mesh::Packet* pkt);
// DataStoreHost methods
bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); }
@@ -209,6 +216,7 @@ public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveRTCTime() { _store->saveRTCTime(); }
DataStore* getDataStore() const { return _store; }
void applyApc(); // (re)initialise Adaptive Power Control from prefs
bool isAckPending(uint32_t expected_ack) const {
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
@@ -276,6 +284,13 @@ private:
uint32_t _active_ble_pin;
bool _iter_started;
bool _cli_rescue;
int8_t _apc_cur_dbm; // APC current TX power (≤ tx_power_dbm ceiling) when tx_apc on
float _apc_margin_ewma; // APC smoothed reverse-link SNR margin above the SF demod floor
uint8_t _apc_fail_count; // APC consecutive lost-confirmation count (graduated ramp-up)
uint8_t _apc_flood_hash[MAX_HASH_SIZE]; // hash of the channel/flood send awaiting a repeater echo
uint16_t _apc_flood_len; // its payload length — cheap pre-filter before hashing
uint32_t _apc_flood_deadline; // echo-wait deadline for that send
bool _apc_flood_pending; // a tracked flood send is awaiting its echo
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
uint8_t app_target_ver;

View File

@@ -120,12 +120,22 @@ 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;
// Hardware duty-cycle receive (battery saver): 0=continuous RX (default), 1=on.
// The SX126x cycles RX↔sleep on its own and wakes on a preamble — cuts average
// RX current at the cost of a little receive latency. See RadioLibWrapper
// power-save (startReceiveDutyCycleAuto).
uint8_t rx_powersave;
// Adaptive Power Control: 0=off (fixed tx_power_dbm, default), 1=on. When on,
// tx_power_dbm is treated as a ceiling and the radio's actual power is lowered
// at runtime on strong links (good ACK SNR), saving TX energy. Never persisted
// below the ceiling, so disabling restores the user's configured power.
uint8_t tx_apc;
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0008;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0009;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage

View File

@@ -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();

View File

@@ -555,7 +555,15 @@ public:
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts());
display.setColor(DisplayDriver::LIGHT);
display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name);
if (_node_prefs && _node_prefs->tx_apc) {
char pwr_buf[8];
snprintf(pwr_buf, sizeof(pwr_buf), "%ddB", (int)radio_driver.getTxPower());
int pwr_w = display.getTextWidth(pwr_buf);
display.drawTextEllipsized(0, 0, rightEdge - 2 - pwr_w - 2, filtered_name);
display.drawTextRightAlign(rightEdge - 2, 0, pwr_buf);
} else {
display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name);
}
}
// ensure current page is visible (e.g. after settings change)
@@ -729,10 +737,14 @@ public:
// tx power, noise floor
display.setCursor(0, content_y + step * 2);
snprintf(tmp, sizeof(tmp),"TX: %ddBm", _node_prefs->tx_power_dbm);
snprintf(tmp, sizeof(tmp),"TX: %ddBm", radio_driver.getTxPower()); // live value (reflects APC)
display.print(tmp);
display.setCursor(0, content_y + step * 3);
snprintf(tmp, sizeof(tmp),"Noise floor: %d", radio_driver.getNoiseFloor());
if (radio_driver.getPowerSaving()) { // duty-cycle RX doesn't sample the floor
snprintf(tmp, sizeof(tmp),"Noise floor: n/a");
} else {
snprintf(tmp, sizeof(tmp),"Noise floor: %d", radio_driver.getNoiseFloor());
}
display.print(tmp);
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::LIGHT);
@@ -2063,9 +2075,21 @@ void UITask::toggleGPS() {
void UITask::applyTxPower() {
if (_node_prefs == NULL) return;
// With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it
// (which also sets the radio) so the live power tracks the new ceiling at once.
if (_node_prefs->tx_apc) { the_mesh.applyApc(); return; }
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::applyApc() {
the_mesh.applyApc(); // (re)initialise Adaptive Power Control from prefs
}
void UITask::applyBrightness() {
if (_display != NULL && _node_prefs != NULL) {
_display->setBrightness(_node_prefs->display_brightness);

View File

@@ -249,6 +249,8 @@ public:
void setBuzzerVolumeLevel(uint8_t level);
uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; }
void applyTxPower();
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
void applyApc(); // Adaptive Power Control on/off from prefs
void applyFont();
void applyRotation();
void applyFullRefreshInterval();