Skip to content

feat: optional hooks around long e-ink BUSY waits - #8

Merged
itsthisjustin merged 4 commits into
Free-Ink:mainfrom
BrianPugh:feat/busy-wait-hooks
Jul 6, 2026
Merged

feat: optional hooks around long e-ink BUSY waits#8
itsthisjustin merged 4 commits into
Free-Ink:mainfrom
BrianPugh:feat/busy-wait-hooks

Conversation

@BrianPugh

Copy link
Copy Markdown
Contributor

Equivalent of crosspoint-reader/community-sdk#21; copying/pasting body below:

An e-ink refresh takes ~0.3–2 s, during which pollBusy() spins on the BUSY pin at whatever clock the host is running. This PR adds setBusyWaitHooks(begin, end): plain function pointers fired when a wait exceeds 20 ms and again when it completes, letting host firmware apply its own power policy for the wait window (CrossPoint uses it to drop the ESP32-C3 to 10 MHz there).

  • Fully backwards compatible: both hooks default to nullptr; behavior is unchanged unless installed. The only added cost is a null-check inside the 1 ms poll loop.
  • The 20 ms threshold means short command waits never fire the hooks, so hosts don't pay two clock switches on trivial waits. The end hook fires only if the begin hook did.
  • Covers both the X4 (active-HIGH) and X3 (active-LOW, two-phase) wait paths, including the X3 early-return path.

Measured on an X3 with a Nordic PPK2 at the battery terminals at 3.8v: with CrossPoint installing a 10 MHz downclock hook, a fast page turn drops from ~38 mC to ~29 mC (−24%), with fast/full/grayscale refreshes visually unchanged on device.

Supports crosspoint-reader/crosspoint-reader#2525

🤖 Generated with Claude Code

BrianPugh and others added 3 commits July 2, 2026 20:50
A refresh takes ~0.3-2 s during which EpdBus::waitBusy() only polls the
BUSY pin at full CPU clock. Add a pair of optional plain-function-pointer
hooks (setBusyWaitHooks, forwarded through FreeInkDisplay) fired when a
wait exceeds 20 ms and when it completes, so host firmware can apply its
own power policy (e.g. reduce the CPU clock) for the wait window. Hooking
the bus covers every driver that polls through waitBusy (SSD1677/X4,
UC8253/X3, ED2208/M5). No behavior change unless hooks are installed;
short command waits never trigger them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A state change only commits after two consecutive matching samples
(DEBOUNCE_DELAY stability window). A host that polls slowly — e.g. an
idle loop pacing 50 ms light-sleep slices — can therefore drop a tap
shorter than its poll period: the press lands in one sample and the
next sample already reads released, so it never commits.

Expose the pending-debounce state so such hosts can re-poll quickly
until the change commits. Also sync lastState in applyStateChange()
so the accessor stays false on the hold-style input paths, which
bypass the debounce sampling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Once a BUSY wait has proven long (same 20 ms threshold as the
begin/end hooks), each poll-delay iteration is offered to a host-
installed slice hook, which can e.g. light-sleep until the BUSY pin
leaves its active level. The hook returns false (or is absent) to
fall back to the plain delay. Gated by a longWait flag independent
of the begin hook's presence, so installing only the slice hook works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@itsthisjustin
itsthisjustin merged commit f363600 into Free-Ink:main Jul 6, 2026
@itsthisjustin

Copy link
Copy Markdown
Contributor

If you've still got an x3 open and a way to scope it...

Can you put a logic analyzer on the panel SPI while stock firmware renders a sleep wallpaper. I need to know the actual CDI value, the exact plane bytes for a known image, and the timing. I want to diff our command stream against stock's byte-for-byte.

@BrianPugh

BrianPugh commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

I have a cheap Salae-clone that I've never used but have been meaning to 😄 . In prep, from looking at the PCB/code, I think the following is the description of the testpads. I would still need to probe/find the following:

  • DISPLAY_CS - GPIO21, Physical Pin 28
  • D/C - GPIO4, Physical Pin 9
  • BUSY - GPIO6, Physical Pin 12
  • RST - GPIO5, Physical Pin 5

There's a few test points on the right-side of the PCB, but I bet they're unrelated. I also haven't yet lifted up the tape covering the display/pcb connector; there could be more test points over there.

20240604_004615 copy

itsthisjustin pushed a commit to crosspoint-reader/crosspoint-reader that referenced this pull request Aug 13, 2026
…e (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**_

---------
szetszho pushed a commit to szetszho/crosspoint-reader that referenced this pull request Aug 14, 2026
…e (X3-measured) (crosspoint-reader#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 crosspoint-reader#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**_

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants