Your E-Paper Panel Isn't Broken: How Retained State Makes Drivers Look Buggy

· msj's blog


I spent an afternoon fixing three bugs in an e-paper driver. None of them existed.

The driver was epdsi, a no_std Rust framework I maintain for electronic paper displays. I was porting a working example from an RP2350 to an ESP32-C3 — same driver crate, same panel, different board. It should have been a twenty-minute job. It took most of a day, and the code I ended up shipping was, in the essentials, the code I started with.

What follows is the failure mode that did it, because I don't think it's written down anywhere, and because anyone working with e-paper will eventually hit it.

The Thing Nobody Warns You About #

E-paper retains its image without power. That's the selling point — the whole reason to use the technology. Every datasheet leads with it.

What the datasheets don't emphasise is that the controller retains state too, and that state is not always benign.

Interrupt a refresh mid-flight — Ctrl-C the monitor, reflash the board, unplug it while the charge pump is running — and the controller can be left latched busy. It sits with BUSY asserted, waiting for an operation that will never complete.

Now run your program again. The driver issues a refresh and waits for BUSY. BUSY is already asserted and never clears, so the wait runs to its timeout. In epdsi that's 60 seconds. On an SSD1680, a single refresh is three stages — power-on, update, power-off — each with its own wait. That's three minutes for one refresh, and my example did six of them.

Worse, it is self-perpetuating. A timed-out refresh leaves the panel in the same latched state, so the next run fails identically. You are now debugging a system that fails the same way every time, which feels exactly like a deterministic bug.

And here is the part that cost me the most: a hardware reset does not clear it. The driver's hard_reset toggles the RST pin, the panel acknowledges it, BUSY pulses correctly — and the underlying state persists. Only removing power actually resets it.

The Four Faces of the Same Problem #

Retained state doesn't announce itself. It wears whatever costume fits your current hypothesis. Over one afternoon it produced all of these, and I diagnosed each as a different bug.

Shifted, clipped content. A write cut off partway leaves the frame buffer half-transferred. The panel displays the result faithfully.

2.13 inch panel showing content shifted right and clipped at the edge

That is a 2.13" monochrome panel. The header at the top is correct; everything below it is displaced about 40 px to the right and running off the edge. It looks precisely like a RAM window or stride miscalculation — and I had recently touched the RAM addressing code, so I believed it immediately.

Here is the same failure on a 4.26" panel, where an interrupted write produced a frame drawn twice side by side with a band of noise between:

4.26 inch panel showing the frame duplicated side by side with a noise band

A refresh that returns in 10 ms. The panel isn't accepting commands, so BUSY never asserts, so the wait sees an idle line and returns at once. I diagnosed this as a race — the driver polling BUSY before the panel had time to raise it. I wrote three successive fixes for that race. The race does not exist.

A refresh that appears to hang. It isn't hanging, it's timing out. But three minutes is indistinguishable from forever when you are watching a terminal, so you interrupt it — which recreates the exact condition that caused it.

Nothing at all. Commands ignored, display unchanged, program running to completion and reporting success.

Four symptoms, four plausible driver bugs, one actual cause.

How I Made It Worse #

Three mistakes, all of which I would have called obvious in someone else's writeup.

I kept reasoning from polluted data. I knew runs were being interrupted. I still drew conclusions from what happened afterwards. At one point I correctly suspected panel state, told myself to power-cycle, got a result that didn't match my theory — and abandoned the theory instead of concluding the theory was wrong.

I built a diagnostic that lied. To get finer timing I hand-rolled the controller's trigger sequence instead of calling the driver's own refresh. It reported nine measurements, all exactly 100 ms, and I read them as data:

1--- A: full frame, Full mode (0xF7) ---
2    power-on  (0xE0): BUSY released after 100 ms
3    update    (0xF7): BUSY released after 100 ms
4    power-off (0x83): BUSY released after 100 ms

They were noise on an idle line. The code never drove the panel at all. What exposed it was not the log — the log looked entirely plausible — but noticing that the display never changed.

I anchored on software because software is where I was working. Known-good third-party code was sitting right there the whole time. I didn't reach for it until much later.

What Actually Worked #

Three techniques, each of which collapsed hours of speculation into a single experiment.

Run stock third-party code across several boards. I flashed the standard Arduino GxEPD2 demo onto four different microcontrollers with the same adapter and the same panel. Three worked perfectly; one produced noise. That one experiment proved the fault was board-level and not in my driver — a conclusion I had failed to reach through hours of code analysis. I later repeated the trick with a different panel and got an equally clean answer.

Validate the instrument against a known measurement. Once I had a trustworthy baseline — a full refresh on this panel takes 3891 ms — any diagnostic reporting 100 ms was self-evidently broken. That baseline turns "interesting result" into "my tool is lying" instantly. Establish one before you need it.

Watch the display, not the log. Firmware happily reports success it did not achieve. The panel cannot. Every diagnostic I wrote after this point drew something unmistakable — solid black, then solid white — so the hardware itself answered the question.

The Protocol #

This is now in my repository as required reading:

Power-cycle, run once, don't interrupt, then judge. Connect and disconnect FPCs with the board unpowered.

Unglamorous, and it would have saved the entire afternoon. Some corollaries.

Never reason from an interrupted run, or from any run following one, until you have removed power. Not reset — power.

Know your reference timings. Deviation is only recognisable against a baseline:

Panel Full refresh Partial refresh
4.26" monochrome ~3.9 s ~1.0 s
2.13" monochrome ~3.9 s ~1.0 s
1.54" tri-colour ~14 s ~14 s

Moving a 4,000-byte frame over SPI at 4 MHz takes 8 ms. Data transfer is essentially never your problem, which eliminates a whole family of tempting hypotheses.

Colour panels are slow, and that's physics. Tri-colour and quad-colour panels have no fast waveform — the coloured pigment is a heavier particle that needs the full waveform to migrate. Every update takes seconds. My 1.54" tri-colour example runs six refreshes at roughly 14 seconds each: ninety seconds of a screen that appears frozen, all of it correct. This is the single most likely thing to make you interrupt a run that was working perfectly.

The Part That Generalises #

The reason this took an afternoon rather than twenty minutes is not that e-paper is unusually difficult. It is that I was debugging a system where both my subject and my instrument could lie to me, and I only ever checked one of them.

The panel lied by retaining state across runs, so every experiment was contaminated by the last. My diagnostics lied by reporting plausible numbers for operations that never happened. Software alone cannot distinguish either case — which is why the fix was physical: pull the power, and watch the screen.

If you are debugging anything with retained state — displays, EEPROMs, radios with persistent configuration, anything with a charge pump — assume your last experiment is still influencing this one until you have proven otherwise. And when a measurement surprises you, check the ruler before you rewrite the code.

The same XIAO ESP32-C3 and ePaper Driver Board from the failure photographs, now rendering a 2.13 inch quad-colour panel correctly in black, white, red and yellow

That is the same board and the same adapter as the two failure photographs above — a XIAO ESP32-C3 on a Seeed ePaper Driver Board — rendering all four inks exactly as intended.

The driver, for the record, needed no changes at all.


epdsi is on crates.io and GitHub — a no_std, embedded-hal 1.0 driver framework for e-paper displays, covering SSD1680/1681/1677, UC8253, JD79661, ED2208 and the Pervasive Displays COGs, verified on hardware across Cortex-M, RISC-V and Xtensa.

last updated: