Files
Brian Pugh 70faa29dac perf: light-sleep idle + refresh downclock — ~3.2x active reading time (X3-measured) (#2525)
## Summary

Reduces active-reading power draw ~3.2x, measured end-to-end on an X3
with a [Nordic
PPK2](https://www.nordicsemi.com/Products/Development-hardware/Power-Profiler-Kit-2)
at the battery terminals (slim build, USB cable removed, 3.8v). Three
independent changes:

1. **Light-sleep between input polls when idle** (the big one): after 1
s of inactivity the main loop replaces its busy `delay(50)` with 50 ms
timer-paced `esp_light_sleep_start()` slices. Poll cadence and input
latency are unchanged — the CPU sleeps between polls instead of spinning
at 10 MHz. Guards: never sleeps while a `HalPowerManager::Lock` is held
(render in flight), WiFi is up, or USB is connected (light sleep kills
the CDC link).
2. **Two-stage idle backoff**: downclock at 500 ms, light sleep at 1 s,
replacing the flat 3 s full-speed window. Renders re-raise the clock via
`Lock` the moment they start, so the full-speed tail only ever served
loop bookkeeping. (The 3 s value in #852 was a placeholder;
responsiveness verified on device against stock.)
3. **Refresh BUSY-wait downclock**: drops the CPU to 10 MHz while
`pollBusy()` polls the BUSY pin (0.3–2 s per refresh), via new optional
hooks in the SDK — **depends on Free-Ink/freeink-sdk/pull/8**. Plus a 1
Hz throttle on the X3's I2C-based USB detection (was polled every loop).

(1+2) reduced average static-page-current from 9.68mA to 3.45mA.
Reducing the battery-monitor polling frequency further reduced it to
2.78mA.

Before merging, we'll need to update the submodule reference.

## Measured results (PPK2, X3)

| Metric | Before | After |
|---|---|---|
| Idle on a static page | 9.68 mA | **2.78 mA** |
| Fast page turn (incl. post-turn behavior) | ~100 mC | **28.98 mC** |
| Post-turn 160 MHz tail | 21.2 mA x 3 s | eliminated |
| Session average @ 30 s/page | ~11.6 mA | **~3.6 mA (~3.2x reading
time)** |
| Deep sleep | 12.8 µA | unchanged |

## Typical-day profiles

Assuming someone spends 30s per page.

- **Light**: 1 h reading, 10 lock/wake cycles, remaining ~23 h deep
sleep
- **Heavy**: 2 h reading, 20 lock/wake cycles, remaining ~22 h deep
sleep
- Per-day cost components: reading 11.6 → 3.6 mA (session avg),
lock+wake cycle
0.113 → 0.099 mAh each (sleep-screen mode), deep sleep 12.8 µA
(unchanged,
  ~0.29 mAh/day)
- Excluded: WiFi sessions, cold chapter indexing, self-discharge

### Days per charge (650mAh)

| Profile | Before | After | Ratio |
|---|---|---|---|
| Light (1 h/day, 10 locks) | 13.0 mAh/day → ~50 days | 4.9 mAh/day →
~133 days | 2.7× |
| Heavy (2 h/day, 20 locks) | 25.7 mAh/day → ~25 days | 9.5 mAh/day →
~68 days | 2.7× |
| Pure active reading | 11.6 mA → ~56 h | 3.6 mA → ~180 h | 3.2× |

### Caveats

- "After" saturates in reality: Li-ion self-discharge (~2–3%/month) is
not modeled
  and becomes the limiter once firmware draw is this low.
- Faster page-turners trend toward ~2.8×, slower readers slightly above
3.2×.
- Frequent locking dilutes the ratio (wake = full cold boot, barely
touched by the
power branch): at 10–20 locks/day, cycles are ~20% of the "after"
budget.
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_

---------
2026-08-12 20:57:45 -04:00

129 lines
5.4 KiB
C++

#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
#include <Logging.h>
#include <freertos/semphr.h>
#include <cassert>
#include "HalGPIO.h"
class HalPowerManager;
extern HalPowerManager powerManager; // Singleton
class HalPowerManager {
int normalFreq = 0; // MHz
bool isLowPower = false;
mutable int _batteryCachedPercent = 0; // Last read battery percentage (0-100)
mutable unsigned long _batteryLastPollMs = 0; // Timestamp of last battery read in milliseconds
enum LockMode { None, NormalSpeed };
LockMode currentLockMode = None;
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
// Bumped by the main loop, read by the render task (see onEinkBusyWaitSlice).
// Single writer, naturally aligned: the 32-bit access is atomic on RISC-V.
volatile uint32_t mainLoopIterations = 0;
// Cap on how long one slice may stay awake waiting for that iteration.
// Productive yields measure ~0.4 ms, so two ticks is ample headroom.
static constexpr unsigned SLICE_YIELD_MAX_TICKS = 2;
// Set while the main loop is blocked in requestUpdateAndWait(). Captured in
// begin(), which Arduino runs on the loop task, so the handle compare below
// ignores any other task that might wait on a render.
TaskHandle_t mainLoopTask = nullptr;
volatile bool mainLoopBlocked = false;
// Serializes wake-source arm -> esp_light_sleep_start() -> disarm across the
// two sleepers (main-loop lightSleep() and the render task's BUSY-wait slice).
// Without it, lightSleep()'s deliberately unsynchronized lock-mode read could
// let both tasks interleave arming/disarming: the loser's disarm-all can strip
// the winner's wake sources and leave it sleeping with none armed.
SemaphoreHandle_t sleepMutex = nullptr;
public:
#if BOARD_HAS_PSRAM
static constexpr int LOW_POWER_FREQ = 80; // MHz
#else
static constexpr int LOW_POWER_FREQ = 10; // MHz
#endif
// Two-stage idle backoff. Renders re-raise the clock via Lock regardless, so the
// full-speed window only needs to cover rapid consecutive input (avoids clock
// thrash); polling stays at 100 Hz until light sleep takes over the cadence.
static constexpr unsigned long IDLE_DOWNCLOCK_MS = 500; // full speed -> LOW_POWER_FREQ
static constexpr unsigned long IDLE_LIGHT_SLEEP_MS = 1000; // 100 Hz polling -> light sleep
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
static constexpr unsigned long LIGHT_SLEEP_SLICE_MS = 50; // ms
// Timer bound for one BUSY-wait light-sleep slice. The refresh end itself
// wakes exactly via GPIO level wake on the BUSY pin; the timer only bounds
// how coarse main-loop button sampling gets during a long refresh.
static constexpr unsigned long BUSY_SLEEP_SLICE_MS = 20; // ms
void begin();
// Control CPU frequency for power saving
void setPowerSaving(bool enabled);
// Setup wake up GPIO and enter deep sleep
// Should be called inside main loop() to handle the currentLockMode
void startDeepSleep(HalGPIO& gpio) const;
// Light-sleep the CPU for LIGHT_SLEEP_SLICE_MS (timer wake; buttons are polled on
// wake at the same cadence as the delay() this replaces). Returns false WITHOUT
// sleeping when unsafe: a performance Lock is held (render in flight), WiFi is
// active, or USB is connected (light sleep kills the CDC link). The caller must
// fall back to delay() in that case.
bool lightSleep(const HalGPIO& gpio) const;
// BUSY-wait slice hook (EpdBus::setBusyWaitSliceHook): light-sleep the chip
// for up to BUSY_SLEEP_SLICE_MS while the panel refreshes autonomously,
// waking early the moment the BUSY pin leaves `busyLevel`. Returns false
// WITHOUT sleeping when unsafe (WiFi active, USB connected, or sleep
// rejected) — the SDK then falls back to its plain poll delay. Called from
// the render task; safe by construction (the waiter IS the locked task).
// Leaves the CPU clock alone: the chip is asleep for most of the wait, so a
// downclock buys nothing and only slows the main loop's awake windows.
bool onEinkBusyWaitSlice(int8_t busyPin, uint8_t busyLevel);
// Call at the end of every main-loop iteration; paces the slice hook's yield.
// Read-then-assign, not ++: C++20 deprecates increment on a volatile lvalue.
void noteMainLoopIteration() { mainLoopIterations = mainLoopIterations + 1; }
// Bracket a blocking wait on the render task (requestUpdateAndWait). While the
// main loop is parked there it cannot poll, so the slice hook skips its yield
// instead of burning the full cap awake once per slice for nothing.
void noteRenderWaitBegin() {
if (xTaskGetCurrentTaskHandle() == mainLoopTask) mainLoopBlocked = true;
}
void noteRenderWaitEnd() {
if (xTaskGetCurrentTaskHandle() == mainLoopTask) mainLoopBlocked = false;
}
// Get battery percentage (range 0-100)
uint16_t getBatteryPercentage() const;
// RAII helper class to manage power saving locks
// Usage: create an instance of Lock in a scope to disable power saving, for example when running a task that needs
// full performance. When the Lock instance is destroyed (goes out of scope), power saving will be re-enabled.
class Lock {
friend class HalPowerManager;
bool valid = false;
public:
explicit Lock();
~Lock();
// Non-copyable and non-movable
Lock(const Lock&) = delete;
Lock& operator=(const Lock&) = delete;
Lock(Lock&&) = delete;
Lock& operator=(Lock&&) = delete;
};
};