Skip to content
Wouter Van de Wiele edited this page Aug 6, 2026 · 2 revisions

Power Management

ROCAT does not use ESP32 deep sleep in normal operation. Instead the whole board is behind a hardware power latch: SX1509 pin 14 (pin_mcu_keep_awake) is driven HIGH at boot and keeps the regulators on; driving it LOW cuts power entirely. Wake-up is a hardware OR of three sources into the keep-alive circuit:

Wake source Signal Notes
Power button joystick center (GPIO 36 path) always armed
RTC alarm PCF8523 INT1 (GPIO 12, active-low) armed whenever the alarm is enabled — this is what powers the device on for the daily alarm
Accelerometer LIS3DH INT1 (GPIO 2) armed at shutdown only if Wake/Move is enabled — a tap/knock (click interrupt, ~500 mg, ≤100 ms) powers the device on

PowerManagerDriver::begin() samples the two interrupt pins before clearing latches to classify the wake source (BUTTON / ACC_INT / RTC_INT).

Shutdown flow

Every producer funnels through the blackboard the same way — some producer posts PowerCmd::SHUT_DOWN, and loop() consumes it — but what happens next splits on dock state, because the hardware latch can only actually cut power when the board is running off the battery alone. A USB/dock charger's power path backfeeds the 3.3 V rail independent of the latch, so a docked shutdown instead reboots into a minimal-init protected mode that tears down peripherals and polls the wake sources directly (see below). The two low-battery paths bypass the blackboard entirely and call wakeup.shut_down() straight from setup()/loop().

flowchart TD
    subgraph Producers
        M[Menu → Power → Shut Down]
        IL[Battery/dock idle timeout]
        PO[Power-off Timer countdown]
    end
    M & IL & PO --> CMD[PowerCmd::SHUT_DOWN on blackboard]
    CMD --> F[flush debounced playback-state save]
    F --> D{"isDocked(): charging or standby?"}
    D -- yes --> PSD[scheduleProtectedShutdown]
    D -- no --> R[prepareRtcForShutdown]
    R --> SD["wakeup.shut_down(accel_wake_enabled)"]

    LB1[Low battery at boot] -->|direct call, unconditional| SD2["wakeup.shut_down()"]
    LB2[Low battery at runtime, every 10s] -->|direct call, unconditional| SD2

    SD --> LATCH["disarm/rearm accel INT1, drive keep-awake LOW"]
    SD2 --> LATCH
    LATCH --> CHK{still executing?}
    CHK -- no: power actually cut --> OFF[("board off")]
    CHK -- yes: dock/USB backfeeds rail --> PSD

    PSD --> RESTART["soft_reboot_hold = true; esp_restart()"]
    RESTART --> BOOT["setup(): resumeProtectedMode == true"]
    BOOT --> EPM["enterProtectedMode: tear down all peripherals"]
    EPM --> POLL{"poll button / RTC INT1 / accel INT1, light-sleep between polls"}
    POLL -- one fires --> REBOOT2["soft_reboot_hold = false; esp_restart() -> normal boot"]
Loading

Protected mode: shutdown while docked

