Skip to content

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

Closed
mikeysklar wants to merge 13 commits into
adafruit:video-giffrom
mikeysklar:epaperdisplay-fast
Closed

mikeysklar wants to merge 13 commits into
adafruit:video-giffrom
mikeysklar:epaperdisplay-fast

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

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

Why

Unbalanced fast updates can damage a panel.

The display now cleans itself after code ends, which a library can't do.

Part 2 of 3 toward GIF video on e-paper, after #11718 and before #11720. Runs without #11718.

Word Meaning
Lag Each changed pixel gets N short updates one way, then N back. lag_frames=N
Fast build Firmware with CIRCUITPY_EPAPERDISPLAY_FAST. On wherever epaperdisplay is, except atmel-samd
Lag build Firmware with CIRCUITPY_EPAPERDISPLAY_LAG, which needs Fast. Off by default, on for Feather RP2040 ThinkInk here
Clean Full refresh with the panel's stored waveform. Automatic before the first refresh and after code ends
Wipe A balanced pass over every pixel, when the script calls clean()
Previous RAM The panel's second image buffer, which it compares against
BUSY The panel's pin that is high while an update runs
Endings Normal end, exception, Ctrl-C, Ctrl-C twice, Ctrl-C during the first clean, Ctrl-D, supervisor.reload(), release_displays()
display = epaperdisplay.EPaperDisplay(
    bus, START, STOP, clean_sequence=CLEAN,
    refresh_display_command=REFRESH, seconds_per_frame=0,
    write_previous_ram_command=0x26, keep_awake=True, lag_frames=3,
    ...)
while True:
    if not display.busy:
        display.refresh()

Changes

Addition Behavior Build
update_refresh_mode(refresh_sequence=) Changes the refresh command with the mode Fast
write_previous_ram_command Writes the same pixels to previous RAM after each refresh, as suggested in #6872 Fast
keep_awake Resets and starts once. stop_sequence goes out on release Fast
lag_frames Lag N. Needs seconds_per_frame=0 Lag
clean_sequence Runs the automatic cleans, only with lag_frames. Accepted without lag, so one driver works on any Fast build Fast
clean(frames, full=) Wipe, optional clean Lag
Defaults All off. Stock drivers behave as on main Fast
Cost Value
Flash, without lag (ThinkInk, Feather RP2040, nRF52840) +1.0 to 1.2 KB
Flash, lag on top +7.6 KB
atmel-samd Fast off: M0 and M4 boards had too little room, so they build as on main
RAM, lag 3 at 400x300 45,000 B, plus about 100 B outside the VM heap

How code ends

Case Result Evidence
Each ending Finishes the update, cleans, stops Board
Display made in code Same. Pins are free at the REPL Board
Script still holding the SPI bus Ends the transaction, then cleans Board
Ctrl-C during a clean or wipe Waits, then KeyboardInterrupt Board; wipe on an earlier build
File save during a clean or wipe Waits, then reloads into a new VM Board
keep_awake without lag, Ctrl-C mid-refresh Ends the held bus, sends stop Board
Same display, release_displays() while the script holds the bus Waits until Ctrl-C Board
microcontroller.reset(), safe mode, power loss, hard fault, watchdog reset No clean From source
BUSY stuck when code ends Time to prompt Sent to the panel Evidence
A reset frees it About 34 s Reset, clean, stop Board, stuck flag faked in a debug build
Stays stuck About 60 s One reset, then nothing. The next lag display cleans it Same
Stays stuck, playback already gave up About 60 s Two resets with the start sequence between, then nothing Same; commands from the simulator
Clears during the first wait Up to about 35 s Clean, stop Host only
Ctrl-C held by a clean, stays stuck About 90 s One reset, then nothing Host only
Slow panel, each update just under 30 s 119 s at lag 3, 962 s at lag 32 Clean, stop Host only
Stuck during playback busy False after 30 s; next refresh() resets and cleans, or raises Board
Serial during these waits Silent Board

Tested

Feather RP2040 ThinkInk, 4.2" SSD1683, Ubuntu host. No camera on the final build, so BUSY and timing only.

CircuitPython 11.0.0-alpha.2 built from this branch, alone and with all 3 PRs.

