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

329 lines
12 KiB
C++

#include <HalGPIO.h>
#include <Logging.h>
#include <PowerManager.h>
#include <Preferences.h>
#include <SPI.h>
#include <Wire.h>
#include <XteinkDetect.h>
#include <esp_sleep.h>
#include <soc/usb_serial_jtag_struct.h>
// Global HalGPIO instance
HalGPIO gpio;
namespace X3GPIO {
bool readI2CReg16LE(uint8_t addr, uint8_t reg, uint16_t* outValue) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
if (Wire.requestFrom(addr, static_cast<uint8_t>(2), static_cast<uint8_t>(true)) < 2) {
while (Wire.available()) {
Wire.read();
}
return false;
}
const uint8_t lo = Wire.read();
const uint8_t hi = Wire.read();
*outValue = (static_cast<uint16_t>(hi) << 8) | lo;
return true;
}
bool readBQ27220CurrentMA(int16_t* outCurrent) {
uint16_t raw = 0;
if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_CUR_REG, &raw)) {
return false;
}
*outCurrent = static_cast<int16_t>(raw);
return true;
}
} // namespace X3GPIO
namespace {
constexpr char HW_NAMESPACE[] = "cphw";
constexpr char NVS_KEY_DEV_OVERRIDE[] = "dev_ovr"; // 0=auto, 1=x4, 2=x3
constexpr char NVS_KEY_DEV_CACHED[] = "dev_det"; // 0=unknown, 1=x4, 2=x3
enum class NvsDeviceValue : uint8_t { Unknown = 0, X4 = 1, X3 = 2 };
NvsDeviceValue readNvsDeviceValue(const char* key, NvsDeviceValue defaultValue) {
Preferences prefs;
if (!prefs.begin(HW_NAMESPACE, true)) {
return defaultValue;
}
const uint8_t raw = prefs.getUChar(key, static_cast<uint8_t>(defaultValue));
prefs.end();
if (raw > static_cast<uint8_t>(NvsDeviceValue::X3)) {
return defaultValue;
}
return static_cast<NvsDeviceValue>(raw);
}
void writeNvsDeviceValue(const char* key, NvsDeviceValue value) {
Preferences prefs;
if (!prefs.begin(HW_NAMESPACE, false)) {
return;
}
prefs.putUChar(key, static_cast<uint8_t>(value));
prefs.end();
}
HalGPIO::DeviceType nvsToDeviceType(NvsDeviceValue value) {
return value == NvsDeviceValue::X3 ? HalGPIO::DeviceType::X3 : HalGPIO::DeviceType::X4;
}
HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
// Explicit override for recovery/support:
// 0 = auto, 1 = force X4, 2 = force X3
const NvsDeviceValue overrideValue = readNvsDeviceValue(NVS_KEY_DEV_OVERRIDE, NvsDeviceValue::Unknown);
if (overrideValue == NvsDeviceValue::X3 || overrideValue == NvsDeviceValue::X4) {
LOG_INF("HW", "Device override active: %s", overrideValue == NvsDeviceValue::X3 ? "X3" : "X4");
return nvsToDeviceType(overrideValue);
}
const NvsDeviceValue cachedValue = readNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::Unknown);
if (cachedValue == NvsDeviceValue::X3 || cachedValue == NvsDeviceValue::X4) {
LOG_INF("HW", "Using cached device type: %s", cachedValue == NvsDeviceValue::X3 ? "X3" : "X4");
return nvsToDeviceType(cachedValue);
}
// No cache yet: use FreeInk's canonical two-pass X3 fingerprint and persist
// only confirmed results. Inconclusive probes deliberately remain uncached.
uint8_t score1 = 0;
uint8_t score2 = 0;
const freeink::XteinkVerdict verdict = freeink::detectXteinkVerdict(&score1, &score2);
LOG_INF("HW", "Xteink probe scores: pass1=%u pass2=%u verdict=%u", score1, score2, static_cast<unsigned>(verdict));
if (verdict == freeink::XteinkVerdict::X3Confirmed) {
writeNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::X3);
return HalGPIO::DeviceType::X3;
}
if (verdict == freeink::XteinkVerdict::X4Confirmed) {
writeNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::X4);
return HalGPIO::DeviceType::X4;
}
// Conservative fallback for first boot with inconclusive probes.
return HalGPIO::DeviceType::X4;
}
} // namespace
void HalGPIO::begin() {
#if FREEINK_MCU_C3
_deviceType = detectDeviceTypeWithFingerprint();
BoardConfig::selectDevice(deviceIsX3() ? BoardConfig::Board::XteinkX3 : BoardConfig::Board::XteinkX4);
// Resolve the per-batch controller before SPI owns the display pins. FreeInk
// checks the OEM hw_calib/screenType value first, then falls back to its
// two-pass display-bus probe. X3's facade keys panel selection off the sibling
// board profile, so preserve a detected UC8279 through setDisplayX3().
freeink::applyXteinkDisplayController();
if (deviceIsX3() && BoardConfig::ACTIVE.displayController == BoardConfig::DisplayController::UC8279) {
BoardConfig::selectDevice(BoardConfig::Board::XteinkX3Uc8279);
}
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
if (deviceIsX4()) {
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT);
}
#else
_deviceType = DeviceType::X4;
#endif
inputMgr.begin();
}
void HalGPIO::update() {
inputMgr.update();
updateUsbState(millis());
}
void HalGPIO::updateUsbState(const unsigned long now) {
// SOF-based host-link sampling (see the member comment). A cheap register
// read, so it runs at its own short cadence on both devices and is never
// behind the I2C throttle below — a fresh enumeration must cancel light
// sleep within a poll or two, or the next slice kills the CDC link again.
if (sofLastSampleMs == 0 || now - sofLastSampleMs >= SOF_SAMPLE_MS) {
const auto sof = static_cast<uint16_t>(USB_SERIAL_JTAG.fram_num.sof_frame_index);
usbSofActive = (sof != lastSofFrameIndex);
lastSofFrameIndex = sof;
sofLastSampleMs = now;
}
// Throttle the X3's I2C-based USB detection; see USB_POLL_X3_MS. First call
// (usbLastPollMs == 0) always polls so boot state is correct. The combined
// verdict below is still recomputed every call so a SOF-detected attach is
// not held back by the throttle window.
if (usbLastPollMs == 0 || !deviceIsX3() || now - usbLastPollMs >= USB_POLL_X3_MS) {
usbLastPollMs = now;
usbElectricalConnected = isUsbElectricalConnected();
}
const bool connected = usbSofActive || usbElectricalConnected;
usbStateChanged = (connected != lastUsbConnected);
lastUsbConnected = connected;
}
void HalGPIO::pollUsbState() {
// Wait out the SOF sample floor so the comparison sees a real frame delta:
// two reads inside one USB frame compare equal and read as "no host".
const unsigned long elapsed = millis() - sofLastSampleMs;
if (sofLastSampleMs != 0 && elapsed < SOF_SAMPLE_MS) {
delay(SOF_SAMPLE_MS - elapsed);
}
updateUsbState(millis());
}
bool HalGPIO::wasUsbStateChanged() const { return usbStateChanged; }
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); }
bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); }
bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); }
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
bool HalGPIO::isDebouncePending() const { return inputMgr.isDebouncePending(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouchTap(nx, ny); }
bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); }
bool HalGPIO::wasTouchReleased() const { return inputMgr.wasTouchReleased(); }
bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const {
return inputMgr.isTouchTapCandidate(nx, ny, heldMs);
}
bool HalGPIO::isTouchHeldAt(float& nx, float& ny) const { return inputMgr.isTouchHeldAt(nx, ny); }
bool HalGPIO::wasTouchLongPress(float& nx, float& ny) const { return inputMgr.wasTouchLongPress(nx, ny); }
void HalGPIO::suppressTouchContact() { inputMgr.suppressTouchContact(); }
unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd);
}
bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
void HalGPIO::setSharedConfirmPowerShortPressEmitsPower(const bool enabled) {
InputManager::setSharedConfirmPowerShortPressEmitsPower(enabled);
}
bool HalGPIO::hasEdgeSideButtons() const {
return BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3 ||
BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4Pro;
}
bool HalGPIO::isXteinkDevice() const {
return BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3 ||
BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3Uc8279 ||
BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4;
}
bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
// Boards without a power button (or M5Paper's latch circuit) cannot verify a
// hold; treat the wake as valid.
if (BoardConfig::ACTIVE.input.power < 0) {
return true;
}
#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER
return true;
#endif
if (shortPressAllowed) {
// Fast path - no duration check needed
return true;
}
// TODO: Intermittent edge case remains: a single tap followed by another single tap
// can still power on the device. Tighten wake debounce/state handling here.
// Calibrate: subtract boot time already elapsed, assuming button held since boot.
const unsigned long calibration = millis();
const unsigned long calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
const auto start = millis();
inputMgr.update();
// inputMgr.isPressed() may take up to ~500ms to return correct state
while (!inputMgr.isPressed(BTN_POWER) && millis() - start < 1000) {
delay(10);
inputMgr.update();
}
if (inputMgr.isPressed(BTN_POWER)) {
do {
delay(10);
inputMgr.update();
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
return false;
}
} else {
return false;
}
return true;
}
bool HalGPIO::isUsbConnected() const {
// Recent SOF activity means an enumerated host regardless of what the
// electrical check says (false at boot until update() has sampled twice).
return usbSofActive || isUsbElectricalConnected();
}
bool HalGPIO::isUsbElectricalConnected() const {
if (deviceIsX3()) {
// X3: infer USB/charging via BQ27220 Current() register (0x0C, signed mA).
// Positive current means charging. Misses a data-only cable and a full
// battery — the SOF check in update() covers those.
for (uint8_t attempt = 0; attempt < 2; ++attempt) {
int16_t currentMa = 0;
if (X3GPIO::readBQ27220CurrentMA(&currentMa)) {
return currentMa > 0;
}
delay(2);
}
return false;
}
if (BoardConfig::ACTIVE.usbDetect < 0) {
return false;
}
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
}
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason();
const bool usbConnected = isUsbConnected();
if (resetReason == ESP_RST_DEEPSLEEP &&
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO || wakeupCause == ESP_SLEEP_WAKEUP_EXT1)) {
return WakeupReason::PowerButton;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) {
return WakeupReason::PowerButton;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
return WakeupReason::AfterFlash;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) {
return WakeupReason::AfterUSBPower;
}
return WakeupReason::Other;
}