-
Notifications
You must be signed in to change notification settings - Fork 0
power
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).
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"]
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) setssoft_reboot_hold— aRTC_NOINIT_ATTR bool, so it survives an ESP32 software reset (esp_restart()) but is garbage after a power-on reset — and callsesp_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()computesresumeProtectedMode = (esp_reset_reason() == ESP_RST_SW) && soft_reboot_hold(main.cpp:352) right afterSerial.begin(). When true, it skips the banner/SPIFFS/LCD-init parts of the normal boot and brings up only whatenterProtectedMode()needs — I2C/SX1509, the wakeup manager, motor driver, RTC driver, accelerometer, and NVS — then callsenterProtectedMode()(main.cpp:468-470) and never proceeds to the rest ofsetup()(audio, WiFi, carousel/mood/web-fetcher tasks never get created for the duration). -
enterProtectedMode()(main.cpp:186-264) does the actual teardown and polling:- 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).
- 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.
- Stops BT and WiFi.
- Clears the RTC alarm flag, retrying up to 5× (same stale-AF retry
pattern as
prepareRtcForShutdown()below) if the alarm is active. - 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. - On any wake source firing, clears
soft_reboot_holdand callsesp_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 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:
- Clear AF.
- 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.
- 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).
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).
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
|
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()— oneread10bitADC1()call, no averaging window) is compared againstBATTERY_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.
Besides the automatic triggers above, shutdown can be requested directly:
-
Menu → Power → Shut Down (
MenuElement::_cbShutDown) — postsPowerCmd::SHUT_DOWNlike the automatic triggers, so it goes through the same docked/undocked branch inloop(). -
Debug CLI
shutdowncommand — currently dead code:#include "debug_cli.h"is commented out inmain.cppandDebugCLIis never instantiated, so this path isn't reachable at runtime as the code stands.
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.
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.