justinp.io projects about contact



Inkblot, which this replaces, drove one screen. A 2.7 inch Waveshare panel, with its dimensions and button pinout baked in as module defaults, and its display components rendered by generating HTML. That worked for that panel. Waveshare makes about seventy others.

Supporting the rest of the line isn't an incremental change to that design. Panels differ in resolution, colour capability, refresh procedure and buffer layout — monochrome, four-level grayscale, tri-colour with two separate one-bit buffers, seven-colour ACeP — and an HTML renderer with hardcoded dimensions has nowhere to put any of that. So ChromaWave is a rewrite rather than a version bump: native C bindings, an actual drawing model, and a driver registry that treats a display as data.

Three layers

The drawing layer exposes a Surface protocol, implemented by Canvas (RGBA, C-accelerated), Layer (a clipped, offset sub-region with its own coordinate space, which composes to arbitrary depth) and Framebuffer (device format, C-backed). Every primitive from lines, rects, circles, polygons, text, blits, flood fill etc works against any of them.

The rendering layer is the only place quantisation happens. It maps RGBA to the nearest palette entry, dithers with Floyd-Steinberg, ordered or threshold, and splits a single canvas into two mono buffers for tri-colour panels. Composition stays in full colour until the last possible moment, which means alpha compositing works normally right up until the display gets involved.

The hardware layer handles pixel packing, SPI transfer and GPIO lifecycle. A refresh takes anywhere from two to fifteen seconds depending on the panel, so it releases the GVL for the duration and other Ruby threads keep running. All hardware operations are mutex-guarded, so concurrent access serialises instead of interleaving into garbage.


Displays as data

Display classes are built at load time from a two-tier driver registry. Straightforward panels are pure configuration, around ten lines each. The complicated ones override with custom C for LUT selection, power cycling or buffer transforms. Capabilities per display such as partial refresh, fast mode, grayscale, dual buffer, regional refresh are composable Ruby modules mixed in per model, so respond_to?(:display_partial) answers the capability question directly instead of a feature flag hash answering it on the model's behalf. Naming a model that doesn't exist raises ModelNotFoundError with did-you-mean suggestions, which matters more than it sounds like when there are seventy of them and the names all look like epd_2in13b_v4.

Canvas as bytes

Canvas stores pixels as packed RGBA bytes in a single Ruby String rather than as an array of Color objects, and the difference isn't marginal. An 800×480 surface comes out at roughly 1.5 MB and two GC objects. The same surface built from Color instances is around 15 MB and close to 384,000 of them, all of which the garbage collector then has to walk on every pass. Because bulk operations write into the buffer directly instead of allocating, blitting a full-size image takes about 5 ms where the naive version takes 800, and compositing a 400×300 region with alpha takes 2ms against 50.

Neither number is exotic on a workstation. On a Raspberry Pi holding a full-size canvas for a large panel, both start to matter.

The three hot paths clear, alpha blit, and bulk RGBA load are C accelerated, each with a Ruby fallback so the gem still works where the extension won't build.

Developing without the hardware

I own two panels. The gem supports seventy. That gap is the central constraint of this project rather than a footnote to it, and MockDevice is the answer to it.

MockDevice is a full Display subclass that swaps SPI and GPIO calls for inspectable stubs. Same model config, same capabilities, same API — no hardware. It records an operation log, holds onto the last framebuffer, and simulates busy-wait durations so timeout and interrupt paths actually get exercised. Most usefully, it exports palette-accurate PNGs. The renderer has already quantised by that point, so the export shows the real palette and the real dithering artefacts rather than a tidied-up approximation of them.

It hooks into RSpec through example metadata, so a spec declares the model it wants and receives a mock device configured for it. The entire suite runs on a laptop. Hardware calls made off-device raise DeviceError with a clear message instead of silently doing nothing, which is the failure mode I wanted least.


The parts that make it pleasant

A layout DSL describes UI as nested rows and columns with flex proportions, padding, gaps and spacers, which is a lightweight flexbox and removes most of the coordinate arithmetic. Text rendering goes through freetype with wrapping and alignment. Images load through libvips, so anything it handles, ChromaWave handles. A Lucide icon font ships bundled — 1,500 icons, no setup. The freetype and libvips dependencies are both optional, and the core drawing pipeline works without either.

There's also a pi_maker recipe built into the gem, so a blank SD card to a Pi that can drive a display is one command sequence rather than an afternoon of enabling SPI and hunting GPIO libraries.

[ ChromaWave on Github ]