From 024d8863bbbe21d59e35d4d2b3ecc62366704b1c Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:38:41 +0200 Subject: [PATCH 1/6] sleep: arm deep-sleep hold so per-pin gpio_hold_en actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doDeepSleep called gpio_hold_en for BUTTON_PIN and LORA_CS, but the ESP-IDF API contract is that gpio_hold_en alone only covers light sleep — deep sleep additionally requires gpio_deep_sleep_hold_en once before esp_deep_sleep_start to arm the holds across the deep- sleep boundary. Without it the pins float in DSLP, which on some boards leaks current through LORA_CS and on others triggers spurious wake from a noisy button line. --- src/sleep.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/sleep.cpp b/src/sleep.cpp index 792781f6d0d..c4b858f221c 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -370,6 +370,14 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN pinMode(I2C_SCL, ANALOG); #endif +#ifdef ARCH_ESP32 + // gpio_hold_en alone only retains pin state during light sleep on ESP32; + // for the holds to apply during deep sleep the global enable must be + // armed once before esp_deep_sleep_start. Without this, BUTTON_PIN and + // LORA_CS float in DSLP and can leak current or trigger spurious activity. + gpio_deep_sleep_hold_en(); +#endif + console->flush(); cpuDeepSleep(msecToWake); } From 634aa56d70777488ae30ae2e1153ea92e757768f Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:41:00 +0200 Subject: [PATCH 2/6] gps: soft-sleep the receiver during CPU light-sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPS only subscribed to notifyDeepSleep, so during light-sleep the receiver kept drawing ~25 mA on a non-router device — the dominant drain on devices that spend most of their time in LIGHT_SLEEP. Subscribe to notifyLightSleep/notifyLightSleepEnd as well, but use GPS_SOFTSLEEP instead of disable()+enable() since the state machine survives across the short light-sleep window and full power-cycle re-acquisition would cost more than it saves. --- src/gps/GPS.cpp | 31 +++++++++++++++++++++++++++++++ src/gps/GPS.h | 13 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index e581eb0ef98..72fd752efc4 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -819,6 +819,10 @@ bool GPS::setup() } notifyDeepSleepObserver.observe(¬ifyDeepSleep); +#ifdef ARCH_ESP32 + notifyLightSleepObserver.observe(¬ifyLightSleep); + notifyLightSleepEndObserver.observe(¬ifyLightSleepEnd); +#endif return true; } @@ -827,6 +831,10 @@ GPS::~GPS() { // we really should unregister our sleep observer notifyDeepSleepObserver.unobserve(¬ifyDeepSleep); +#ifdef ARCH_ESP32 + notifyLightSleepObserver.unobserve(¬ifyLightSleep); + notifyLightSleepEndObserver.unobserve(¬ifyLightSleepEnd); +#endif } // Put the GPS hardware into a specified state @@ -1246,6 +1254,29 @@ int GPS::prepareDeepSleep(void *unused) return 0; } +#ifdef ARCH_ESP32 +// Light-sleep is short and the GPS state machine survives across it; soft-sleep +// the receiver instead of disabling so we don't pay re-init / re-acquire cost +// on every cycle. Without this the GPS keeps drawing ~25 mA the whole time the +// MCU is asleep — the dominant drain on most non-router devices. +int GPS::prepareLightSleep(void *unused) +{ + preLightSleepState = powerState; + if (powerState == GPS_ACTIVE) { + setPowerState(GPS_SOFTSLEEP); + } + return 0; +} + +int GPS::endLightSleep(esp_sleep_wakeup_cause_t cause) +{ + if (preLightSleepState == GPS_ACTIVE && powerState != GPS_ACTIVE) { + setPowerState(GPS_ACTIVE); + } + return 0; +} +#endif + static const char *PROBE_MESSAGE = "Trying %s (%s)..."; static const char *DETECTED_MESSAGE = "%s detected"; diff --git a/src/gps/GPS.h b/src/gps/GPS.h index 8d63ce82fee..2778ae79d4c 100644 --- a/src/gps/GPS.h +++ b/src/gps/GPS.h @@ -198,6 +198,13 @@ class GPS : private concurrency::OSThread CallbackObserver notifyDeepSleepObserver = CallbackObserver(this, &GPS::prepareDeepSleep); +#ifdef ARCH_ESP32 + GPSPowerState preLightSleepState = GPS_OFF; + CallbackObserver notifyLightSleepObserver = CallbackObserver(this, &GPS::prepareLightSleep); + CallbackObserver notifyLightSleepEndObserver = + CallbackObserver(this, &GPS::endLightSleep); +#endif + /** If !NULL we will use this serial port to construct our GPS */ #if defined(ARCH_RP2040) static SerialUART *_serial_gps; @@ -226,6 +233,12 @@ class GPS : private concurrency::OSThread /// always returns 0 to indicate okay to sleep int prepareDeepSleep(void *unused); +#ifdef ARCH_ESP32 + /// Drop the GPS into a low-power state for the duration of CPU light-sleep, and restore on wake. + int prepareLightSleep(void *unused); + int endLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + /** Set power with EN pin, if relevant */ void writePinEN(bool on); From 3f04334853b35e188d52187f9442a2028667f919 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:56:04 +0200 Subject: [PATCH 3/6] power: latch low-battery decision in RTC memory and add recovery hysteresis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous low-voltage handler counted 11 consecutive readings under OCV[min] and triggered EVENT_LOW_BATTERY, but on SDS wake the counter reset to 0. With the cell still flat, the count climbed back to 11 and deep-slept again — wake-thrash burning boot energy each cycle. There was also no recovery hysteresis: a brief spike across the threshold would clear the count even with the battery still effectively flat. Persist the latch in RTC memory so SDS doesn't lose it. Once latched, clearing requires K consecutive readings above OCV[min] + 100 mV. If the device wakes and is still latched-flat, immediately re-enter SDS without running a normal boot. --- src/Power.cpp | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Power.cpp b/src/Power.cpp index bb9f554be40..40142ff55be 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -848,6 +848,17 @@ void Power::shutdown() #endif } +// Persisted across SDS / deep-sleep on ESP32 so a flat-cell device that has +// already chosen to deep-sleep doesn't wake on the timer, see still-low +// voltage, and burn boot energy on a doomed full init only to deep-sleep +// again. On non-ESP32 the SDS path doesn't reset this anyway, so a plain +// static gives the same effective semantics. +#ifdef ARCH_ESP32 +RTC_DATA_ATTR static bool low_battery_latched = false; +#else +static bool low_battery_latched = false; +#endif + /// Reads power status to powerStatus singleton. // // TODO(girts): move this and other axp stuff to power.h/power.cpp. @@ -968,11 +979,35 @@ void Power::readPowerStatus() // if (batteryLevel && powerStatus2.getHasBattery() && !powerStatus2.getHasUSB()) { - if (batteryLevel->getBattVoltage() < OCV[NUM_OCV_POINTS - 1]) { + // Hysteresis around the low-battery latch: + // - The latch survives SDS via RTC memory so a still-flat cell doesn't + // wake-thrash on the timer. + // - To clear the latch, voltage has to recover above OCV[min] + + // LOW_BATT_HYSTERESIS_MV for the same K reads we used to set it. + // - If still low when we wake, immediately re-trigger SDS rather than + // spending battery on a doomed boot. + constexpr int LOW_BATT_HYSTERESIS_MV = 100; + const int v = batteryLevel->getBattVoltage(); + if (low_battery_latched) { + if (v > OCV[NUM_OCV_POINTS - 1] + LOW_BATT_HYSTERESIS_MV) { + low_voltage_counter++; + if (low_voltage_counter > 10) { + LOG_INFO("Battery recovered above hysteresis threshold, clear latch"); + low_battery_latched = false; + low_voltage_counter = 0; + } + } else { + low_voltage_counter = 0; + LOG_INFO("Latched low-battery still flat at %dmV, re-enter deep sleep", v); + powerFSM.trigger(EVENT_LOW_BATTERY); + } + } else if (v < OCV[NUM_OCV_POINTS - 1]) { low_voltage_counter++; LOG_DEBUG("Low voltage counter: %d/10", low_voltage_counter); if (low_voltage_counter > 10) { LOG_INFO("Low voltage detected, trigger deep sleep"); + low_battery_latched = true; + low_voltage_counter = 0; powerFSM.trigger(EVENT_LOW_BATTERY); } } else { From 56a117a1112e0d47ee6300431ab6f9d173210c27 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:56:45 +0200 Subject: [PATCH 4/6] sleep: replace assert(0) on stuck preflight with controlled restart waitEnterSleep panicked with assert(0) when an observer vetoed sleep for >30s (FIXME #167). On a field device that turns into a noisy crash log every 30s rather than a clean reboot. Use the same per-arch restart primitives Power::reboot uses, fall back to assert only for archs we don't have a primitive for. --- src/sleep.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/sleep.cpp b/src/sleep.cpp index c4b858f221c..d1fcbff54b7 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -195,7 +195,23 @@ static void waitEnterSleep(bool skipPreflight = false) if (!Throttle::isWithinTimespanMs(now, THIRTY_SECONDS_MS)) { // If we wait too long just report an error and go to sleep RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_SLEEP_ENTER_WAIT); - assert(0); // FIXME - for now we just restart, need to fix bug #167 + // FIXME issue #167: a misbehaving observer can veto sleep forever. + // Restart cleanly rather than panicking — same effective recovery, + // no core-dump pollution, and the firmware can come back up with a + // working state machine instead of triggering the panic handler. + LOG_ERROR("Preflight sleep wait exceeded 30s, restarting"); + console->flush(); +#if defined(ARCH_ESP32) + ESP.restart(); +#elif defined(ARCH_NRF52) + NVIC_SystemReset(); +#elif defined(ARCH_RP2040) + rp2040.reboot(); +#elif defined(ARCH_STM32WL) + HAL_NVIC_SystemReset(); +#else + assert(0); // fallback for archs without a clean restart primitive +#endif break; } } From 1726feafcc9ee280d01f104564a374165779d3a0 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:57:59 +0200 Subject: [PATCH 5/6] power: use BQ25896 ADC for both battery voltage and presence getBattVoltage on the BQ27220+BQ25896 combo returned the fuel-gauge reading while isBatteryConnect derived presence from the BQ25896 charger ADC. The two could disagree on a freshly-attached cell before the fuel gauge had learned: hasBattery would say false but voltage 4.0V, and the upstream PowerFSM would treat the unit as externally powered. Read voltage from the same source as presence so both views move together. --- src/Power.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 40142ff55be..77498809208 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1703,9 +1703,14 @@ class LipoCharger : public HasBatteryLevel } /** - * The raw voltage of the battery in millivolts, or NAN if unknown + * The raw voltage of the battery in millivolts, or NAN if unknown. + * Use the BQ25896 charger ADC for both voltage and presence detection so + * the two readings can never disagree. The BQ27220 fuel gauge can report + * 0 / stale on a freshly-attached cell before its initial learn cycle + * finishes, which would make hasBattery=false but voltage=4.0V — the + * upstream PowerFSM then thinks the device is externally powered. */ - virtual uint16_t getBattVoltage() override { return bq->getVoltage(); } + virtual uint16_t getBattVoltage() override { return PPM->getBattVoltage(); } /** * return true if there is a battery installed in this unit From ab77d4dba2514de46ad9765b867d4a7089707ff8 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 22:51:39 +0200 Subject: [PATCH 6/6] fix(power): address review feedback on #10425 - Scale OCV by NUM_CELLS in the low-battery threshold check. getBattVoltage() returns pack voltage but OCV[] is per-cell, so the comparison was wrong on multi-cell variants (chatter2, station-g1). Scale both the latch and the recovery-hysteresis threshold so behaviour is correct across all NUM_CELLS configurations. - Correct the getBattVoltage docstring on the BQ27220+BQ25896 path: the function returns uint16_t with 0 as the unknown sentinel, not NAN as the comment claimed. - Notify rebootObservers from waitEnterSleep before the platform reset primitive, matching Power::reboot's contract so modules like InkHUD that register a notifyReboot observer get a last-second cleanup pass. --- src/Power.cpp | 16 +++++++++++----- src/sleep.cpp | 3 +++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 77498809208..fa41c694023 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -983,13 +983,19 @@ void Power::readPowerStatus() // - The latch survives SDS via RTC memory so a still-flat cell doesn't // wake-thrash on the timer. // - To clear the latch, voltage has to recover above OCV[min] + - // LOW_BATT_HYSTERESIS_MV for the same K reads we used to set it. + // LOW_BATT_HYSTERESIS_MV (per cell) for the same K reads we used + // to set it. // - If still low when we wake, immediately re-trigger SDS rather than // spending battery on a doomed boot. - constexpr int LOW_BATT_HYSTERESIS_MV = 100; + // OCV[] is per-cell; getBattVoltage() returns pack voltage. Scale by + // NUM_CELLS to keep the comparison consistent with the per-cell + // table on multi-cell variants (e.g. chatter2, station-g1). + constexpr int LOW_BATT_HYSTERESIS_MV_PER_CELL = 100; const int v = batteryLevel->getBattVoltage(); + const int lowThreshold = OCV[NUM_OCV_POINTS - 1] * NUM_CELLS; + const int recoveryThreshold = lowThreshold + LOW_BATT_HYSTERESIS_MV_PER_CELL * NUM_CELLS; if (low_battery_latched) { - if (v > OCV[NUM_OCV_POINTS - 1] + LOW_BATT_HYSTERESIS_MV) { + if (v > recoveryThreshold) { low_voltage_counter++; if (low_voltage_counter > 10) { LOG_INFO("Battery recovered above hysteresis threshold, clear latch"); @@ -1001,7 +1007,7 @@ void Power::readPowerStatus() LOG_INFO("Latched low-battery still flat at %dmV, re-enter deep sleep", v); powerFSM.trigger(EVENT_LOW_BATTERY); } - } else if (v < OCV[NUM_OCV_POINTS - 1]) { + } else if (v < lowThreshold) { low_voltage_counter++; LOG_DEBUG("Low voltage counter: %d/10", low_voltage_counter); if (low_voltage_counter > 10) { @@ -1703,7 +1709,7 @@ class LipoCharger : public HasBatteryLevel } /** - * The raw voltage of the battery in millivolts, or NAN if unknown. + * The raw voltage of the battery in millivolts, or 0 if unknown. * Use the BQ25896 charger ADC for both voltage and presence detection so * the two readings can never disagree. The BQ27220 fuel gauge can report * 0 / stale on a freshly-attached cell before its initial learn cycle diff --git a/src/sleep.cpp b/src/sleep.cpp index d1fcbff54b7..eb1fa784fba 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -200,6 +200,9 @@ static void waitEnterSleep(bool skipPreflight = false) // no core-dump pollution, and the firmware can come back up with a // working state machine instead of triggering the panic handler. LOG_ERROR("Preflight sleep wait exceeded 30s, restarting"); + // Notify reboot observers (e.g. InkHUD) so they can persist / + // shut down cleanly, matching Power::reboot's contract. + notifyReboot.notifyObservers(NULL); console->flush(); #if defined(ARCH_ESP32) ESP.restart();