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

176 lines
7.3 KiB
C++

#pragma once
#include <Arduino.h>
#include <InputManager.h>
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
#define EPD_SCLK 8 // SPI Clock
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
#define EPD_CS 21 // Chip Select
#define EPD_DC 4 // Data/Command
#define EPD_RST 5 // Reset
#define EPD_BUSY 6 // Busy
#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out)
#define BAT_GPIO0 0 // Battery voltage
#define UART0_RXD 20 // Used for USB connection detection
// Xteink X3 Hardware
#define X3_I2C_SDA 20
#define X3_I2C_SCL 0
#define X3_I2C_FREQ 400000
// TI BQ27220 Fuel gauge I2C
#define I2C_ADDR_BQ27220 0x55 // Fuel gauge I2C address
#define BQ27220_SOC_REG 0x2C // StateOfCharge() command code (%)
#define BQ27220_CUR_REG 0x0C // Current() command code (signed mA)
#define BQ27220_VOLT_REG 0x08 // Voltage() command code (mV)
// Analog DS3231 RTC I2C
#define I2C_ADDR_DS3231 0x68 // RTC I2C address
#define DS3231_SEC_REG 0x00 // Seconds command code (BCD)
// QST QMI8658 IMU I2C
#define I2C_ADDR_QMI8658 0x6B // IMU I2C address
#define I2C_ADDR_QMI8658_ALT 0x6A // IMU I2C fallback address
#define QMI8658_WHO_AM_I_REG 0x00 // WHO_AM_I command code
#define QMI8658_WHO_AM_I_VALUE 0x05 // WHO_AM_I expected value
class HalGPIO {
#if CROSSPOINT_EMULATED == 0
InputManager inputMgr;
#endif
bool lastUsbConnected = false;
bool usbStateChanged = false;
unsigned long usbLastPollMs = 0;
bool usbElectricalConnected = false; // last result of the per-device electrical/charge check
// X3 USB detection is a BQ27220 I2C read (~0.3-1 ms of awake CPU per call);
// polled every loop it costs a few percent of the light-sleep idle floor for
// nothing. At >=1 s intervals the energy cost is unmeasurable (~µC/s), so 1 s
// is chosen for prompt plug/unplug UX (battery icon, light-sleep USB guard /
// CDC recovery). X4 detection is a single digitalRead and stays per-loop.
static constexpr unsigned long USB_POLL_X3_MS = 1000;
// USB-Serial-JTAG SOF activity, sampled by update(): the host sends a SOF
// frame every 1 ms while the bus is enumerated, so a frame index that moved
// between two samples means a live host link. Catches what the charge-based
// X3 check misses: a data-only cable, and any cable once the battery is full
// (charge current ~0). Samples must be >SOF_SAMPLE_MS apart — update() can be
// called back-to-back (inner input loops), and adjacent reads would compare
// equal and flicker the verdict.
uint16_t lastSofFrameIndex = 0;
unsigned long sofLastSampleMs = 0;
bool usbSofActive = false;
static constexpr unsigned long SOF_SAMPLE_MS = 10;
// Per-device electrical/charge-inference USB check (fresh read; X3 = BQ27220
// charge current over I2C, X4 = VBUS-driven level on GPIO20).
bool isUsbElectricalConnected() const;
// Shared body of update()/pollUsbState(): SOF sampling + throttled electrical
// check + combined-verdict edge tracking.
void updateUsbState(unsigned long now);
public:
enum class DeviceType : uint8_t { X4, X3 };
private:
DeviceType _deviceType = DeviceType::X4;
public:
HalGPIO() = default;
// Inline device type helpers for cleaner downstream checks
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
bool isXteinkDevice() const;
// True when the board's page buttons sit on the left/right screen edges
// (X3, X4 Pro) rather than an off-screen vertical rocker. Drives side-hint
// placement and the flipped large-step direction in selection activities.
// Keyed off the active BoardConfig profile, not the X3/X4 runtime detection.
bool hasEdgeSideButtons() const;
// Start button GPIO and setup SPI for screen and SD card
void begin();
// Button input methods
void update();
bool isPressed(uint8_t buttonIndex) const;
bool wasPressed(uint8_t buttonIndex) const;
bool wasAnyPressed() const;
bool wasReleased(uint8_t buttonIndex) const;
bool wasAnyReleased() const;
// True while a raw button-state change is still inside the debounce window.
// The idle loop polls fast while this is set so the confirming sample lands
// ~10 ms after the first; at the 50 ms light-sleep cadence a short tap can
// otherwise appear in a single sample and never commit (dropped press).
bool isDebouncePending() const;
unsigned long getHeldTime() const;
unsigned long getPowerButtonHeldTime() const;
bool hasTouch() const;
bool wasTouchTap(float& nx, float& ny) const;
bool wasTouchDown(float& nx, float& ny) const;
// Raw release edge, reported even when the contact was not a tap (swipe end,
// drag-off). Snapshot builders forward it so interaction routing can clear
// pressed state.
bool wasTouchReleased() const;
bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const;
bool isTouchHeldAt(float& nx, float& ny) const;
// One-shot long-press, fired by the SDK classifier while the finger is still
// down (stationary contact held past its threshold). Position = touch-down
// point. Callers that act on it should suppressTouchContact() so the lift
// cannot also tap.
bool wasTouchLongPress(float& nx, float& ny) const;
// Ignore the remainder of the current contact (its continued hold and its
// release edge). Self-clears once the contact ends.
void suppressTouchContact();
unsigned long lastTouchHeldMs() const;
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
bool wasTouchActivity() const;
void setSharedConfirmPowerShortPressEmitsPower(bool enabled);
// Verify power button was held long enough after wakeup.
// Returns true if verification succeeded, false if device should return to sleep.
// Should only be called when wakeup reason is PowerButton.
bool verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed);
// Check if USB is connected
bool isUsbConnected() const;
// Sample USB state without a full input update. Called during setup() BEFORE
// the first e-ink refresh: the boot paint happens before loop() ever runs
// update(), so without this the light-sleep slice guards see the unsampled
// default ("no USB") and sleeping through the boot refresh kills a live CDC
// link (charge-based X3 detection also reads false whenever the battery is
// full). Two calls >=SOF_SAMPLE_MS apart establish the SOF verdict; the
// method itself waits out the floor if called too soon after the last sample.
void pollUsbState();
// USB state as sampled by the last update() call.
// Prefer this in per-loop polling: isUsbConnected() performs a fresh I2C read on X3.
bool isUsbConnectedCached() const { return lastUsbConnected; }
// Returns true once per edge (plug or unplug) since the last update()
bool wasUsbStateChanged() const;
enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
WakeupReason getWakeupReason() const;
// Button indices
static constexpr uint8_t BTN_BACK = 0;
static constexpr uint8_t BTN_CONFIRM = 1;
static constexpr uint8_t BTN_LEFT = 2;
static constexpr uint8_t BTN_RIGHT = 3;
static constexpr uint8_t BTN_UP = 4;
static constexpr uint8_t BTN_DOWN = 5;
static constexpr uint8_t BTN_POWER = 6;
};
extern HalGPIO gpio;