Files
wadamesh/test/test_pager_power.cpp
Kaj SchittecatandClaude Opus 5.5 67ed48f4fb test: the Pager's charger, gauge, vibration and IMU logic on fake chips
Host tests that compile the production variant files (TLoraPagerBoard.cpp,
PagerHaptics.cpp, PagerImu.cpp from #748-#751) against register-level fakes of
the BQ25896, BQ27220, DRV2605 and BHI260AP (test/pager_hw_fakes/):

- test_pager_power: REG0B decoding (USB host, adapter, power-good alone, OTG not
  counted as USB, the 1 s cache), the temperature guard with its 3 C band, user
  limits and implausible readings, battery care 80/75 %, both holds through the
  one charger switch, charge limits rounded to the chip's 64 mA / 16 mV steps,
  the gauge capacity written once per target (a sealed gauge is left alone on
  later boots), and ship mode refused on USB, read afresh, BATFET_DLY cleared
  and BATFET_DIS set on battery.
- test_pager_haptics: the waveform slots of every pattern and strength, all
  eight slots rewritten, standby only after GO clears, no bus traffic without
  the chip.
- test_pager_imu: one firmware upload per boot at 1 MHz with the bus clock put
  back, scaling to g, stale samples refused, the 3 s idle stop and restart, no
  retry storm after a failed upload.

Each was checked against a mutated copy of its subject (hysteresis removed,
care resume moved, ship mode on USB, OTG as USB, stale waveform tail, standby
mid-pattern, capacity written every boot): all eight mutants fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:04:59 +01:00

224 lines
9.6 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// The T-LoRa Pager's charger (BQ25896) and fuel gauge (BQ27220) decisions, run on
// the production TLoraPagerBoard.cpp against fake chips (test/pager_hw_fakes/):
// the USB / charge-status decoding of REG0B, the temperature guard and battery care
// (both switch the charger through one place, with hysteresis), the charge limits
// rounded to the chip's steps, the gauge capacity written once per target, and the
// ship-mode power-off refused on USB.
//
// Build: g++ -std=c++17 -I test/pager_hw_fakes -I src test/test_pager_power.cpp
#include <cassert>
#include <cstdio>
#include "pager_hw_fakes/Arduino.h"
#include "pager_hw_fakes/Wire.h"
#define TLORA_PAGER 1
#define ESP32 1
#define P_LORA_NSS 36
#define P_LORA_RESET 47
#define P_LORA_DIO_1 14
#define PIN_USER_BTN 0
uint32_t testMillis = 1000;
TestSerial Serial;
TwoWire Wire;
static int s_haptics_begun = 0;
void pagerHapticsBegin() { ++s_haptics_begun; }
#include "../variants/lilygo_tlora_pager/TLoraPagerBoard.cpp"
static void advance(uint32_t ms) { testMillis += ms; }
// A fresh board, charger and gauge, as after a power-on. NVS survives unless wiped.
static void powerOn(TLoraPagerBoard& b, bool wipe_nvs) {
if (wipe_nvs) testNvs().clear();
s_charger = PowersBQ25896{};
b = TLoraPagerBoard{};
advance(5000);
}
static void testBootLimitsAndCapacity() {
TLoraPagerBoard b;
powerOn(b, true);
b.gauge.design = 3000; // as the gauge leaves the factory
b.begin();
assert(s_charger.vreg_mv == 4288 && s_charger.ichg_ma == 704); // LilyGoLib's figures, not the 2 A default
assert(s_charger.chargeEnabled() && s_charger.enable_calls == 1); // a reboot never leaves charging off
assert(b.gauge.capacity_writes == 1 && b.gauge.design == 1500);
assert(testNvs()["pagerhw/cap_try"] == 1500);
// Next boot: the gauge already reads the target, nothing is written.
GaugeBQ27220 kept = b.gauge;
powerOn(b, false);
b.gauge = kept; b.gauge.capacity_writes = 0;
b.begin();
assert(b.gauge.capacity_writes == 0);
// A sealed gauge that refuses: tried once, then left alone on later boots.
powerOn(b, true);
b.gauge.design = 3000; b.gauge.accept_capacity = false;
b.begin();
assert(b.gauge.capacity_writes == 1);
for (int boot = 0; boot < 3; ++boot) {
GaugeBQ27220 g = b.gauge; g.capacity_writes = 0;
powerOn(b, false);
b.gauge = g;
b.begin();
assert(b.gauge.capacity_writes == 0);
}
// The user picks another pack: a new target, so it is tried again.
b.gauge.accept_capacity = true;
assert(b.setBatteryCapacity(2000));
assert(b.gauge.design == 2000 && b.batteryCapacitySetting() == 2000);
assert(!b.setBatteryCapacity(100) && !b.setBatteryCapacity(30000)); // out of range: refused
assert(b.batteryCapacitySetting() == 2000);
// Charger not answering: no limits, ship mode refused, power state unknown.
powerOn(b, true);
s_charger.present = false;
b.begin();
assert(b.externalPowerPresent() == -1);
assert(b.chargeState() == TLoraPagerBoard::ChargeState::Unknown);
assert(!b.enterShipMode() && s_charger.shutdown_calls == 0);
}
static void testUsbDecoding() {
TLoraPagerBoard b;
powerOn(b, true);
b.begin();
struct { uint8_t reg0b; int ext; int chg; } cases[] = {
{ 0x00, 0, 0 }, // nothing in
{ 0x20 | 0x04, 1, 0 }, // USB host, power good
{ 0x40 | 0x04 | 0x10, 1, 2 }, // adapter, fast charging
{ 0x40 | 0x04 | 0x18, 1, 3 }, // adapter, charge done
{ 0x04, 1, 0 }, // power good alone counts
{ 0xE0, 0, 0 }, // OTG: the Pager is the source, not on USB
{ 0x20, 1, 0 }, // VBUS seen, PG not yet
};
for (auto& c : cases) {
s_charger.reg[0x0B] = c.reg0b;
advance(1000); // past the 1 s cache
assert(b.externalPowerPresent() == c.ext);
assert((int)b.chargeState() == c.chg);
}
// Cached for a second: a change inside that window is seen after it.
s_charger.reg[0x0B] = 0x00; advance(1000); assert(b.externalPowerPresent() == 0);
s_charger.reg[0x0B] = 0x24; advance(400); assert(b.externalPowerPresent() == 0);
advance(700); assert(b.externalPowerPresent() == 1);
}
// Feed one gauge reading through the 2 s refresh, as the battery callers do.
static void gaugeAt(TLoraPagerBoard& b, float temp_c, int soc) {
b.gauge.temp_c = temp_c;
b.gauge.soc = (uint16_t)soc;
advance(2000);
(void)b.getGaugeInfo();
}
static void testTemperatureGuard() {
TLoraPagerBoard b;
powerOn(b, true);
b.begin();
const int en0 = s_charger.enable_calls;
gaugeAt(b, 44.9f, 50); assert(b.chargeTempBlock() == 0 && s_charger.chargeEnabled());
gaugeAt(b, 45.2f, 50); assert(b.chargeTempBlock() == 1 && !s_charger.chargeEnabled());
assert(s_charger.disable_calls == 1);
gaugeAt(b, 46.0f, 50); assert(s_charger.disable_calls == 1); // no repeat writes
gaugeAt(b, 43.0f, 50); assert(b.chargeTempBlock() == 1); // inside the 3 C band: still off
gaugeAt(b, 42.1f, 50); assert(b.chargeTempBlock() == 1);
gaugeAt(b, 42.0f, 50); assert(b.chargeTempBlock() == 0 && s_charger.chargeEnabled());
assert(s_charger.enable_calls == en0 + 1);
// Cold: below 0 off, back on from 3 C.
gaugeAt(b, -0.5f, 50); assert(b.chargeTempBlock() == -1 && !s_charger.chargeEnabled());
gaugeAt(b, 2.9f, 50); assert(b.chargeTempBlock() == -1);
gaugeAt(b, 3.0f, 50); assert(b.chargeTempBlock() == 0 && s_charger.chargeEnabled());
// Implausible readings change nothing, either way.
gaugeAt(b, 50.0f, 50); assert(b.chargeTempBlock() == 1);
gaugeAt(b, -273.1f, 50); assert(b.chargeTempBlock() == 1); // a gauge read as 0 K
gaugeAt(b, 120.0f, 50); assert(b.chargeTempBlock() == 1);
gaugeAt(b, 30.0f, 50); assert(b.chargeTempBlock() == 0);
// The user's limits: 50 C upper, 5 C lower; and the guard off.
b.setChargeTempMax(500); b.setChargeTempMin(50);
gaugeAt(b, 49.0f, 50); assert(b.chargeTempBlock() == 0);
gaugeAt(b, 50.5f, 50); assert(b.chargeTempBlock() == 1);
gaugeAt(b, 47.0f, 50); assert(b.chargeTempBlock() == 0);
gaugeAt(b, 4.0f, 50); assert(b.chargeTempBlock() == -1);
b.setChargeTempMin(-100); // never below freezing
gaugeAt(b, 8.5f, 50); assert(b.chargeTempBlock() == 0);
gaugeAt(b, -1.0f, 50); assert(b.chargeTempBlock() == -1);
b.setChargeTempMax(0); // guard off: always allowed
assert(b.chargeTempBlock() == 0 && s_charger.chargeEnabled());
gaugeAt(b, 60.0f, 50); assert(b.chargeTempBlock() == 0);
// A gauge that stops answering marks the reading invalid, keeps the last SOC.
b.gauge.answers = false; advance(2000);
assert(!b.getGaugeInfo().valid && b.getBattStateOfCharge() == 50);
}
static void testBatteryCare() {
TLoraPagerBoard b;
powerOn(b, true);
b.begin();
b.setChargeCare(true);
gaugeAt(b, 25.0f, 79); assert(!b.chargeCareHold() && s_charger.chargeEnabled());
gaugeAt(b, 25.0f, 80); assert(b.chargeCareHold() && !s_charger.chargeEnabled());
gaugeAt(b, 25.0f, 76); assert(b.chargeCareHold());
gaugeAt(b, 25.0f, 75); assert(b.chargeCareHold());
gaugeAt(b, 25.0f, 74); assert(!b.chargeCareHold() && s_charger.chargeEnabled());
// Both holds: charging only comes back when neither holds it off.
gaugeAt(b, 25.0f, 85); assert(!s_charger.chargeEnabled());
gaugeAt(b, 46.0f, 85); assert(b.chargeTempBlock() == 1 && b.chargeCareHold());
b.setChargeCare(false);
assert(!b.chargeCareHold() && !s_charger.chargeEnabled()); // still too hot
gaugeAt(b, 40.0f, 85); assert(s_charger.chargeEnabled());
// Care on with no SOC yet: no decision.
powerOn(b, true);
b.begin();
b.setChargeCare(true);
gaugeAt(b, 25.0f, 255); // out of range: SOC stays unknown
assert(b.getBattStateOfCharge() == -1);
assert(!b.chargeCareHold() && s_charger.chargeEnabled());
}
static void testChargeLimits() {
TLoraPagerBoard b;
powerOn(b, true);
b.begin();
b.setChargeLimits(1000, 4300); assert(s_charger.ichg_ma == 960 && s_charger.vreg_mv == 4288);
b.setChargeLimits(1536, 4352); assert(s_charger.ichg_ma == 1536 && s_charger.vreg_mv == 4352);
b.setChargeLimits(10, 3000); assert(s_charger.ichg_ma == 64 && s_charger.vreg_mv == 3840);
for (uint16_t ma : { 256, 512, 704, 1024, 1536 }) { b.setChargeLimits(ma, 4288); assert(s_charger.ichg_ma == ma); }
for (uint16_t mv : { 4096, 4208, 4288, 4352 }) { b.setChargeLimits(704, mv); assert(s_charger.vreg_mv == mv); }
}
static void testShipMode() {
TLoraPagerBoard b;
powerOn(b, true);
b.begin();
// On USB: refused, the battery switch is not touched (the caller deep-sleeps).
s_charger.reg[0x0B] = 0x44;
assert(!b.enterShipMode());
assert(s_charger.shutdown_calls == 0 && !(s_charger.reg[0x09] & 0x20));
// The cache must not hide a plug-in that just happened: enterShipMode reads afresh.
s_charger.reg[0x0B] = 0x00; advance(1000); assert(b.externalPowerPresent() == 0);
s_charger.reg[0x0B] = 0x44; // plugged in 10 ms before the power-off
advance(10);
assert(!b.enterShipMode() && s_charger.shutdown_calls == 0);
// On battery: BATFET_DLY cleared (open at once), BATFET_DIS set, then it waits for
// the power to go and, still running, hands back to the deep-sleep fallback.
s_charger.reg[0x0B] = 0x00;
s_charger.reg[0x09] = 0x08;
const uint32_t t0 = testMillis;
assert(!b.enterShipMode());
assert(s_charger.shutdown_calls == 1);
assert((s_charger.reg[0x09] & 0x20) && !(s_charger.reg[0x09] & 0x08));
assert(testMillis - t0 >= 2000);
}
int main() {
testBootLimitsAndCapacity();
testUsbDecoding();
testTemperatureGuard();
testBatteryCare();
testChargeLimits();
testShipMode();
std::printf("test_pager_power: OK\n");
return 0;
}