# ChromaWave

Canonical: https://justinp.io/projects/chromawave

Ruby C bindings for Waveshare e-paper displays. One drawing API across 70+ panels, from 1.02" to 13.3", monochrome through seven-colour. The rewrite that replaces Inkblot.

Tags: ruby, gem, epaper, hardware, c

Repository: https://github.com/jtp184/chroma_wave

![](https://justinp.io/media/047c441c-2597-5908-83a4-80b20ebbeb6c/image_processing20260821-2343-1muakpz.png)[Inkblot](http://justinp.io/project/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.

![](https://justinp.io/media/f08cb9a2-7e14-5c14-8442-ee00f7d3da51/image_processing20260821-2343-s8c7ye.png)
## 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.

![](https://justinp.io/media/4eb4dbe5-b7d9-5afc-8b1e-1fd4ecb7ab78/image_processing20260821-2343-1upkr2s.png)
## 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.

