Skip to content

Fast e-paper updates that keep the panel balanced - #11739

Open
mikeysklar wants to merge 29 commits into
mainfrom
epaperdisplay-fast
Open

mikeysklar wants to merge 29 commits into
mainfrom
epaperdisplay-fast

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

What

E-paper displays can update in about 50 ms and drive every pixel equally both ways.

Why

Unbalanced fast updates can damage a panel.

Part 2 of 3, stacked on #11738.

display = epaperdisplay.EPaperDisplay(
    bus, START, STOP, clean_sequence=CLEAN,
    refresh_display_command=REFRESH, seconds_per_frame=0,
    write_previous_ram_command=0x26, fast=True,
    ...)
while True:
    if not display.busy:
        display.refresh()
Addition Behavior
write_previous_ram_command Writes previous RAM after each refresh (#6872)
fast Starts once, stops on release, drives with lag
lag_frames 4 by default, 1 to 32, only with fast
clean_sequence, clean() Full refresh at start and code end, wipe on demand
update_refresh_mode(refresh_sequence=) Changes the refresh command

All off by default. fast is on for espressif and raspberrypi; other ports raise NotImplementedError.

Tested on Feather RP2040 ThinkInk, 4.2" SSD1683 Result
Update time 52.9 ms, stock 6.1 s
8 ways code can end, 3 each 24/24 clean, back to REPL
Flash +8.5 KB

The docs warn that this exceeds panel makers' refresh limits, and long-term wear is unknown.

Question: should the previous RAM write move from refresh() into the background task?

AI assistance

Written with Claude Code.

🤖 Generated with Claude Code

@tannewt

tannewt commented Nov 3, 2026

Copy link
Copy Markdown
Member

This PR description is way too long. Please simplify it.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain these changes concisely.

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Trimmed the description.

What this PR does:

  • Adds fast partial updates to EPaperDisplay
  • Updates take about 50 ms, not 6 s
  • Lag drive keeps every pixel balanced
  • Panel cleans itself when code ends
  • clean() wipes ghosting on demand

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lag stuff is a lot of complexity and I don't really understand why/how they work to keep things balanced. I'd like to understand it more before we commit it.

@mikeysklar

mikeysklar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Did some review to make sure I have this right. Updating code comments as well so the explanation is easy to find.

Fast updates compare the new frame with the last one, so each change gets one weak push.

Lag frames compare with the frame lag_frames updates back, so each change gets several pushes. The multiple pushes give e-ink crisp images without ghosting.

Each frame is used twice:

  1. Pixels are pushed toward it when it is new.
  2. Then away from it lag_frames updates later.

So the pushes always cancel.

That is the balance: every pixel gets as many pushes toward black as toward white.

Example with lag_frames=3: a pixel is black for 2 updates, then white.

Update 1 2 3 4 5 6 7
New frame ⬜ ⬛ ⬛ ⬜ ⬜ ⬜ ⬜
Old, 3 updates back ⬜ ⬜ ⬜ ⬜ ⬛ ⬛ ⬜
Push none to black to black none to white to white none

2 pushes to black, 2 to white: balanced.

@mikeysklar
mikeysklar requested a review from tannewt November 4, 2026 21:59
Base automatically changed from video-gif to main November 5, 2026 19:38
mikeysklar and others added 15 commits November 5, 2026 11:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a refresh finishes, write the same areas again with this command so
the controller's previous image RAM matches the screen. This lets SSD168x
style partial updates diff against what is actually shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keep_awake display ends a transaction still holding its bus when the VM ends, since
Ctrl-C can leave one open and the stop sequence would otherwise wait for the bus forever.
The stop sequence is also guarded when it follows a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous RAM holds frame N-lag instead of N-1, so each changed pixel is
driven for lag_frames updates, the same number toward black and toward white.
Only the box around changed bytes is sent. clean_sequence runs before the first
refresh and when the display is released, leaving the panel white and balanced.

A waveform or panel reset cut short leaves the panel dark, so while one is under
way an interrupt waits and is raised when it ends. A send it stops starts over.
Waits are bounded per update so a stuck panel can't hang shutdown, and the
watchdog is fed while interrupts are held.

CIRCUITPY_EPAPERDISPLAY_LAG gates it, off by default and on for Feather RP2040
ThinkInk. clean_sequence is accepted on every build. lag_frames raises ValueError
where the drive is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings every pixel to white, optionally runs clean_sequence, then drives every
pixel frames updates each way. For scripts to remove ghosting while running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release_displays() lost the owed drive and the stop sequence silently when
something held the bus for the whole 30 s bound. Wait for the bus first, and raise
with every display untouched, so the script can free it and try again. The end of
the VM can't catch an exception, and doesn't need to: nothing can still hold the
bus by then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait used the whole deadline, so a panel a reset would free got no
clean. Reset once, wait once more with no renewal, and run the stored
waveform if BUSY falls. If it stays high, nothing more is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The faster refresh options (write_previous_ram_command, keep_awake and
update_refresh_mode's refresh_sequence) cost about 900 bytes, which no
longer fits on several SAMD21 and SAMD51 boards. With the flag off,
EPaperDisplay builds as it did before. Lag drive needs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fast and lag arguments and clean() now exist on every build. Builds
without them raise NotImplementedError when they are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Those are the ports with e-paper boards. Other ports keep the same API
and raise NotImplementedError, which saves flash on small boards such as
the nRF52840 dongles that overflowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was only on for the Feather RP2040 ThinkInk, but external panels need it
too. It costs about 7 KB of flash and 80 bytes of RAM. The frame memory is
only allocated when lag_frames is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each frame is driven toward once and away from once, lag steps later.
Adds a small example of a pixel that changes back early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tannewt
tannewt force-pushed the epaperdisplay-fast branch from bcbe057 to 4c2e227 Compare November 5, 2026 19:38
@tannewt

tannewt commented Nov 5, 2026

Copy link
Copy Markdown
Member

That is the balance: every pixel gets as many pushes toward black as toward white.

Example with lag_frames=3: a pixel is black for 2 updates, then white.
Update 1 2 3 4 5 6 7
New frame ⬜ ⬛ ⬛ ⬜ ⬜ ⬜ ⬜
Old, 3 updates back ⬜ ⬜ ⬜ ⬜ ⬛ ⬛ ⬜
Push none to black to black none to white to white none

2 pushes to black, 2 to white: balanced.

Wouldn't frame 4 incorrectly show black? Maybe that's ok for a fast change.

It seems like we always want lag frames when doing fast updates. Maybe we tie it's use to the FAST and partial ability? Then we omit both from SAMD and nRF52.

@tannewt

tannewt commented Nov 5, 2026

Copy link
Copy Markdown
Member

What would inform the number of lag frames? That seems like an implementation detail that we could decide. I feel like we could simplify the API surface of this by simply having a "fast" mode that could be set. However, I don't know how dependent on the driver IC that is.

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Wouldn't frame 4 incorrectly show black?

Yep! Briefly. Frame 4 still shows black and clears at 5.

On moving content that looks like a short trail. That's tradeoff for drive balanced.

Maybe we tie it's use to the FAST and partial ability? Then we omit both from SAMD and nRF52.

Agreed. This is a good plan. I'll fold LAG into FAST and turn FAST off for SAMD and nRF52.

What would inform the number of lag frames?

The two factors that would influence lag frame are :

  1. What looks good
  2. How much memory - 6 frames @ 400x300 is 90 KB.

The vendors don't specific guidance for these fast partial updates.

We can use lag=4 to save memory and it looks acceptable.

How about fast=True with a built in default lag of 4. Plus an optional override so users can tune easily tune it.

mikeysklar and others added 2 commits November 5, 2026 14:21
Lag drive is what makes fast updates safe for the panel, so it no longer
has its own build flag. CIRCUITPY_EPAPERDISPLAY_LAG is gone and its code
builds with CIRCUITPY_EPAPERDISPLAY_FAST. FAST stays on only for espressif
and raspberrypi, so atmel-samd and nordic get neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fast=True is now the one switch for fast updates. It keeps the display
awake between refreshes and drives with lag, so keep_awake is gone.
lag_frames defaults to 4, must be 1 to 32 with fast, and is ignored
without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Simplified as you suggested.

fast=True now turns on fast updates and lag drive together.

lag_frames defaults to 4, so most code never sets it.

It trades darker motion against smear and memory, so I kept it as an optional setting.

keep_awake is gone, since fast covers it.

@mikeysklar
mikeysklar requested review from tannewt and removed request for tannewt November 9, 2026 00:00

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting there! I like having LAG + FAST combined. Folks at supercon really wanted this for the badge epaper.

Comment thread py/circuitpy_mpconfig.mk Resolved
mikeysklar and others added 6 commits November 9, 2026 19:17
CIRCUITPY_EPAPERDISPLAY_FAST now follows CIRCUITPY_EPAPERDISPLAY, so new
ports get it automatically. It is turned off where flash is tight:
samd21, SAMx5x chips without 1MB flash (the same HAS_1MB_FLASH gate
as ulab), and nordic boards that keep their filesystem in internal
flash. The espressif and raspberrypi lines that set the same default
are dropped.

Add a default of 0 in circuitpy_mpconfig.h, since zephyr-cp builds
the module without setting the flag.

The feature cost 5.3 KB on espruino_pico, the tightest board that
gains it, which keeps 16 KB free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per review. Also call the display "fast" in the end_held_bus comment,
matching the renamed argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per review. The lag_* members of the display object become an
epaperdisplay_lag_t member named lag, with the prefix dropped
(self->lag_head is now self->lag.head). No behavior change: the
ThinkInk firmware is the same size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It holds the fast argument, which replaced keep_awake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per review. The lag drive section and the helpers only it uses move
verbatim from EPaperDisplay.c into lag_drive.c. The functions the two
files share are declared, with what they return, in a private
lag_drive.h, and get module-prefixed names: lag_refresh() becomes
epaperdisplay_lag_refresh() and wait_for_busy() becomes
epaperdisplay_epaperdisplay_wait_for_busy().

lag_drive.c compiles to nothing without CIRCUITPY_EPAPERDISPLAY_FAST,
so zephyr-cp, which builds every file in the module, is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	shared-bindings/epaperdisplay/EPaperDisplay.c
@tannewt

tannewt commented Nov 10, 2026

Copy link
Copy Markdown
Member

(FYI there are still unresolved/unresponded to comments.)

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Sorry about that. I totally missed those.

mikeysklar and others added 2 commits November 10, 2026 17:24
Compile the fast keyword arguments and clean() only when
CIRCUITPY_EPAPERDISPLAY_FAST is on, and keep the shared helpers static
otherwise, so boards without fast build the same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Off for ESP32-C3, C5 and C6, STM32F401 and F411, the WIZnet Picos and
TG-Watch, which would have under 64 KB of flash free with it on. Also
off for atmegazero_esp32s2 and microdev_micro_s2, which run out of
internal RAM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar
mikeysklar requested a review from tannewt November 11, 2026 03:31
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