mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-15 15:59:40 -04:00
* fix(power): stop a battery-less board deep-sleeping itself forever The low-battery counter only reset inside its `hasBattery && !hasUSB` guard, so a board with no battery - whose floating divider drifts in and out of the battery-present window - ratcheted the count up across the gaps until it tripped `sds_secs`, which defaults to a ~24.8-day deep sleep. The button could not rescue it either, because `doDeepSleep()` force-holds `BUTTON_PIN` and a held pad ignores `ext1_wakeup_prepare()`'s re-route to RTC; `rtc_gpio_isolate()`'s pin list has the same effect on boards whose button is GPIO 2 or 34. Separately the cutoff now scales by `NUM_CELLS`, without which no multi-cell pack can ever read low enough to shut down at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(power): satisfy trunk check Apply the `ascii-dash` autoformat that `trunk fmt` wants on the comments this PR's file already carries, and rename the no-battery test so its `test_` prefix plus exactly 35 characters stops matching trufflehog's Lob API-key shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
6.2 KiB
C++
147 lines
6.2 KiB
C++
// Unit tests for updateLowVoltageCounter() in src/Power.cpp - the gate on the low-battery deep sleep.
|
|
//
|
|
// Power::readPowerStatus() calls this once per Power thread cycle (20s) with the freshly probed
|
|
// battery state. When it returns true the device takes EVENT_LOW_BATTERY -> stateLowBattSDS ->
|
|
// doDeepSleep(config.power.sds_secs), and sds_secs defaults to UINT32_MAX, so a false positive parks
|
|
// a node for the ~24.8-day clamp with only RST or a power cycle to recover it. That asymmetry is why
|
|
// the counter has to be conservative: a missed shutdown costs a flat battery, a spurious one costs
|
|
// the whole node.
|
|
//
|
|
// The contract is that only *consecutive* confirmed-low readings count. The regression guarded is
|
|
// the original shape, where the reset lived inside the "battery present and not on USB" guard rather
|
|
// than beside it. A board with no battery reads a floating divider that drifts across the 2600mV
|
|
// battery-present threshold, so each excursion into the window bumped the counter and nothing outside
|
|
// the window ever cleared it; eleven such flickers, spread over any span at all, deep-slept a healthy
|
|
// USB-powered node. Reported for Heltec V4 on USB with no battery in meshtastic/firmware#11796.
|
|
//
|
|
// Also pins the cutoff as a pack voltage. readPowerStatus() passes OCV[NUM_OCV_POINTS-1] * NUM_CELLS;
|
|
// it previously passed the bare single-cell OCV point, which no multi-cell pack can fall below, so
|
|
// those boards would have discharged to destruction instead of shutting down.
|
|
#include "Arduino.h"
|
|
#include "TestUtil.h"
|
|
#include <cstdint>
|
|
#include <unity.h>
|
|
|
|
// Declared here rather than via Power.h, which pulls in the ADC and telemetry sensor headers.
|
|
// A signature change breaks the link rather than silently diverging from the definition.
|
|
bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv);
|
|
|
|
// LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN, spelled out so a change to it has to be a deliberate edit here.
|
|
static constexpr uint8_t kReadingsBeforeShutdown = 10;
|
|
|
|
// The default LiIon curve's lowest OCV point, and a single-cell pack comfortably below it.
|
|
static constexpr uint16_t kCutoffMv = 3100;
|
|
static constexpr uint16_t kLowMv = 2900;
|
|
static constexpr uint16_t kHealthyMv = 3900;
|
|
|
|
// One reading of a battery-backed node running off its battery - the only case that may ever count.
|
|
static bool lowReading(uint8_t &counter, uint16_t battMv = kLowMv, uint16_t cutoffMv = kCutoffMv)
|
|
{
|
|
return updateLowVoltageCounter(counter, true, false, battMv, cutoffMv);
|
|
}
|
|
|
|
void setUp(void) {}
|
|
void tearDown(void) {}
|
|
|
|
void test_an_unbroken_run_of_low_readings_shuts_down(void)
|
|
{
|
|
uint8_t counter = 0;
|
|
|
|
for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++)
|
|
TEST_ASSERT_FALSE_MESSAGE(lowReading(counter), "must not fire before the full run is seen");
|
|
|
|
TEST_ASSERT_TRUE(lowReading(counter));
|
|
}
|
|
|
|
void test_a_healthy_reading_clears_the_run(void)
|
|
{
|
|
uint8_t counter = 0;
|
|
for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++)
|
|
lowReading(counter);
|
|
|
|
TEST_ASSERT_FALSE(lowReading(counter, kHealthyMv));
|
|
TEST_ASSERT_EQUAL_UINT8(0, counter);
|
|
TEST_ASSERT_FALSE_MESSAGE(lowReading(counter), "the run restarts from zero, it does not resume");
|
|
}
|
|
|
|
// #11796: the battery-less board. Its floating divider reads "no battery" as often as it reads a
|
|
// phantom one, and it is never on a detectable USB rail, so the gaps are the only thing that can
|
|
// save it. Interleaving them must hold the counter at zero however long this runs.
|
|
void test_no_battery_reading_clears_the_run(void)
|
|
{
|
|
uint8_t counter = 0;
|
|
|
|
for (int cycle = 0; cycle < 50; cycle++) {
|
|
TEST_ASSERT_FALSE(lowReading(counter));
|
|
TEST_ASSERT_FALSE(updateLowVoltageCounter(counter, false, false, kLowMv, kCutoffMv));
|
|
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, counter, "a reading with no battery resets, it does not skip");
|
|
}
|
|
}
|
|
|
|
void test_usb_power_clears_the_run(void)
|
|
{
|
|
uint8_t counter = 0;
|
|
for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++)
|
|
lowReading(counter);
|
|
|
|
TEST_ASSERT_FALSE(updateLowVoltageCounter(counter, true, true, kLowMv, kCutoffMv));
|
|
TEST_ASSERT_EQUAL_UINT8(0, counter);
|
|
}
|
|
|
|
// A pack sitting exactly on the cutoff is not below it; the OCV table's last point is a valid voltage.
|
|
void test_the_cutoff_is_exclusive(void)
|
|
{
|
|
uint8_t counter = 0;
|
|
TEST_ASSERT_FALSE(lowReading(counter, kCutoffMv));
|
|
TEST_ASSERT_EQUAL_UINT8(0, counter);
|
|
|
|
TEST_ASSERT_FALSE(lowReading(counter, kCutoffMv - 1));
|
|
TEST_ASSERT_EQUAL_UINT8(1, counter);
|
|
}
|
|
|
|
// The caller scales by NUM_CELLS. Against the bare single-cell point a 2S pack never reads low at all.
|
|
void test_the_cutoff_is_a_pack_voltage(void)
|
|
{
|
|
constexpr uint16_t twoCellCutoffMv = kCutoffMv * 2;
|
|
constexpr uint16_t flatTwoCellPackMv = 6000;
|
|
|
|
uint8_t counter = 0;
|
|
for (uint8_t i = 0; i <= kReadingsBeforeShutdown; i++)
|
|
TEST_ASSERT_EQUAL(i == kReadingsBeforeShutdown, lowReading(counter, flatTwoCellPackMv, twoCellCutoffMv));
|
|
|
|
counter = 0;
|
|
for (uint8_t i = 0; i <= kReadingsBeforeShutdown; i++)
|
|
TEST_ASSERT_FALSE_MESSAGE(lowReading(counter, flatTwoCellPackMv, kCutoffMv),
|
|
"unscaled cutoff: the regression that never shuts a 2S pack down");
|
|
}
|
|
|
|
// The counter is a uint8_t and the caller keeps calling after it fires, so it must saturate. Were it
|
|
// to wrap, the node would come back up, count to 255 again and re-sleep in an unattended loop.
|
|
void test_the_counter_saturates_rather_than_wrapping(void)
|
|
{
|
|
uint8_t counter = 0;
|
|
|
|
for (int i = 0; i < 400; i++) {
|
|
const bool shutdown = lowReading(counter);
|
|
if (i >= kReadingsBeforeShutdown)
|
|
TEST_ASSERT_TRUE_MESSAGE(shutdown, "once tripped it stays tripped until a reading clears it");
|
|
}
|
|
TEST_ASSERT_EQUAL_UINT8(UINT8_MAX, counter);
|
|
}
|
|
|
|
void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_an_unbroken_run_of_low_readings_shuts_down);
|
|
RUN_TEST(test_a_healthy_reading_clears_the_run);
|
|
RUN_TEST(test_no_battery_reading_clears_the_run);
|
|
RUN_TEST(test_usb_power_clears_the_run);
|
|
RUN_TEST(test_the_cutoff_is_exclusive);
|
|
RUN_TEST(test_the_cutoff_is_a_pack_voltage);
|
|
RUN_TEST(test_the_counter_saturates_rather_than_wrapping);
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|