Inkwell


A Go dashboard engine for e-ink panels, built to be fully testable without touching hardware.

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 Inkwell dashboard as the panel renders it: a seven-day calendar with weather, an inverted column for today, temperature lines and precipitation bars

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 same dashboard as the source design canvas, in smooth grayscale before packing

The same dashboard packed to 1-bit black and white

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:

1
2
3
4
inkwell --version
sudo inkwell self-update --check
sudo inkwell self-update
sudo systemctl restart inkwell

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.