From d4ecb44fb7e4f2195066d79f700e8ce4b992d687 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] 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 | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Power.cpp b/src/Power.cpp index bb9f554be40..9a2897b748b 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,41 @@ 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 (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. + // 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 > recoveryThreshold) { + 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 < lowThreshold) { 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 {