This PR alone, bitmap moved 8 px, lag 3 Result
Code ends 3/3, 4.0 s to REPL
Ctrl-C 3/3, 3.6 s to REPL
Stock refresh 6.95 s first, 6.26 s after, same as main
All 3 PRs, final build Result
8 endings, 3 each 24/24, 3.6-7.2 s to REPL
Random Ctrl-C 5/5
Ctrl-C then Ctrl-D 10/10, new VM reached
File save during a clean, during a wipe 2/2, 2/2
keep_awake without lag, Ctrl-C mid-refresh 3/3, 4.0 s to REPL
60 s playback, 5 runs, debug build 5/5, longest wait 0.12 s
Earlier build, camera check Passed
8 endings, 5 each 40/40, white
Random Ctrl-C 50/50, white
File save during a clean 20/20, white
Earlier build, 40x40 square moved 8 px Trials ms per refresh Both RAM planes match
Stock driver, full refresh 5 6128.9 (6128.0-6130.1) Yes
Fast waveform, keep_awake, previous RAM 152 52.9 (52.8-53.4) Yes
Host test: real lag code, model panel, no USB, VM or SPI Result
Every interrupt, at each point of a wipe, clean, start, shutdown 44 sweeps each, normal and reload, 0 unbalanced
Every commit builds; balance at each lag commit Pass, 0 errors
nRF52840, Metro ESP32-P4, Feather M4 Build; lag doesn't fit on Feather M4

Test files

