feat(power): RX duty-cycle watchdog, noise-floor recal, GPS duty-cycling

Three power-saving additions, prompted by comparing this fork's existing
RX duty-cycle support against IoTThinks/EasySkyMesh:

- RX duty-cycle watchdog: the SX126x's hardware RX<->sleep sequencer runs
  with no MCU polling, so a desync (a known failure mode) previously had
  nothing watching for it. A new watchdog samples the BUSY pin every tick;
  no transition for too long triggers a soft re-arm, then a full chip
  reset (with cached radio params reapplied, since std_init() resets to
  compiled firmware defaults) if that doesn't clear it. Soft/hard recovery
  counts surface on Tools > Diagnostics > Live as "RXPS wd s/h".

- Noise-floor recalibration during power-save: sampling was previously
  skipped entirely while duty-cycling, freezing int.thresh interference
  detection at whatever the floor was when power-save turned on. Now
  borrows a brief continuous-RX window once a minute to take a fresh
  reading before re-arming duty-cycle.

- GPS duty-cycling (Settings > System > "GPS pwr"): cycles GPS off between
  fixes instead of running it continuously. Each wake waits for a fix
  (capped at 60s) before sleeping again for the configured interval.
  Repurposes the long-dead NodePrefs::gps_interval byte rather than adding
  a new persisted field. A "is anything live using GPS right now" hold in
  UITask keeps GPS continuously on whenever trail recording, live-share,
  an armed Locator, or the Compass/Nearby-navigate view actually need a
  live fix, so none of those features degrade. Locator crossing-state is
  reset on each wake so a still-settling first fix can't read as a false
  geofence crossing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-07 22:14:19 +02:00