The power latch cannot cut board power while a charger is attached (see isDocked(), main.cpp), so a dock-time PowerCmd::SHUT_DOWN — and the latch-based path itself, if it discovers execution is still continuing after releasing the latch — falls back to a software-reboot-based standby instead. This is the biggest structural piece the shutdown flow above doesn't show at a glance, so it's worth spelling out end to end (all in src/main.cpp unless noted):

  • scheduleProtectedShutdown() (main.cpp:276-281) sets soft_reboot_hold — a RTC_NOINIT_ATTR bool, so it survives an ESP32 software reset (esp_restart()) but is garbage after a power-on reset — and calls esp_restart() immediately. It never returns; whatever state the motor/LEDs/display/audio are in at the moment of the call persists unchanged through the reset, since a software reset doesn't wipe RAM. PowerManagerDriver::shut_down() calls this same function as its own fallback (lib/PowerManagerDriver/power_manager_driver.cpp:94-95) when it finds itself still executing after driving the latch LOW — proof the latch didn't cut power, e.g. because the dock/USB path was backfeeding the rail.
  • On the next boot, setup() computes resumeProtectedMode = (esp_reset_reason() == ESP_RST_SW) && soft_reboot_hold (main.cpp:352) right after Serial.begin(). When true, it skips the banner/SPIFFS/LCD-init parts of the normal boot and brings up only what enterProtectedMode() needs — I2C/SX1509, the wakeup manager, motor driver, RTC driver, accelerometer, and NVS — then calls enterProtectedMode() (main.cpp:468-470) and never proceeds to the rest of setup() (audio, WiFi, carousel/mood/web-fetcher tasks never get created for the duration).
  • enterProtectedMode() (main.cpp:186-264) does the actual teardown and polling:
    1. Reads "wake on accel" and "alarm active" straight from NVS/the RTC chip (the Blackboard's own NVS-load never runs on this minimal-init path).
    2. Stops radio/sound, disables the motor, turns LEDs off, turns the backlight and display off, disables the accelerometer's normal interrupt and — if accel-wake is enabled — re-arms a less-sensitive motion interrupt (300 mg/100 ms, vs. the mood manager's normal 200 mg/40 ms) so residual hand contact from just shutting the menu down doesn't immediately re-trigger it.
    3. Stops BT and WiFi.
    4. Clears the RTC alarm flag, retrying up to 5× (same stale-AF retry pattern as prepareRtcForShutdown() below) if the alarm is active.
    5. Loops: checks RTC INT1 (HIGH = asserted, only if the alarm is active), accel INT1 (LOW = asserted, only if accel-wake is enabled), and the joystick-center ADC reading directly; between checks it calls esp_sleep_enable_timer_wakeup(30000) + esp_light_sleep_start() (30 ms ticks) to save power while parked.
    6. On any wake source firing, clears soft_reboot_hold and calls esp_restart() — the next boot proceeds through the normal (non- minimal) setup() path, exactly like a real cold boot; nothing needs to "undo" the teardown since it never ran on the fresh boot.

Net effect: a docked shutdown looks like the device power-cycling itself repeatedly (rebooting into protected mode, then rebooting again out of it on wake) rather than a true power-off, because true power-off isn't physically possible while docked.

The RTC alarm-minute trap (prepareRtcForShutdown)

The PCF8523 re-evaluates its alarm match on every second tick, so during the alarm minute the AF flag (and INT1, with AIE=1) re-asserts within a second of being cleared. Since INT1 is hardware-ORed into the keep-alive circuit, releasing the latch while INT1 is high (asserted — INT1 passes through an inverting level shifter, so HIGH is the asserted level at the ESP32 GPIO) simply doesn't power off. AIE must stay enabled (it's what wakes the device at the next alarm), so instead:

  1. Clear AF.
  2. If we're currently inside the alarm minute, show a "SHUTTING DOWN" splash and busy-wait (clearing AF each 500 ms) until the minute rolls over.
  3. Re-clear AF up to 5×; warn if INT1 still reads HIGH.

This path only runs for the non-docked (true-latch) branch; protected mode does its own equivalent stale-AF retry inline (see above).

Alarm firing (while running)

The same AF-retriggering quirk is why the running system does not use the AF flag to detect the alarm. loop()'s 1 Hz RTC block compares wall time to the alarm time with a per-minute latch and sets Blackboard.alarm_ringing; the carousel then overlays the alarm screen (see GUI & Carousel).

Automatic shutdown triggers

Idle sleep is driven by CarouselManager::_updateIdleSleep(), which tracks a single _lastActivity timestamp (reset by any button/touch input and by charger attach/detach) rather than carousel lap counts:

Trigger Condition Configuration
Idle sleep (on battery) idle ≥ 15 min — extended to 4 h while the web radio is playing (audio_playing), so a battery-powered stream isn't cut off just because no buttons are touched. Backlight turns off at 5 min idle regardless. implicit; disabled while Backlight (Settings page) is set (it keeps refreshing the activity timestamp)
Stream stop (docked) idle ≥ ~3 h 55 min and radio playing → stream stops (device itself stays on; backlight only dims at 5 min idle while docked) implicit, docked-only
Docked-and-forgotten shutdown idle ≥ 8 h while docked → full PowerCmd::SHUT_DOWN, routed through the protected-mode path above since the latch can't cut docked power DOCK_SLEEP_MS in carousel_manager.cpp
Power-off timer fixed countdown from selection; activity does not cancel it menu Power → Timer: None/15/30/45/60/90/120 min (RAM-only, cleared on boot)
Low battery (runtime) battery 2 s-window average < 3200 mV and not docked, checked every 10 s thresholds in main.cpp
Low battery (boot) see below thresholds in main.cpp

