From 5b58049139fabef313e16082d1c555a7bd46163a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:30:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(power):=20battery=20saving=20=E2=80=94=20h?= =?UTF-8?q?ardware=20duty-cycle=20RX=20+=20adaptive=20TX=20power?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FEATURES.md | 75 +++++++++-- README.md | 4 + .../settings_screen/settings_screen.md | 8 +- examples/companion_radio/DataStore.cpp | 13 +- examples/companion_radio/MyMesh.cpp | 119 +++++++++++++++++- examples/companion_radio/MyMesh.h | 15 +++ examples/companion_radio/NodePrefs.h | 12 +- .../companion_radio/ui-new/SettingsScreen.h | 22 ++++ examples/companion_radio/ui-new/UITask.cpp | 30 ++++- examples/companion_radio/ui-new/UITask.h | 2 + src/helpers/radiolib/CustomSX1262Wrapper.h | 11 ++ src/helpers/radiolib/RadioLibWrappers.cpp | 51 +++++--- src/helpers/radiolib/RadioLibWrappers.h | 26 +++- 13 files changed, 346 insertions(+), 42 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index e9a8bfe8..79ba47a3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -339,7 +339,65 @@ A fuller position-centred navigator (you fixed at screen centre, fixed/preset zoom, pan) is a larger separate feature; start with the rotate-the-fit version if pursued. -### 📋 Battery / power optimization (CAD RX windowing + APC) — after trail polish +### 🚧 Battery / power optimization (duty-cycle RX + APC) + +**On branch `feat/power-saving`** — two independent toggles under Settings › +Radio, both **default OFF**. Under field testing; not yet merged. + +**✅ Done — hardware duty-cycle RX ("Pwr save")** +- Uses the SX126x's own **RX duty-cycle** (`SetRxDutyCycle`, datasheet 13.1.7) via + RadioLib `startReceiveDutyCycleAuto(preamble, 8)`: the chip's sequencer cycles + RX↔sleep, latches a preamble and stays in RX to receive the packet (RX_DONE on + DIO1). No MCU state machine — `recvRaw()` reads the packet exactly as in + continuous RX. `armRecv()` arms duty-cycle when power-save is on, else a normal + `startReceive()`; `loop()` re-arms only on a toggle. Falls back to continuous RX + if the modem doesn't support duty-cycle (non-SX126x). `state` stays `STATE_RX` + so the dispatcher's not-in-RX watchdog never trips. +- Duty-cycle engages when the configured preamble ≥ 2·8+1 symbols. At SF≤8 the + preamble is 32 → full duty-cycle; at SF9–12 it is 16 → RadioLib transparently + stays on continuous RX (no power saving on the slow SFs). +- Companion: `rx_powersave` pref (schema `0xC0DE0009`), **Settings › Radio › + "Pwr save"**, applied at boot (MyMesh) and on change (UITask). Noise-floor + sampling is skipped while on (chip is asleep most of the time) — the radio page + shows "Noise floor: n/a". + + > **History:** an earlier attempt used a *software* CAD state machine (scan → + > warm-sleep window → on-detect full RX, with `standbyXOSC`/burst windows). It + > fought the hardware — querying a warm-sleeping chip from `checkSend()` gave a + > phantom-busy channel that stalled TX for ~4 s, and ACKs dropped in the scan + > gaps. Replaced wholesale by the hardware duty-cycle above, which fixed both. + +**✅ Done — Adaptive Power Control ("Auto pwr")** +- `tx_power_dbm` becomes a *ceiling*; APC drives the radio's actual power within + `[APC_MIN_DBM −9, ceiling]` to hold the link margin near a target. Lives in + `MyMesh` (`applyApc` + `apcSampleSnr`/`apcOnFailure` controller), `tx_apc` pref. +- **Two feedback sources**, both the *reverse* link (no protocol change): + - **Direct messages** — ACK SNR (`onAckRecv`); missed ACK (`onSendTimeout`) = + lost confirmation. + - **Channel/flood messages** — no ACK exists, so we hash each originated flood + (`apcTrackFloodSend` in `sendFloodScoped`, hash excludes the path) and listen + in `filterRecvFloodPacket` for a repeater rebroadcasting it; the heard echo's + SNR is a sample, and **no echo within ~6 s** counts as a lost confirmation. + This is what lets a channel send recover after APC trimmed power below what the + repeaters can hear (previously it could strand channel TX at the floor). +- Margin is measured **above the per-SF demod floor** (`−7.5 − 2.5·(SF−7)` dB) so + one target works across SF7–12. Each SNR sample is smoothed with an EWMA (α=0.4); + power steps **proportionally** to the error (capped ±2 dB) with a ±2 dB deadband + to avoid hunting; the EWMA is nudged by each step so it doesn't re-trigger on a + stale sample. +- Lost confirmation → step up `+4 dB`; **2 consecutive** losses → jump to the + ceiling (ramp gradually first since a loss is an ambiguous power signal). Any + confirmation clears the streak. Live power shown on the radio page + name bar. + +**⏸ To return to (not done)** +- **Current measurement** — never taken (no PPK2/meter to hand). Reliability is + confirmed in use; the actual mA win is still unquantified. Do this first. +- **APC sample targeting** — a direct ACK's SNR is the last hop of the *return* + path (sound on symmetric/direct links); a flood echo is the *first* hop from us + to a repeater, which is exactly what our TX power controls. Both feed one shared + controller; per-source weighting or direct-only (0-hop) gating could be explored. + +**Original analysis (kept for context):** Goal: multiply battery life **without losing functionality**. The dominant draw on this node is the radio in **continuous RX** (always-on `startReceive()` @@ -373,18 +431,15 @@ NRF52 companion power-saving ([PR #1238](https://github.com/meshcore-dev/MeshCor loop now sleeps via `board.sleep(0)` whenever `hasPendingWork()` is false — but that only covers **MCU idle** (it does not touch the radio, which is the real draw), and there is no on/off toggle because not-sleeping would only waste power. -The same merge also brought the **preamble 16→32 bump for SF<9**, which was the -blocker for our CAD RX-windowing experiment (short windows dropped long packets -at preamble 16). Prefer adopting/extending upstream work over a parallel +The same merge also brought the **preamble 16→32 bump for SF<9**, which is what +makes the hardware RX duty-cycle viable at SF8 (it needs ≥ 2·8+1 = 17 preamble +symbols to latch). Prefer adopting/extending upstream work over a parallel implementation. Note ZephCore's licence before copying any code verbatim (architecture inspiration is fine). -Sequencing: **deferred until the trail/waypoints/nav polishing is finished.** -The radio-side CAD windowing lives on the `feat/power-saving` branch and is now -**unblocked** by the preamble fix. First step when picked up: rebase it onto the -v1.16 merge (it will hit the removed `radio_set_tx_power()`/`radio_set_params()` -globals — replace with `radio_driver.*` like the merge did), then re-test whether -long messages still drop at preamble 32, ideally profiling with a PPK2/meter. +Sequencing: hardware duty-cycle RX and APC are both done and in field test on +`feat/power-saving`; see the status block at the top of this entry for what's +left (PPK2 current measurement, multi-hop APC gating). ### SOS broadcast diff --git a/README.md b/README.md index 74c02d33..44426727 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ Solo firmware thread: https://discord.com/channels/1495203904898728149/150529433 - [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert +- **Battery saving (radio)** — two optional, independent toggles under Settings › Radio: + - **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency + - **Auto pwr** — Adaptive Power Control: trims actual TX power on strong links (from ACK SNR) and ramps back up to the configured ceiling on weak/lost links; the home screen shows the live power + ### E-ink Display The e-ink variant targets the Wio Tracker L1 fitted with a 2.13″ GxEPD2 panel (250 × 122 px). All screens have been adapted for the e-ink panel: diff --git a/docs/solo_features/settings_screen/settings_screen.md b/docs/solo_features/settings_screen/settings_screen.md index f283978f..76b88784 100644 --- a/docs/solo_features/settings_screen/settings_screen.md +++ b/docs/solo_features/settings_screen/settings_screen.md @@ -63,9 +63,11 @@ Lists all available home screen pages. For each entry: ### Radio -| Setting | Options | Notes | -| -------- | -------- | ---------- | -| TX power | 2–22 dBm | LEFT/RIGHT | +| Setting | Options | Notes | +| --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| TX power | 2–22 dBm | LEFT/RIGHT. With **Auto pwr** on this is the *ceiling* — the radio may transmit lower. | +| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). | +| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers actual TX power on strong links to save energy, ramping back up — to the **TX power** ceiling — on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). | --- diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index ae1531df..23b221cc 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0a763641..32dcd706 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 SF7–SF12. 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 { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 81806601..b1c65135 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 0586d5fe..7a75006d 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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 diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index a3f99acc..676a567e 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -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(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c1fa7cab..cb933424 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index f83ebbac..5332b40e 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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(); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index cc7bb223..bd1fe279 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -40,6 +40,17 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } + // Power-save RX = hardware RX duty-cycle (SX126x SetRxDutyCycle, datasheet + // 13.1.7). The chip's sequencer cycles RX↔sleep on its own, latches a preamble + // of the configured length and then stays in RX to receive the packet, raising + // RX_DONE on DIO1 — handled by the normal recvRaw() path, no MCU polling. + // minSymbols=8 is the reliable preamble-latch count for SF7-12. If the + // configured preamble is too short for a real duty-cycle (senderPreamble < + // 2*minSymbols+1), RadioLib transparently falls back to a continuous receive. + int16_t startPowerSaveRecv() override { + return ((SX126x *)_radio)->startReceiveDutyCycleAuto(preambleLengthForSF(_preamble_sf), 8); + } + void setRxBoostedGainMode(bool en) override { ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b6519aef..a452d7ee 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -47,6 +47,7 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + _tx_dbm = dbm; _radio->setOutputPower(dbm); } @@ -84,6 +85,17 @@ void RadioLibWrapper::resetAGC() { } void RadioLibWrapper::loop() { + // Power-save toggled vs the currently-armed RX mode: re-arm into the other mode + // once the radio is idle (don't interrupt an in-flight TX or an unread RX-done). + if (_power_save != _ps_active) { + if (state != STATE_TX_WAIT && !(state & STATE_INT_READY) && !isReceivingPacket()) armRecv(); + return; + } + // In power-save the SX126x hardware duty-cycles RX on its own — nothing to poll + // here, and noise-floor sampling (used only by the disabled interference check) + // would read a chip that is asleep most of the time. + if (_power_save) return; + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { int rssi = getCurrentRSSI(); @@ -104,6 +116,21 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { + armRecv(); +} + +// Arm the receiver. In power-save mode this starts the SX126x hardware RX +// duty-cycle (the chip cycles RX↔sleep and latches a preamble on its own); +// otherwise a continuous RX. Falls back to continuous RX if the modem doesn't +// support duty-cycle (base startPowerSaveRecv() returns UNSUPPORTED). +void RadioLibWrapper::armRecv() { + if (_power_save) { + int16_t e = startPowerSaveRecv(); + if (e == RADIOLIB_ERR_NONE) { state = STATE_RX; _ps_active = true; return; } + MESH_DEBUG_PRINTLN("RadioLibWrapper: RX duty-cycle unsupported (%d) — power-save off", (int)e); + _power_save = false; + } + _ps_active = false; int err = _radio->startReceive(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; @@ -136,12 +163,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 duty-cycle in power-save mode } return len; } @@ -191,22 +213,13 @@ float RadioLibWrapper::getLastSNR() const { return _radio->getSNR(); } -// Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) -static float snr_threshold[] = { - -7.5, // SF7 needs at least -7.5 dB SNR - -10, // SF8 needs at least -10 dB SNR - -12.5, // SF9 needs at least -12.5 dB SNR - -15, // SF10 needs at least -15 dB SNR - -17.5,// SF11 needs at least -17.5 dB SNR - -20 // SF12 needs at least -20 dB SNR -}; - float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { if (sf < 7) return 0.0f; - - if (snr < snr_threshold[sf - 7]) return 0.0f; // Below threshold, no chance of success - auto success_rate_based_on_snr = (snr - snr_threshold[sf - 7]) / 10.0; + float floor = snrFloorForSF(sf); // min SNR for a chance of success + if (snr < floor) return 0.0f; // below the demod floor → no chance + + auto success_rate_based_on_snr = (snr - floor) / 10.0; auto collision_penalty = 1 - (packet_len / 256.0); // Assuming max packet of 256 bytes return max(0.0, min(1.0, success_rate_based_on_snr * collision_penalty)); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index efd3e179..d5b8b434 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -19,10 +19,27 @@ protected: virtual bool isReceivingPacket() =0; virtual void doResetAGC(); + // Power-save RX: hardware SX126x RX duty-cycle (SetRxDutyCycle). Instead of a + // continuous receive the chip itself cycles RX↔sleep, latches a preamble, then + // stays in RX to receive the packet (RX_DONE on DIO1) — no MCU state machine, + // average RX current cut several-fold. Driven from armRecv()/loop(); falls back + // to continuous RX if the modem doesn't support it. + bool _power_save = false; + bool _ps_active = false; // is the radio currently armed in duty-cycle mode + int8_t _tx_dbm = 0; // last TX power applied (tracks APC's live value) + void armRecv(); // arm RX: duty-cycle in power-save, else continuous + // Arm the hardware RX duty-cycle. Base returns UNSUPPORTED → armRecv() falls + // back to continuous RX; SX126x overrides with startReceiveDutyCycleAuto(). + virtual int16_t startPowerSaveRecv() { return RADIOLIB_ERR_UNSUPPORTED; } + public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } void begin() override; + // Enable/disable hardware duty-cycle RX. Takes effect on the next RX re-arm + // (loop() re-arms once the live mode differs from this request). + 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; @@ -32,7 +49,7 @@ public: bool isInRecvMode() const override; bool isChannelActive(); - bool isReceiving() override { + bool isReceiving() override { if (isReceivingPacket()) return true; return isChannelActive(); @@ -41,10 +58,17 @@ public: virtual void setParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; uint32_t getRngSeed(); void setTxPower(int8_t dbm); + int8_t getTxPower() const { return _tx_dbm; } // actual current power (reflects APC) virtual float getCurrentRSSI() =0; virtual uint8_t getSpreadingFactor() const { return LORA_SF; } static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } + // Approx SNR demod floor per SF (Semtech): SF7 -7.5 dB … SF12 -20 dB, -2.5 dB/SF. + // Single source for both packetScore() and the APC link-margin target. + static float snrFloorForSF(uint8_t sf) { + if (sf < 7) sf = 7; else if (sf > 12) sf = 12; + return -7.5f - 2.5f * (float)(sf - 7); + } void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } int getNoiseFloor() const override { return _noise_floor; }