mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
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:
75
FEATURES.md
75
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user