Low-battery protection

There is no cross-boot memory of a low-battery shutdown — no NVS flag persists one boot to the next. Both checks below share one stateless function, shutDownForLowBattery() (main.cpp:287-306); its own comment notes there's "no NVS latch to remember a prior one... every critically-low-battery shutdown is its own independent decision now."

  • Boot-time check (setup(), main.cpp:475-480): once the battery driver is up, a single instantaneous ADC read (battery.get_voltage() — one read10bitADC1() call, no averaging window) is compared against BATTERY_SHUTDOWN_MV = 3200.0f, gated on !isDocked() (charging or standby/charge-complete both skip the check, not just active charging).
  • Runtime check (loop(), main.cpp:755-763): every 10 s, battery.get_adc_stats_2s() (the 2 s rolling-window average — the finest-grained tier the battery driver keeps, see below) is compared against the same 3200 mV floor, with the same !isDocked() gate.

Either path calling shutDownForLowBattery(voltageMv) does the same thing: disable the RTC alarm, clear the "wake on accel" NVS setting (so neither source can wake the device back up onto a battery that's still critically low), pause the carousel, show "BATTERY LOW / CHARGE ME NOW" for 5 s, then call wakeup.shut_down() — which folds back into the docked/undocked split described above (it can still end up in protected mode if docked or if the latch fails to cut power).

The status-bar battery icon has its own, separate low-battery threshold (BATTERY_LOW_MV = 3400.0f in carousel_manager.cpp) purely for the visual warning marker — it used to compare that millivolt constant against volts and so never lit (see hardware#battery-sensing for that fix); it's now correctly millivolts-vs-millivolts and, since 3400 mV is above the 3200 mV shutdown floor, the icon warns before the device actually shuts itself down.

Manual shutdown paths

Besides the automatic triggers above, shutdown can be requested directly:

  • Menu → Power → Shut Down (MenuElement::_cbShutDown) — posts PowerCmd::SHUT_DOWN like the automatic triggers, so it goes through the same docked/undocked branch in loop().
  • Debug CLI shutdown command — currently dead code: #include "debug_cli.h" is commented out in main.cpp and DebugCLI is never instantiated, so this path isn't reachable at runtime as the code stands.

Battery monitoring pipeline

BatteryDriver::poll() is called every loop() iteration and is internally throttled to the LIS3DH ADC1 refresh (~20 ms @ 50 Hz ODR, gated by the data-ready bit):

100 samples (~2 s) → base window {min,max,avg}
30 base windows    → 1 min tier
5 × 1 min          → 5 min tier

That's the full cascade — 2 s base, 1 min, and 5 min, three tiers total; there is no 15 min/1 h/2 h rollup. Each tier reports the previous completed window (a periodic cascade, not a sliding window — the 5 min stats refresh once every 5 minutes). All values are published to the blackboard at 5 Hz and printed as a diagnostics table (2s/1min/5min rows only) on serial every 30 s. Tiers are sample-count-based, so wall-clock duration drifts slightly under I2C contention — fine for trend monitoring.

Charger status pins are cached and re-read at most once per second to avoid hammering I2C for signals that change rarely.

The unused ULP deep-sleep path

lib/DEEP_SLEEP/sleep.cpp contains a complete ULP-coprocessor alternative: setup_deep_sleep() stops WiFi/BT, loads a tiny ULP program that polls three RTC GPIOs (GPIO 36 = joystick, GPIO 12 = RTC INT1, GPIO 2 = accel INT1) and issues I_WAKE() when any goes low (M_BL(2,1); swap to M_BGE for active-high pins), then enters deep sleep. recover_deep_sleep() logs the wake reason. main.cpp currently never calls it — the power latch replaced it — but it remains available for a future low-power mode where RAM/RTC state must survive.

Clone this wiki locally