Test driver: ssd1683_lag.py (SSD1683 4.2", needs a lag build)
# ssd1683_lag: stand-in for a driver library. Constructs EPaperDisplay with lag drive on the
# Feather RP2040 ThinkInk + 4.2" SSD1683, 1-frame 125 Hz balanced LUT. Needs firmware built with
# CIRCUITPY_EPAPERDISPLAY_LAG. The display cleans itself: before its first refresh and when it is
# released or the code stops.
import board, busio, displayio, epaperdisplay, fourwire

NO_PINGPONG = bytes([0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
VSS, VSH1, VSL = 0x00, 0x40, 0x80


def seq(c, data=b"", delay=None):
    n = len(data)
    hi = (n >> 8) | (0x80 if delay is not None else 0)
    return bytes([c, hi, n & 0xFF]) + data + (bytes([delay]) if delay is not None else b"")


def make_lut(frames, fr):
    def one(vs):
        g = bytearray(42)
        g[0:7] = bytes([1, vs | frames, 0, 0, 0, 1, 0])
        return g

    lut = bytearray(227)
    lut[0:42] = one(VSS)  # LUTC
    lut[42:84] = one(VSS)  # WW
    lut[84:126] = one(VSH1)  # BW
    lut[126:168] = one(VSL)  # WB
    lut[168:210] = one(VSS)  # BB
    lut[224] = fr
    return lut


START = (
    seq(0x12, delay=0x32) + seq(0x18, b"\x80") + seq(0x11, b"\x03") + seq(0x01, b"\x2b\x01\x00")
    + seq(0x32, make_lut(1, 0x05))  # 1 frame, frame rate byte 0x05 = 125 Hz
    + seq(0x3F, b"\x07") + seq(0x03, b"\x17") + seq(0x04, b"\x41\xa8\x32") + seq(0x2C, b"\x30")
    + seq(0x21, b"\x00\x00") + seq(0x3C, b"\x80") + seq(0x37, NO_PINGPONG)
    + seq(0x22, b"\xc0") + seq(0x20)
)
# Full refresh with the waveform stored in the panel. Runs with both RAM planes white.
CLEAN = (
    seq(0x12, delay=0x32) + seq(0x3C, b"\x05") + seq(0x18, b"\x80") + seq(0x11, b"\x03")
    + seq(0x44, b"\x00\x31") + seq(0x45, b"\x00\x00\x2b\x01") + seq(0x01, b"\x2b\x01\x00")
    + seq(0x1A, b"\x5a\x00") + seq(0x22, b"\x91") + seq(0x20) + seq(0x22, b"\xc7") + seq(0x20)
)
STOP = seq(0x10, b"\x01", delay=0x64)  # deep sleep, HV off
# Full RAM window before the update.
REFRESH = seq(0x44, b"\x00\x31") + seq(0x45, b"\x00\x00\x2b\x01") + seq(0x22, b"\x0c") + seq(0x20)


_spi = None


def release():
    # Finishes any owed drive, cleans, sleeps and frees the ring. The SPI would otherwise keep
    # the pins.
    global _spi
    displayio.release_displays()
    if _spi is not None:
        _spi.deinit()
        _spi = None


def make_display(lag=6):
    global _spi
    release()
    spi = _spi = busio.SPI(board.EPD_SCK, board.EPD_MOSI)
    bus = fourwire.FourWire(spi, command=board.EPD_DC, chip_select=board.EPD_CS,
                            reset=board.EPD_RESET, baudrate=20_000_000)
    return epaperdisplay.EPaperDisplay(
        bus, START, STOP, clean_sequence=CLEAN, width=400, height=300, ram_width=50, ram_height=300,
        busy_pin=board.EPD_BUSY, busy_state=True, seconds_per_frame=0,
        refresh_display_command=REFRESH, write_black_ram_command=0x24,
        write_previous_ram_command=0x26, keep_awake=True, lag_frames=lag,
        set_column_window_command=0x44, set_row_window_command=0x45,
        set_current_column_command=0x4E, set_current_row_command=0x4F,
        address_little_endian=True, two_byte_sequence_length=True)
Test code for this PR alone: bm_lib.py and the Ctrl-C case

STUCK is the marker the test harness waits for before sending Ctrl-C.

# bm_lib.py
# B alone build (no video): lag display from ssd1683_lag, a 40x40 black square on a Bitmap moved
# 8 px per refresh through displayio. The display cleans itself on its first refresh and at the end.
import displayio, ssd1683_lag


def start():
    d = ssd1683_lag.make_display(3)
    bm = displayio.Bitmap(400, 300, 2)
    p = displayio.Palette(2); p[0] = 0xFFFFFF; p[1] = 0x000000
    sq = displayio.Bitmap(40, 40, 2); sq.fill(1)
    t = displayio.TileGrid(sq, pixel_shader=p, x=0, y=130)
    g = displayio.Group(); g.append(displayio.TileGrid(bm, pixel_shader=p)); g.append(t)
    d.root_group = g
    return d, t, bm, sq, p, g


def step(d, t):
    while d.busy:
        pass
    t.x = (t.x + 8) % 360
    d.refresh()
# code.py
import bm_lib
x = bm_lib.start()
for _ in range(40):
    bm_lib.step(x[0], x[1])
print("STUCK")
while True:
    bm_lib.step(x[0], x[1])
Results log for this PR alone
f10a_e1 bm_e e KI after -, to prompt 3.98 s, BUSY True
f10a_e2 bm_e e KI after -, to prompt 3.98 s, BUSY True
f10a_e3 bm_e e KI after -, to prompt 3.98 s, BUSY True
f10a_c1 bm_c c KI after 0.02 s, to prompt 3.59 s, BUSY True
f10a_c2 bm_c c KI after 0.02 s, to prompt 3.59 s, BUSY True
f10a_c3 bm_c c KI after 0.02 s, to prompt 3.59 s, BUSY True
11.0.0-alpha.2-56-gc8ce6108d2 on 2026-11-01
refresh 0 black box: call 0.88 s, busy total 6.95 s
refresh 1 white: call 0.20 s, busy total 6.26 s
refresh 2 black box: call 0.20 s, busy total 6.26 s
refresh 3 white: call 0.20 s, busy total 6.26 s
released
BUSY True
rc=0

Panel safety

The EPaperDisplay docs now have a warning: keep_awake, write_previous_ram_command and lag_frames allow updates much faster than panel makers' limits on refresh rate. Fast updates need a balanced waveform, and long-term wear on the panel is unknown. Lag drive keeps each pixel balanced, but there is no published lifetime data for this kind of driving.

Open issues

Seen Status
White after each ending, final build Not checked by eye or camera
Last RAM byte kept before a reset Not confirmed. A 0x7F no-op follows each lag RAM write as a precaution
One Ctrl-C with no answer for 60 s, build before the rebase Cause unknown, not seen since
One Ctrl-C then Ctrl-D with dead serial, earlier build Cause unknown, not seen in 76 trials since
Whether a reset frees a real stuck panel Unknown
Long-term panel wear at this update rate Unknown
Stuck panel during update_refresh_mode Raises "display_bus in use", which is misleading
Inverted panels, watchdog during a clean Not tested on a board

Question for review

Where the previous RAM write runs Good Bad
Blocking wait in refresh() (this PR) Small. Dirty areas are still valid refresh() blocks for the update, or for refresh_time with no BUSY pin
Background task, when the refresh is done refresh() stays non-blocking Has to keep the area list past finish_refresh, or redraw the whole screen

Scope

Monochrome only.

AI assistance

Written with Claude Code.

🤖 Generated with Claude Code

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>
@mikeysklar
mikeysklar changed the base branch from main to video-gif November 2, 2026 20:00
@mikeysklar
mikeysklar marked this pull request as ready for review November 2, 2026 20:18
mikeysklar and others added 5 commits November 2, 2026 14:18
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dhalbert
dhalbert requested a review from tannewt November 3, 2026 16:28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Replaced by #11739, opened from an adafruit branch so the three PRs stack.

@mikeysklar mikeysklar closed this Nov 3, 2026
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.

1 participant