Skip to main content

Battery and Power Management

The power manager answers three questions honestly: how full is the battery, is it charging, and when must the device stop? "Honestly" is the hard part, and each answer below exists because the naive version lied.

Measuring the battery

A LiPo cell swings 3.0 to 4.2 V; the ADC reads to ~0.9 V full scale usefully, so a 100k/27k divider (wiring) scales it down and the firmware multiplies back up. Three defenses stand between the raw pin and a number anyone believes:

  1. Averaging: 16 samples per reading. The ESP32's ADC is noisy, and WiFi transmit bursts induce tens of millivolts of spray; averaging squeezes that below the resolution the percent curve needs.
  2. Calibration: ESP-IDF's per-chip ADC calibration (burned at the factory) converts counts to millivolts, correcting reference drift between chips.
  3. Plausibility gating: readings outside 2.5 to 4.6 V mean no battery is sensed (bench power, divider unwired), and monitoring disables itself with one log line instead of screaming "0 percent" or, worse, shutting down a USB-powered bench board. This gate was added after exactly that false shutdown; a monitor must know when it is blind.

Voltage to percent

A LiPo discharge curve is not a line: it falls fast from 4.2, plateaus long and flat around 3.7, then cliffs below 3.4. A linear map shows "60 percent" for hours then collapses; users calibrate their trust accordingly, downward. Safi uses a piecewise curve fit to the real discharge shape, monotonic by construction (a unit test asserts monotonicity, because a battery meter that goes up under load is the fastest possible trust destroyer). The result reads pessimistically under heavy load (voltage sags), which is the safe direction to be wrong.

Charging state is not inferred from voltage at all: the TP4056's CHRG pin says it directly (open drain, low while charging) on GPIO17. An earlier revision inferred "charging" from "voltage above threshold", which is astrology; the pin is truth.

The shutdown latch

Below ~3.0 V a LiPo suffers permanent damage, so the device must stop before the protection IC hard-cuts power mid-flash-write. The naive trigger (if voltage < 3.05 shutdown) fires spuriously: a WiFi burst sags a tired battery past any threshold for milliseconds. The shipped logic is a debounced latch, pure and unit-tested:

streak = battery_update_low_streak(volts, charging, sense_valid, streak);
if (battery_should_shutdown(streak)) // streak >= 3 consecutive low reads
initiate_shutdown();

Three consecutive low readings, seconds apart, with the streak reset by a charger connection, recovery above the threshold, or an invalid sense. Transient sags produce streaks of one; a genuinely empty battery walks the streak to three. The function is pure (state in, state out) precisely so the test suite can walk every path without hardware.

Orderly death, then deep sleep

The shutdown path is one of two justified uses of the event bus's synchronous mode:

  1. EVENT_SYSTEM_SHUTDOWN publishes synchronously: every subscriber (chiefly the audio player, which saves the resume position via NVS) completes before the next line runs.
  2. A two-second grace period drains queued flash writes and lets the display show why it is dying.
  3. power_sleep_enter() arms the wake sources and calls esp_deep_sleep_start().

Wake is an ext1 ANY_LOW mask: always the encoder push pin (GPIO15), plus the DS3231 alarm pin (GPIO10) when a schedule wake is wanted. It used to be single-pin ext0; ext1 was the price of letting the RTC wake the device for scheduled playback. The two callers apply opposite policies on purpose:

  • The battery emergency path never arms the RTC alarm. A cell that forced a shutdown at 3.05 V must not be woken to drive a speaker.
  • The user sleep path (POST /api/system/sleep, the dashboard's Sleep button) computes the next enabled schedule and arms alarm 1 before sleeping, so an overnight sleep still delivers the dawn recitation.

Deep sleep draws microamps (the CPU is off; only the RTC domain lives), so a "dead" Safi rests for weeks without harming the cell further. Pressing the knob wakes it into a normal boot; if the battery is still empty, the latch fires again quickly and the device returns to sleep, a cheap and correct loop. On wake with a charged battery, boot proceeds normally and auto-resume, if enabled, continues the recitation that the shutdown interrupted, which closes the loop the whole design serves: power loss should cost the user nothing but time.

Telemetry

battery_get_state() feeds the status bar, GET /api/battery, and WebSocket pushes: voltage, percent, charging flag, and the sense-valid flag, because the dashboard should say "no battery sensed" rather than fake a number, the same honesty rule that runs through this whole chapter.