Started 2026 · Active Go · Raspberry Pi · SPI · E-Paper
Inkwell drives a Waveshare 7.5" e-paper panel from a Raspberry Pi Zero 2 W. It composes widgets into a frame, packs that frame down to what the display can actually show, and pushes it over SPI. Out of the box it renders a calendar and weather dashboard from iCal feeds and an Open-Meteo ensemble.

The reason it exists is less about e-ink and more about the state of the software around it. The Waveshare sample code is Python, written to demonstrate that the hardware works rather than to be built on, and it isn’t testable in any way I recognized. I wanted a dashboard I could keep changing for years without being afraid of it.
Adding a display is a data problem
The thing I’m happiest with is that the driver has no per-display code in it. A
DisplayProfile in
internal/inkwell/profile.go
is a plain struct: resolution, colour depth, and the raw SPI command sequences
for full, fast, partial and 4-gray init, plus the buffer and refresh opcodes and
the sleep sequence. No methods, no interface implementations, no behaviour.
Supporting a new panel means filling in a new profile and registering it in a map. The existing driver handles the rest. That was a goal from the first design document rather than something I noticed afterwards, and it survived contact with a second colour mode, which is usually where this kind of abstraction falls over.
Testing a thing you can’t plug in
There is one Hardware interface and four implementations of it. SPIHardware
talks to the real panel through periph.io. MockHardware
records calls.
ImageBackend writes PNGs. WebPreview serves a live browser view over HTTP
with server-sent events for reload.
The preview is the one worth explaining. It doesn’t re-render the frame through a parallel drawing path, which would let it drift from the device. It captures the actual old-plane and new-plane bytes going out over the wire and reconstructs the image from those, so the browser shows what the panel would receive. It defaults to that device view rather than the smooth source canvas, because the smooth version is a design aid and a liar.


The SPI backend lives behind a //go:build hardware tag and registers itself
through an init() hook, so periph.io never enters the tested build. That’s what
makes 100% statement coverage achievable on a hardware driver, and CI fails the
build below it. The ratio ended up around two lines of test for every line of
production code, which sounds excessive until you remember the alternative is
carrying a Raspberry Pi around to find out whether a refactor worked.
Refreshing without driving yourself mad
E-paper refresh is its own subsystem, and it took me longer to get right than the rendering did. Two separate questions: which waveform to use (skip, fast, full, or 4-gray), and when a change is allowed to push at all.
A full refresh flashes the whole panel to clear ghosting. It has to happen periodically, and it’s ugly, so it’s on an hourly cadence that is deliberately not user-configurable. It’s a property of the panel, not something a widget author should be reasoning about.
Per-widget refresh intervals are mandatory, with no default, and LoadConfig
errors if a widget omits one. They’re aligned to the wall clock, so two widgets
on a five minute interval both land on :00, :05, :10 and coalesce into one panel
update rather than flashing independently. The version of this I tried first let
each widget refresh on its own schedule from whenever it started, and the panel
never sat still.
There’s also a negative result written into
refresh.go:
a windowed, flicker-free per-change refresh that I built and then abandoned,
because the force-drive it needs to redraw changed pixels cleanly settles the
box inverted under the partial waveform on real hardware. Keeping the note there
is cheaper than rediscovering it in a year.
Getting it onto the desk
The panel lives on my desk, in a stand I 3D printed for it.
Releases are automated from conventional commits through to multi-arch tarballs,
with the version demoted one level so a feat: doesn’t accidentally cut a 1.0
before I’m ready for one. On the Pi itself:
The updater fetches from GitHub Releases, verifies the checksum, and replaces the binary atomically. A failed update leaves the running binary alone.
Four dependencies total, two of which are periph.io. Everything else is written out: the BDF font parser, the iCal parser with recurrence expansion, the weather client, the updater. That wasn’t a purity exercise, it’s that each one is small and specific enough that a general library would have been more work to bend than to write. Ask me again when someone sends me a calendar with a timezone I haven’t thought about.