co-authored by Claude Opus 5
parent fe02fda897
commit 73744175b0
14 changed files with 386 additions and 31 deletions
+1
View File
@@ -264,6 +264,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet));
file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled));
file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval));
if (_prefs.gps_interval > 86400) _prefs.gps_interval = 0; // now a duty-cycle sleep window (secs); 0 = disabled
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config));
file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops));
file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain));
+6 -5
View File
@@ -308,11 +308,12 @@ public:
#if ENV_INCLUDE_GPS == 1
void applyGpsPrefs() {
sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0");
if (_prefs.gps_interval > 0) {
char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null)
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
}
// gps_interval doubles as the GPS duty-cycle sleep window in seconds
// (0 = disabled, GPS stays continuous) -- see EnvironmentSensorManager::
// gpsDutyCycleLoop(). Max: 24 hours = 86400 seconds (5 digits + null).
char interval_str[12];
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
}
#endif
+1 -1
View File
@@ -62,7 +62,7 @@ struct NodePrefs { // persisted to file
uint8_t buzzer_quiet;
uint8_t buzzer_volume; // 0=min..4=max, default 4
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
uint32_t gps_interval; // GPS read interval in seconds
uint32_t gps_interval; // GPS duty-cycle sleep window in seconds (0 = disabled, GPS stays continuous)
uint8_t autoadd_config; // bitmask for auto-add contacts config
uint8_t rx_boosted_gain; // SX126x RX boosted gain mode (0=power saving, 1=boosted)
uint8_t client_repeat;
@@ -143,6 +143,13 @@ class DiagnosticsScreen : public UIScreen {
if (len > 0 && buf[len - 1] == ' ') buf[len - 1] = '\0';
}
addRow("Errors", buf);
// RX duty-cycle watchdog recovery counts since boot/reset (soft re-arm /
// hard chip reset). Always 0/0 on radios or profiles that never arm
// duty-cycle power-save (repeaters force it off — see applyPowerSave()).
snprintf(buf, sizeof(buf), "%lu/%lu", (unsigned long)radio_driver.getRxPsWatchdogSoftCount(),
(unsigned long)radio_driver.getRxPsWatchdogHardCount());
addRow("RXPS wd s/h", buf);
}
void buildSystemLines() {
@@ -257,7 +264,8 @@ public:
if (_reset_menu.active) {
auto res = _reset_menu.handleInput(c); // Back/Cancel dismisses; the only item is "Reset counters"
if (res == PopupMenu::SELECTED) {
the_mesh.resetStats(); // zeroes Dispatcher per-type counters + Mesh forward count + err flags
the_mesh.resetStats(); // zeroes Dispatcher per-type counters + Mesh forward count + err flags
radio_driver.resetStats(); // zeroes the radio's own counters, incl. RXPS watchdog soft/hard counts
_task->showAlert("Counters reset", 800);
}
return true;
@@ -734,6 +734,11 @@ public:
_sort_label[0] = '\0';
}
// Whether the full-screen navigate-to-node view is up -- used by UITask's
// GPS duty-cycle "is anything live using GPS right now" check, since this
// view needs an unbroken stream of fixes for bearing/ETA, not a stale one.
bool isNavigating() const { return _nav; }
void onShow() override {
_sel = _scroll = 0;
_detail = false;
@@ -66,6 +66,9 @@ class SettingsScreen : public UIScreen {
DEVICE_NAME,
TIMEZONE,
LOW_BAT,
#if ENV_INCLUDE_GPS == 1
GPS_DUTY_CYCLE,
#endif
UNITS,
REBOOT,
// Keyboard section
@@ -106,12 +109,24 @@ class SettingsScreen : public UIScreen {
static const char* AUTO_OFF_LABELS[5];
static const int AUTO_OFF_COUNT = 5;
#endif
// GPS update interval tables are no longer surfaced in Settings — the sensor
// manager defaults to 1 s when nothing else sets it. Pref byte _prefs.gps_interval
// is retained for backwards compatibility.
static const uint16_t LOW_BAT_OPTS[7];
static const char* LOW_BAT_LABELS[7];
static const int LOW_BAT_COUNT = 7;
#if ENV_INCLUDE_GPS == 1
// GPS duty-cycle sleep window: how long GPS naps between fix acquisitions.
// "off" (0) keeps it continuously on, today's behaviour. Backed by
// NodePrefs::gps_interval, seconds.
static const uint32_t GPS_DUTY_OPTS[6];
static const char* GPS_DUTY_LABELS[6];
static const int GPS_DUTY_COUNT = 6;
int gpsDutyIndex() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
for (int i = 0; i < GPS_DUTY_COUNT; i++)
if (GPS_DUTY_OPTS[i] == p->gps_interval) return i;
return 0;
}
#endif
static const char* BATT_DISPLAY_LABELS[3];
static const int BATT_DISPLAY_COUNT = 3;
static const char* SOUND_LABELS[4];
@@ -562,6 +577,12 @@ class SettingsScreen : public UIScreen {
display.print("LowBat");
display.setCursor(valCol(display), y);
display.print(LOW_BAT_LABELS[lowBatIndex()]);
#if ENV_INCLUDE_GPS == 1
} else if (item == GPS_DUTY_CYCLE) {
display.print("GPS pwr");
display.setCursor(valCol(display), y);
display.print(GPS_DUTY_LABELS[gpsDutyIndex()]);
#endif
} else if (item == UNITS) {
display.print("Units");
display.setCursor(valCol(display), y);
@@ -923,6 +944,17 @@ public:
if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT;
if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; }
}
#if ENV_INCLUDE_GPS == 1
if (_selected == GPS_DUTY_CYCLE && p && (left || right)) {
int idx = gpsDutyIndex();
if (right) idx = (idx + 1) % GPS_DUTY_COUNT;
if (left) idx = (idx + GPS_DUTY_COUNT - 1) % GPS_DUTY_COUNT;
p->gps_interval = GPS_DUTY_OPTS[idx];
_task->applyGpsInterval();
_dirty = true;
return true;
}
#endif
if (_selected == UNITS && p && (left || right || enter)) {
p->units_imperial ^= 1;
_dirty = true;
@@ -1053,6 +1085,10 @@ const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s"
#endif
const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 };
const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" };
#if ENV_INCLUDE_GPS == 1
const uint32_t SettingsScreen::GPS_DUTY_OPTS[6] = { 0, 60, 300, 900, 1800, 3600 };
const char* SettingsScreen::GPS_DUTY_LABELS[6] = { "off", "1 min", "5 min", "15 min", "30 min", "1 h" };
#endif
const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" };
const char* SettingsScreen::SOUND_LABELS[4] = { "built-in", "M1", "M2", "None" };
const char* SettingsScreen::AD_SCOPE_LABELS[2] = { "All", "Zero-hop" };
+36 -1
View File
@@ -497,6 +497,10 @@ class HomeScreen : public UIScreen {
// keep running with Bluetooth off, so their cue must not vanish with it.
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
bool gps_on = loc && _node_prefs && _node_prefs->gps_enabled;
// Blinks while GPS is napping between duty-cycle wakes -- same convention
// the background-mode icons below already use for "running, but not busy
// right this instant".
bool gps_napping = _sensors && _sensors->isGpsDutySleeping();
bool mute_on = false;
#ifdef PIN_BUZZER
mute_on = _task->isBuzzerQuiet();
@@ -504,7 +508,7 @@ class HomeScreen : public UIScreen {
struct Sicon { bool active; const MiniIcon* icon; bool boxed; bool blink; };
const Sicon icons[] = {
{ _task->isSerialEnabled(), &ICON_BLUETOOTH, _task->isSerialEnabled() && _task->isBLEConnected(), false },
{ gps_on, &ICON_GPS, gps_on && loc->isValid(), false },
{ gps_on, &ICON_GPS, gps_on && loc->isValid(), gps_napping },
{ _node_prefs && _node_prefs->alarm_on, &ICON_ALARM, true, false },
{ mute_on, &ICON_MUTE, true, false },
{ _node_prefs && _node_prefs->advert_auto_interval_sec > 0, &ICON_ADVERT, true, true },
@@ -2543,6 +2547,28 @@ void UITask::loop() {
next_batt_chck = millis() + 8000;
}
// GPS duty-cycle hold — tells the sensor manager whether *anything* needs
// an unbroken stream of fixes right now, so it knows it's safe to let GPS
// nap between reads (EnvironmentSensorManager::gpsDutyCycleLoop()).
// Deliberately excludes the COG sampler just below: that one runs
// unconditionally every ~1s specifically so a heading is ready whenever a
// screen opens, and including it here would keep GPS permanently awake and
// defeat duty-cycling entirely — it just goes stale during a sleep window
// and catches up whenever GPS is awake for any other reason.
if (_sensors) {
bool gps_needed_live =
(_trail.isActive() && !_trail.isPaused())
|| (_node_prefs && _node_prefs->loc_share_enabled)
|| (_node_prefs && _node_prefs->locator_enabled && _node_prefs->locator_has_target)
|| curr == compass_screen
|| (curr == nearby_screen && ((NearbyScreen*)nearby_screen)->isNavigating());
_sensors->setGpsKeepAwake(gps_needed_live);
// A fresh wake (either a duty-cycle wake, or GPS forced continuously back
// on) may deliver a still-settling first fix — re-seed the locator's
// crossing state so that doesn't read as a spurious geofence crossing.
if (_sensors->consumeGpsWakeEvent()) resetLocator();
}
// GPS trail sampling — runs in the background while the trail is
// active, independent of which screen is shown. Skips silently if no GPS
// fix; min-delta gate inside addPoint() avoids near-stationary spam.
@@ -3334,6 +3360,15 @@ void UITask::applyApc() {
the_mesh.applyApc(); // (re)initialise Adaptive Power Control from prefs
}
#if ENV_INCLUDE_GPS == 1
void UITask::applyGpsInterval() {
if (_node_prefs == NULL || _sensors == NULL) return;
char buf[12];
sprintf(buf, "%u", _node_prefs->gps_interval);
_sensors->setSettingValue("gps_interval", buf);
}
#endif
void UITask::applyRadioParams() {
if (_node_prefs == NULL) return;
the_mesh.applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
+3
View File
@@ -455,6 +455,9 @@ public:
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
void applyApc(); // Adaptive Power Control on/off from prefs
void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change)
#if ENV_INCLUDE_GPS == 1
void applyGpsInterval(); // GPS duty-cycle sleep window from prefs
#endif
// Save-on-exit helper for the screen `_dirty` pattern: persists NodePrefs once
// only if `dirty`, then clears the flag. Standardises the screens' exit paths
// (some used to leave the flag set, relying on onShow() to reset it) and keeps