From 73744175b09294ffc8710280a03d769f6f80b85d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:14:19 +0200 Subject: [PATCH] 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 --- examples/companion_radio/DataStore.cpp | 1 + examples/companion_radio/MyMesh.h | 11 +- examples/companion_radio/NodePrefs.h | 2 +- .../ui-new/DiagnosticsScreen.h | 10 +- .../companion_radio/ui-new/NearbyScreen.h | 5 + .../companion_radio/ui-new/SettingsScreen.h | 42 ++++++- examples/companion_radio/ui-new/UITask.cpp | 37 +++++- examples/companion_radio/ui-new/UITask.h | 3 + src/helpers/SensorManager.h | 11 ++ src/helpers/radiolib/CustomSX1262Wrapper.h | 43 +++++++ src/helpers/radiolib/RadioLibWrappers.cpp | 112 ++++++++++++++++-- src/helpers/radiolib/RadioLibWrappers.h | 45 ++++++- .../sensors/EnvironmentSensorManager.cpp | 76 +++++++++++- .../sensors/EnvironmentSensorManager.h | 19 ++- 14 files changed, 386 insertions(+), 31 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 0c05b252..6b37ae02 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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)); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e87f9741..e8abe386 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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 diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index fa69eec3..c3707dbc 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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; diff --git a/examples/companion_radio/ui-new/DiagnosticsScreen.h b/examples/companion_radio/ui-new/DiagnosticsScreen.h index 57e47916..dbfd5d42 100644 --- a/examples/companion_radio/ui-new/DiagnosticsScreen.h +++ b/examples/companion_radio/ui-new/DiagnosticsScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index a7ff5479..61285e24 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 22be931a..e1f05c29 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 069f6dde..15af6032 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 54c8ef4a..37c6ff44 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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 diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index a1ca4306..0c55c304 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -23,6 +23,17 @@ public: virtual const char* getSettingValue(int i) const { return NULL; } virtual bool setSettingValue(const char* name, const char* value) { return false; } virtual LocationProvider* getLocationProvider() { return NULL; } + // GPS duty-cycle hold: force GPS continuously on regardless of any sleep + // schedule, because something needs an unbroken stream of fixes right now. + virtual void setGpsKeepAwake(bool on) { } + // One-shot: true the first call after GPS transitions sleep->awake, so a + // caller can re-seed state that a stale reading could otherwise corrupt + // (e.g. a geofence's crossing state). False the rest of the time. + virtual bool consumeGpsWakeEvent() { return false; } + // True while GPS is in the sleep phase of its duty cycle (physically off, + // waiting for the next scheduled wake) -- purely a UI cue, e.g. to blink + // the GPS status icon. + virtual bool isGpsDutySleeping() const { return false; } virtual int getAvailableLPPTypes(uint8_t* types, int max_count) const { return 0; } // Helper functions to manage setting by keys (useful in many places ...) diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 03f93852..1fa4c648 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -9,10 +9,20 @@ #endif class CustomSX1262Wrapper : public RadioLibWrapper { + // Cached runtime radio params, only used to recover from a hard reset (see + // radioHardReset()): std_init() re-applies the compiled LORA_FREQ/BW/SF/CR + // firmware defaults, not whatever the user has actually configured, so + // these are needed to restore real state afterwards. + float _wd_freq = 0, _wd_bw = 0; + uint8_t _wd_sf = 0, _wd_cr = 0; + bool _wd_params_valid = false; + bool _wd_rx_boosted_gain = false; + public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + _wd_freq = freq; _wd_bw = bw; _wd_sf = sf; _wd_cr = cr; _wd_params_valid = true; ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -55,9 +65,42 @@ public: } void setRxBoostedGainMode(bool en) override { + _wd_rx_boosted_gain = en; ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en); } bool getRxBoostedGainMode() const override { return ((CustomSX1262 *)_radio)->getRxBoostedGainMode(); } + + bool supportsRxPsWatchdog() const override { return true; } + + // BUSY is high whenever the chip can't service SPI -- including the sleep + // window of an armed RX duty-cycle. Same access pattern already used by + // sx126xResetAGC() in SX126xReset.h. + bool isChipBusy() override { + SX126x* radio = (SX126x *)_radio; + return radio->mod->hal->digitalRead(radio->mod->getGpio()); + } + + // Full chip reset + re-init after a stuck RX duty-cycle that a soft re-arm + // didn't clear. std_init() re-applies compiled firmware defaults, not the + // user's runtime settings, so reapply the cached params and re-attach the + // packet-received action once it returns. + bool radioHardReset() override { + if (!((CustomSX1262 *)_radio)->std_init(&SPI)) return false; + reattachRecvAction(); + if (_wd_params_valid) { + ((CustomSX1262 *)_radio)->setFrequency(_wd_freq); + ((CustomSX1262 *)_radio)->setSpreadingFactor(_wd_sf); + ((CustomSX1262 *)_radio)->setBandwidth(_wd_bw); + ((CustomSX1262 *)_radio)->setCodingRate(_wd_cr); + updatePreamble(_wd_sf); + } + _radio->setOutputPower(getTxPower()); + // Unconditional: std_init() may have just turned boosted gain back ON via + // the board's SX126X_RX_BOOSTED_GAIN compile default, so the OFF case + // needs reapplying just as much as ON. + ((CustomSX1262 *)_radio)->setRxBoostedGainMode(_wd_rx_boosted_gain); + return true; + } }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index a452d7ee..be6ff60a 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,10 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +#define RXPS_WATCHDOG_THRESHOLD_MS 60000UL // no BUSY transition for this long => stuck +#define NF_CALIB_INTERVAL_MS 60000UL // recalibrate at least once a minute +#define NF_CALIB_TIMEOUT_MS 5000UL // give up on the window (busy channel) + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -84,17 +88,94 @@ void RadioLibWrapper::resetAGC() { _floor_sample_sum = 0; } +// Detects a stuck RX duty-cycle sequencer: a healthy chip shows the hardware +// BUSY pin toggling as it moves through its own RX<->sleep cycle. If that +// stops for too long, first try a cheap soft re-arm; if it's still stuck next +// time, escalate to a full chip reset (radioHardReset()). +void RadioLibWrapper::rxPsWatchdogCheck() { + // Don't interfere mid-transmit, or with a completed-but-unread packet — a + // pending DIO1 event is itself proof the radio is alive, and recvRaw() + // will consume it and re-arm (re-basing the watchdog) on its own. + if ((state & STATE_INT_READY) != 0 || (state & ~STATE_INT_READY) == STATE_TX_WAIT) return; + + uint32_t now = millis(); + bool busy = isChipBusy(); + if (busy != _wd_last_busy) { + _wd_last_busy = busy; + _wd_last_transition_ms = now; + _wd_stage = 0; // proof of life -- any prior stall is over + return; + } + + if (now - _wd_last_transition_ms <= RXPS_WATCHDOG_THRESHOLD_MS) return; + + _wd_last_transition_ms = now; // grace period before the next escalation + + if (_wd_stage == 0) { + _wd_stage = 1; + _wd_soft_count++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: RX duty-cycle watchdog: stuck, soft re-arm"); + state = STATE_IDLE; // next recvRaw()/loop() re-arms via armRecv() + } else { + _wd_hard_count++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: RX duty-cycle watchdog: still stuck, hard reset"); + radioHardReset(); + _wd_stage = 0; // chip is freshly (re)initialized either way -- observe again from scratch + state = STATE_IDLE; + } +} + +void RadioLibWrapper::reattachRecvAction() { + _radio->setPacketReceivedAction(setFlag); +} + +// Checks whether a periodic noise-floor recalibration window is due while RX +// duty-cycle power-save is active (see the field comments in the header). +// Only starts one when it's safe to interrupt: not mid-transmit, no unread +// packet waiting, and no reception currently in progress. +void RadioLibWrapper::noiseFloorCalibCheck() { + if ((state & STATE_INT_READY) != 0 || (state & ~STATE_INT_READY) == STATE_TX_WAIT) return; + if (isReceivingPacket()) return; + + uint32_t now = millis(); + if (_nf_last_calib_ms != 0 && now - _nf_last_calib_ms < NF_CALIB_INTERVAL_MS) return; + + _nf_calib_active = true; + _nf_calib_deadline_ms = now + NF_CALIB_TIMEOUT_MS; + _num_floor_samples = 0; // start a fresh batch for this window + _floor_sample_sum = 0; + state = STATE_IDLE; // next loop() re-arms into continuous RX (see armRecv()) +} + 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) { + // Power-save vs the currently-armed RX mode (toggled by the user, or by a + // noise-floor recalibration window borrowing continuous RX for a moment): + // re-arm into the wanted mode once the radio is idle (don't interrupt an + // in-flight TX or an unread RX-done). + bool want_duty_cycle = _power_save && !_nf_calib_active; + if (want_duty_cycle != _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 (want_duty_cycle) { + // Steady-state duty-cycle: the chip cycles RX<->sleep on its own, nothing + // to poll here. Just watch for a stuck sequencer and check whether a + // recalibration window is due (noiseFloorCalibCheck() flips + // _nf_calib_active, which the branch above then re-arms out of). + if (supportsRxPsWatchdog()) rxPsWatchdogCheck(); + noiseFloorCalibCheck(); + return; + } + + if (_nf_calib_active && !isReceivingPacket() && (int32_t)(millis() - _nf_calib_deadline_ms) >= 0) { + // Batch couldn't complete in time (busy channel) -- give up, keep the + // previous floor, and let the branch above re-arm the duty-cycle. + _nf_calib_active = false; + _nf_last_calib_ms = millis(); + state = STATE_IDLE; + return; + } if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { @@ -112,6 +193,11 @@ void RadioLibWrapper::loop() { _floor_sample_sum = 0; MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + + if (_nf_calib_active) { + _nf_calib_active = false; // fresh floor published -- back to duty-cycle + _nf_last_calib_ms = millis(); + } } } @@ -119,12 +205,14 @@ 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). +// Arm the receiver. In power-save mode (and outside a noise-floor +// recalibration window, which needs a moment of plain continuous RX -- see +// noiseFloorCalibCheck()) 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) { + if (_power_save && !_nf_calib_active) { 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); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index ef762255..f6769dee 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -13,6 +13,19 @@ protected: int32_t _floor_sample_sum; uint8_t _preamble_sf; + // Periodic noise-floor recalibration while RX duty-cycle power-save is + // active: the frontend is off for most of a duty cycle, so samples taken + // there aren't meaningful and loop() skips them entirely (see loop()) — + // meaning _noise_floor would otherwise freeze at whatever it was when + // power-save turned on, silently breaking int.thresh interference + // detection. Instead, drop to plain continuous RX for one window every + // NF_CALIB_INTERVAL_MS, run the normal sampling loop, then re-arm the + // duty-cycle once a fresh average is published. + bool _nf_calib_active = false; + uint32_t _nf_last_calib_ms = 0; + uint32_t _nf_calib_deadline_ms = 0; // abort the window if it can't complete (busy channel) + void noiseFloorCalibCheck(); + void idle(); void startRecv(); float packetScoreInt(float snr, int sf, int packet_len); @@ -32,6 +45,34 @@ protected: // back to continuous RX; SX126x overrides with startReceiveDutyCycleAuto(). virtual int16_t startPowerSaveRecv() { return RADIOLIB_ERR_UNSUPPORTED; } + // RX duty-cycle watchdog: the chip's own sequencer cycles RX<->sleep with no + // MCU polling, so if it desyncs (a known SX126x failure mode) nothing else + // would notice. Healthy operation shows up as the hardware BUSY pin + // toggling as the chip moves through its cycle; if that stops for too long, + // first try a cheap soft re-arm, then a full chip reset. + bool _wd_last_busy = false; + uint32_t _wd_last_transition_ms = 0; + uint8_t _wd_stage = 0; // 0 = healthy / not yet tried, 1 = soft re-arm already attempted this stall + uint32_t _wd_soft_count = 0, _wd_hard_count = 0; + void rxPsWatchdogCheck(); + // Re-attach the packet-received/duty-cycle-done interrupt action. Exposed so + // radioHardReset() overrides (a different translation unit) can redo this + // binding after a fresh begin(), without duplicating the static ISR here. + void reattachRecvAction(); + + // Overridden by radios that support the watchdog (SX126x only today, since + // it's the only one with a working startPowerSaveRecv()). Default false so + // the watchdog never runs where isChipBusy()/radioHardReset() aren't real. + virtual bool supportsRxPsWatchdog() const { return false; } + // True while the chip can't service SPI (duty-cycle sleep window, or + // briefly mid-command) — radios expose this via the hardware BUSY pin. + virtual bool isChipBusy() { return false; } + // Full chip reset + re-init after a stuck duty-cycle a soft re-arm didn't + // clear. Returns false if unsupported (base default: no-op). Implementations + // must reapply any runtime radio state a fresh init would reset to compiled + // firmware defaults (frequency/bandwidth/SF/CR/TX power/preamble/gain). + virtual bool radioHardReset() { return false; } + public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } @@ -87,7 +128,9 @@ public: uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + uint32_t getRxPsWatchdogSoftCount() const { return _wd_soft_count; } + uint32_t getRxPsWatchdogHardCount() const { return _wd_hard_count; } + void resetStats() { n_recv = n_sent = n_recv_errors = 0; _wd_soft_count = _wd_hard_count = 0; } virtual float getLastRSSI() const override; virtual float getLastSNR() const override; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index a558899a..44c2661d 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -703,7 +703,7 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const { int settings = 0; #if ENV_INCLUDE_GPS if (gps_detected && i == settings++) { - return gps_active ? "1" : "0"; + return gps_master_enabled ? "1" : "0"; } #endif return NULL; @@ -712,16 +712,19 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const { bool EnvironmentSensorManager::setSettingValue(const char* name, const char* value) { #if ENV_INCLUDE_GPS if (gps_detected && strcmp(name, "gps") == 0) { - if (strcmp(value, "0") == 0) { - stop_gps(); - } else { + gps_master_enabled = strcmp(value, "0") != 0; + if (gps_master_enabled) { start_gps(); + } else { + stop_gps(); } return true; } if (strcmp(name, "gps_interval") == 0) { - uint32_t interval_seconds = atoi(value); - gps_update_interval_sec = interval_seconds > 0 ? interval_seconds : 1; + // Now the GPS duty-cycle sleep window in seconds (0 = disabled) rather + // than a read/snapshot cadence -- same setting key, so the companion + // app / CLI (MyMesh.cpp's "gps_interval" frame handler) needs no change. + gps_duty_sleep_sec = (uint32_t)atoi(value); return true; } #endif @@ -761,6 +764,7 @@ void EnvironmentSensorManager::initBasicGPS() { MESH_DEBUG_PRINTLN("GPS detected"); #ifdef PERSISTANT_GPS gps_active = true; + gps_master_enabled = true; return; #endif } else { @@ -768,6 +772,7 @@ void EnvironmentSensorManager::initBasicGPS() { } _location->stop(); gps_active = false; //Set GPS visibility off until setting is changed + gps_master_enabled = false; } // gps code for rak might be moved to MicroNMEALoactionProvider @@ -793,6 +798,7 @@ void EnvironmentSensorManager::rakGPSInit(){ else{ MESH_DEBUG_PRINTLN("No GPS found"); gps_active = false; + gps_master_enabled = false; gps_detected = false; Serial1.end(); return; @@ -802,6 +808,12 @@ void EnvironmentSensorManager::rakGPSInit(){ //Now that GPS is found and set up, set to sleep for initial state stop_gps(); #endif + // Mirror whatever gps_active ended up as above into the master on/off + // intent -- true if FORCE_GPS_ALIVE kept it running, false if stop_gps() + // just parked it. Without this the duty-cycle scheduler (which only obeys + // gps_master_enabled) would immediately turn GPS back on next tick and + // defeat the initial-sleep intent of the stop_gps() call above. + gps_master_enabled = gps_active; } bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){ @@ -852,6 +864,56 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){ } #endif +// Max time to wait for a fix per wake before giving up and going back to +// sleep anyway -- an uncapped wait would defeat duty-cycling entirely +// indoors / under canopy / in a canyon, where a fix may never come. +#define GPS_DUTY_ACQUIRE_TIMEOUT_MS 60000UL + +// Cycles GPS power between a sleep phase and a wake-and-acquire phase when +// duty-cycling is enabled (gps_duty_sleep_sec > 0) and nothing is holding it +// continuously awake. Skipped entirely -- and GPS forced back on if it +// happened to be asleep -- while the master toggle is off, duty-cycling is +// disabled, or a live consumer is holding _gps_keep_awake, so all three of +// those states behave exactly like today's always-on GPS. +void EnvironmentSensorManager::gpsDutyCycleLoop() { + if (!gps_detected || !gps_master_enabled) return; + + if (gps_duty_sleep_sec == 0 || _gps_keep_awake) { + if (!gps_active) { start_gps(); _gps_just_woke = true; } + _gps_duty_phase_until = 0; // no scheduler-owned phase while bypassed -- + // re-armed fresh below whenever this resumes + return; + } + + uint32_t now = millis(); + if (!gps_active) { + // sleeping -- wake up once the sleep window has elapsed + if (_gps_duty_phase_until == 0 || (int32_t)(now - _gps_duty_phase_until) >= 0) { + start_gps(); + _gps_just_woke = true; + _gps_duty_phase_until = now + GPS_DUTY_ACQUIRE_TIMEOUT_MS; + } + return; + } + + // GPS is on, but not because we just armed an acquire phase for it -- + // either boot-time start via applyGpsPrefs(), or duty-cycling just resumed + // after a keep-awake hold. Start a fresh acquire window instead of treating + // the unset timer as already-expired, which would stop_gps() before a cold + // fix ever had a chance. + if (_gps_duty_phase_until == 0) { + _gps_duty_phase_until = now + GPS_DUTY_ACQUIRE_TIMEOUT_MS; + return; + } + + // awake -- back to sleep once we have a fix, or once we've waited long + // enough that one isn't coming this cycle + if (_location->isValid() || (int32_t)(now - _gps_duty_phase_until) >= 0) { + stop_gps(); + _gps_duty_phase_until = now + gps_duty_sleep_sec * 1000UL; + } +} + void EnvironmentSensorManager::start_gps() { gps_active = true; #ifdef RAK_WISBLOCK_GPS @@ -888,6 +950,8 @@ void EnvironmentSensorManager::stop_gps() { void EnvironmentSensorManager::loop() { #if ENV_INCLUDE_GPS + gpsDutyCycleLoop(); + static long next_gps_update = 0; if (gps_active) { _location->loop(); diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 29147c89..340a1d9b 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -20,11 +20,23 @@ protected: uint8_t next_available_channel = TELEM_CHANNEL_SELF + 1; bool gps_detected = false; - bool gps_active = false; + bool gps_active = false; // physically powered on right now uint32_t gps_update_interval_sec = 1; #if ENV_INCLUDE_GPS LocationProvider* _location; + bool gps_master_enabled = false; // user's on/off intent (Settings/bot/CLI) -- distinct + // from gps_active, which duty-cycling now flips on its own + // Duty-cycling: while enabled, GPS sleeps for gps_duty_sleep_sec between + // acquisitions instead of running continuously. 0 = disabled (today's + // always-on behaviour). Skipped entirely while _gps_keep_awake is held by + // a live consumer (background trail/live-share/locator, or a screen that + // needs a fresh reading right now) -- see setGpsKeepAwake(). + uint32_t gps_duty_sleep_sec = 0; + bool _gps_keep_awake = false; + uint32_t _gps_duty_phase_until = 0; + bool _gps_just_woke = false; // one-shot; consumed by UITask to reset locator state + void gpsDutyCycleLoop(); void start_gps(); void stop_gps(); void initBasicGPS(); @@ -38,6 +50,11 @@ public: #if ENV_INCLUDE_GPS EnvironmentSensorManager(LocationProvider &location): _location(&location){}; LocationProvider* getLocationProvider() { return _location; } + void setGpsKeepAwake(bool on) override { _gps_keep_awake = on; } + bool consumeGpsWakeEvent() override { bool w = _gps_just_woke; _gps_just_woke = false; return w; } + bool isGpsDutySleeping() const override { + return gps_master_enabled && gps_duty_sleep_sec > 0 && !_gps_keep_awake && !gps_active; + } #else EnvironmentSensorManager(){}; #endif