Creative Coding in Rust for a Seven-Colour E-Ink Display
We already looked at a creative coding for a simpler e-ink device, but now let's look at a seven colour version, the Pimoroni Inky Frame 5.7. We'll build a generative art frame, that every hour draws a completely new artwork.
We'll again use Rust, which suits the constraints of the hardware well. We can setup the drawing code to run both on desktop and on the microcontroller. Build a picture in memory, inspect it as a PNG, then send the same pixel data to the physical display.
This tutorial builds that portable drawing library and a working desktop preview. It then explains how to integrate them with firmware, including memory allocation, display updates and waking up to draw another picture.
NB The examples below are very specifically tied to the Pimoroni Inky Frame 5.7, with an RP2040 and a 600 × 448 seven-colour panel. Other displays need their own dimensions, palette and driver. But AI tools make this really easy. You can even follow the drawing and preview sections with Rust installed and no hardware. Connecting a display also requires board support: startup code, peripheral configuration and a driver for the exact panel.
Design for physical ink
The panel has seven colours: black, white, green, blue, red, yellow and orange. Every pixel must select one of them. There is no transparency or arbitrary RGB colour at the hardware boundary. A full refresh takes roughly 30 seconds on this model. That makes desktop previews useful even when drawing itself is quick. It also makes hourly or daily changes a much better use than anything interactive, as the time to update would be way too slow.
Start with large shapes, clear gaps and two or three inks. Fine lines can work, but subtle gradients need dithering: patterns of available colours that suggest intermediate shades from a distance. A preview can reproduce the pixel layout precisely. Its RGB colours only approximate reflective pigment, whose appearance also depends on lighting. Check promising compositions on the real panel before tuning the palette further.
Keep drawing separate from hardware
We'll create three things:
- A portable library turns a seed into a framebuffer: an array containing the image's pixels.
- A desktop executable reads that buffer and writes a PNG.
- Firmware passes the buffer to a display driver and manages the board's power.
Assuming you have Rust installed, create a library package:
cargo new --lib inky-sketchbook
cd inky-sketchbook
mkdir -p examples
Replace Cargo.toml with:
[package]
name = "inky-sketchbook"
version = "0.1.0"
edition = "2024"
[dependencies]
embedded-graphics = "0.8"
[target.'cfg(not(target_os = "none"))'.dev-dependencies]
png = "0.18"
embedded-graphics provides drawing primitives. The PNG encoder is a development dependency restricted to ordinary operating systems, so it stays out of the embedded build.
Replace src/lib.rs with:
#![no_std]
pub mod framebuffer;
pub mod pen;
pub mod sketch;
#![no_std] makes the library use Rust's core library instead of std. It has no filesystem or operating-system services. The desktop executable can still use std while calling this library.
Keep the default compilation target as your desktop for now. That makes cargo run --example preview work without a target override. We will check the embedded target separately.
Represent the palette directly
We'll give each physical ink its own enum value. For this panel's packed format, codes 0 to 6 represent the seven colours.
Create src/pen.rs:
use embedded_graphics::{pixelcolor::raw::RawU4, prelude::PixelColor};
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
#[repr(u8)]
pub enum Pen {
Black = 0,
#[default]
White = 1,
Green = 2,
Blue = 3,
Red = 4,
Yellow = 5,
Orange = 6,
}
impl Pen {
pub const PALETTE: [Self; 7] = [
Self::Black,
Self::White,
Self::Green,
Self::Blue,
Self::Red,
Self::Yellow,
Self::Orange,
];
pub const fn code(self) -> u8 {
self as u8
}
pub const fn rgb(self) -> [u8; 3] {
match self {
Self::Black => [0, 0, 0],
Self::White => [255, 255, 255],
Self::Green => [0, 180, 0],
Self::Blue => [0, 70, 210],
Self::Red => [220, 20, 30],
Self::Yellow => [245, 220, 0],
Self::Orange => [240, 120, 0],
}
}
}
impl PixelColor for Pen {
type Raw = RawU4;
}
PixelColor connects the enum to embedded-graphics; RawU4 says that its raw representation uses four bits. PALETTE gives later sketches a list of valid artistic colours to choose from.
Pack two pixels into each byte
A four-bit pixel occupies half a byte, a 'nibble'. Put the first pixel in the high nibble and the next in the low nibble. A row runs left to right, followed by the next row.
byte 0: [pixel 0 | pixel 1]
byte 1: [pixel 2 | pixel 3]
At 600 × 448 pixels, the buffer takes 134,400 bytes, or about 131 KiB. This fits in the RP2040's RAM, but two complete buffers would consume almost all of it before accounting for the stack and other state.
Create src/framebuffer.rs:
use crate::pen::Pen;
use embedded_graphics::{prelude::*, primitives::Rectangle};
pub const WIDTH: u32 = 600;
pub const HEIGHT: u32 = 448;
pub const BUFFER_LEN: usize = (WIDTH as usize * HEIGHT as usize) / 2;
pub struct FrameBuffer {
data: [u8; BUFFER_LEN],
}
impl FrameBuffer {
pub const fn new() -> Self {
Self {
data: [0; BUFFER_LEN],
}
}
pub fn set_pixel(&mut self, x: i32, y: i32, pen: Pen) {
if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
return;
}
let offset = y as usize * WIDTH as usize + x as usize;
let byte = &mut self.data[offset / 2];
if offset & 1 == 0 {
*byte = (*byte & 0x0f) | (pen.code() << 4);
} else {
*byte = (*byte & 0xf0) | pen.code();
}
}
pub fn pixel(&self, x: i32, y: i32) -> Option<Pen> {
if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
return None;
}
let offset = y as usize * WIDTH as usize + x as usize;
let byte = self.data[offset / 2];
Some(
match if offset & 1 == 0 {
byte >> 4
} else {
byte & 0x0f
} {
0 => Pen::Black,
1 => Pen::White,
2 => Pen::Green,
3 => Pen::Blue,
4 => Pen::Red,
5 => Pen::Yellow,
6 => Pen::Orange,
_ => return None,
},
)
}
pub fn fill(&mut self, pen: Pen) {
let pair = (pen.code() << 4) | pen.code();
self.data.fill(pair);
}
pub fn as_bytes(&self) -> &[u8; BUFFER_LEN] {
&self.data
}
}
impl OriginDimensions for FrameBuffer {
fn size(&self) -> Size {
Size::new(WIDTH, HEIGHT)
}
}
impl DrawTarget for FrameBuffer {
type Color = Pen;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(point, pen) in pixels {
self.set_pixel(point.x, point.y, pen);
}
Ok(())
}
fn clear(&mut self, pen: Pen) -> Result<(), Self::Error> {
self.fill(pen);
Ok(())
}
fn fill_solid(&mut self, area: &Rectangle, pen: Pen) -> Result<(), Self::Error> {
let clipped = area.intersection(&self.bounding_box());
for y in clipped.rows() {
for x in clipped.columns() {
self.set_pixel(x, y, pen);
}
}
Ok(())
}
}
set_pixel changes one nibble while preserving its neighbour. For example, black followed by red produces 0x04; changing the first pixel to blue produces 0x34.
Clipping belongs here too. Negative coordinates and points beyond the edge are ignored, so a sketch can draw a circle partly outside the image without checking every pixel itself.
Implementing DrawTarget lets the library draw shapes into this buffer. clear fills pairs of pixels at once.
The constructor fills the array with zeroes, which means black. Each sketch should explicitly clear to its chosen background before drawing. The zeroed constructor will also be useful for static allocation in firmware.
Make each composition reproducible
A seed is a number that selects a sequence of pseudo-random values. With unchanged drawing code and settings, the same seed should recreate the same picture. That makes both visual experiments and bug reports repeatable.
Create src/sketch.rs:
use crate::{
framebuffer::{FrameBuffer, HEIGHT, WIDTH},
pen::Pen,
};
use embedded_graphics::{
prelude::*,
primitives::{Circle, Line, PrimitiveStyle},
};
pub struct Rng {
state: u64,
}
impl Rng {
pub fn new(seed: u64) -> Self {
let mut rng = Self { state: 0 };
rng.next_u32();
rng.state = rng.state.wrapping_add(seed);
rng.next_u32();
rng
}
pub fn next_u32(&mut self) -> u32 {
let old = self.state;
self.state = old
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let word = (((old >> 18) ^ old) >> 27) as u32;
word.rotate_right((old >> 59) as u32)
}
pub fn below(&mut self, limit: u32) -> u32 {
// Adequate for artwork; use rejection sampling if exact uniformity matters.
((self.next_u32() as u64 * limit as u64) >> 32) as u32
}
}
pub fn draw(frame: &mut FrameBuffer, seed: u64) {
let mut rng = Rng::new(seed);
frame.clear(Pen::White).unwrap();
// A simple "constellation": circles joined to their previous neighbour.
let mut previous = Point::new((WIDTH / 2) as i32, (HEIGHT / 2) as i32);
for i in 0..48 {
let point = Point::new(
rng.below(WIDTH - 40) as i32 + 20,
rng.below(HEIGHT - 40) as i32 + 20,
);
let ink = if i % 5 == 0 { Pen::Orange } else { Pen::Blue };
Line::new(previous, point)
.into_styled(PrimitiveStyle::with_stroke(ink, 1))
.draw(frame)
.unwrap();
let diameter = 3 + rng.below(14);
Circle::with_center(point, diameter)
.into_styled(PrimitiveStyle::with_fill(ink))
.draw(frame)
.unwrap();
previous = point;
}
}
The generator uses a small PCG-style calculation with explicit wrapping arithmetic. Its initialisation mixes in the seed before the sketch draws anything. It is intended for artwork, not security. below maps a random value into a bounded range. Its tiny distribution bias is acceptable here; use rejection sampling if your application needs exactly uniform choices.
The sketch clears to white, chooses points within a margin, and joins them with blue or orange lines. Small filled circles mark the points. Changing the seed changes the composition while keeping its overall character. The drawing calls return an infallible result because they only write to memory. A hardware update can fail, so it will need separate error handling later.
Turn the buffer into a PNG
The preview should decode the actual packed bytes. Reimplementing the sketch in a different graphics library would introduce a second rendering path and could hide errors in the framebuffer.
Create examples/preview.rs:
use inky_sketchbook::{
framebuffer::{FrameBuffer, HEIGHT, WIDTH},
sketch,
};
use std::{
fs::{File, create_dir_all},
io::BufWriter,
path::Path,
};
fn main() {
let argument = std::env::args().nth(1).unwrap_or_else(|| "5eed".into());
let seed = u64::from_str_radix(argument.trim_start_matches("0x"), 16)
.expect("pass a hexadecimal seed, for example 0xbeef");
// A local buffer is fine for this desktop example.
let mut frame = FrameBuffer::new();
sketch::draw(&mut frame, seed);
let mut rgb = Vec::with_capacity((WIDTH * HEIGHT * 3) as usize);
for y in 0..HEIGHT as i32 {
for x in 0..WIDTH as i32 {
rgb.extend_from_slice(&frame.pixel(x, y).unwrap().rgb());
}
}
create_dir_all("preview").unwrap();
write_png(Path::new("preview/latest.png"), &rgb);
println!("preview/latest.png, seed = 0x{seed:016x}");
}
fn write_png(path: &Path, rgb: &[u8]) {
let file = File::create(path).unwrap();
let mut encoder = png::Encoder::new(BufWriter::new(file), WIDTH, HEIGHT);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(rgb)
.unwrap();
}
Here the framebuffer is an ordinary desktop variable. The larger RGB array exists only while writing the PNG; firmware will send the packed bytes directly and will not need that conversion buffer.
Run the example from the package directory:
cargo run --release --example preview -- 0xbeef
The argument is hexadecimal, with or without 0x. Invalid input produces an error instead of silently selecting another composition. With no argument, the seed defaults to 0x5eed.
Open preview/latest.png. This is the output from the code above with seed 0xbeef:
Try another seed, change the point count or adjust the circle sizes. Keep the seed fixed while changing one drawing rule so you can see what that rule does.
For a larger sketchbook, save each image under its seed and arrange a batch into a contact sheet. Record the code revision too: a seed only reproduces an image while the algorithm and settings stay the same.
Move the drawing library onto the board
The portable code needs no changes to compile for the RP2040. Install its Cortex-M0+ target and check the library:
rustup target add thumbv6m-none-eabi
cargo check --lib --target thumbv6m-none-eabi
That checks the drawing library, not a flashable application. Firmware also needs an entry point, a linker memory map, a panic handler and configured hardware peripherals.
Use a separate firmware package that depends on the drawing library by path. For sibling firmware and inky-sketchbook directories, add this to the firmware's dependencies:
inky-sketchbook = { path = "../inky-sketchbook" }
Embassy's RP examples provide starting points for async firmware. Use matching dependency versions and linker configuration from one example.
Keep its board target and runner settings inside the firmware package. If you put both packages in a workspace, pass --target thumbv6m-none-eabi explicitly when building firmware to keep desktop previews straightforward.
The Embedded Rust Book explains startup and linking if those are unfamiliar. Check the memory map against your board's flash and RAM before building the application.
Allocate the framebuffer once
Do not construct the 134,400-byte framebuffer as a local variable on the embedded stack. Put it in static storage and take one mutable reference during startup.
ConstStaticCell supports this pattern. In the firmware package, add static_cell = "2.1" and use:
use inky_sketchbook::framebuffer::FrameBuffer;
use static_cell::ConstStaticCell;
static FRAME: ConstStaticCell<FrameBuffer> =
ConstStaticCell::new(FrameBuffer::new());
// Inside the firmware entry point, once:
// let frame = FRAME.take();
On the RP2040, enable portable-atomic's critical-section feature and provide a critical-section implementation through the HAL. This supports the atomic operations used by static_cell on Cortex-M0+. The all-zero initial value allows the pixel array to live in the zero-initialised .bss section. It takes RAM without storing a second image-sized array of initial data in flash. Inspect memory usage after linking. Leave room for async tasks, driver state and the stack; a successful host preview does not prove that the firmware fits.
Give the display driver a small job
The board layer should expose an update operation that accepts a borrowed framebuffer. Inside that operation, initialise the panel, send frame.as_bytes(), request a refresh and wait for completion. Use the exact panel's command sequence. Pimoroni's UC8159 driver is a reference for the 5.7-inch display's initialisation and update behaviour. The board adapter owns SPI, chip-select, data/command and reset signals. It also needs a way to read the panel's BUSY state, with a timeout so a failed refresh returns control to the application.
On this Inky Frame, BUSY and the buttons are read through a shift register. The board support code shows how that fits together. Borrow the framebuffer during the transfer rather than copying it. If the driver uses DMA, keep that borrow until DMA completes so the sketch cannot overwrite pixels while they are being sent. This is the integration boundary: the drawing code above is complete, but the hardware adapter must supply GPIO setup, controller commands and power control. Those details vary between boards and panel revisions.
Treat battery sleep as a restart
The Inky Frame can cut power to the processor and display while its real-time clock remains powered. Before releasing the power-hold signal, finish the refresh, put the panel into its low-power state and arm the next wake event. Handle stale RTC interrupt flags as part of the wake and alarm sequence. Follow the board's reference implementation for the ordering of RTC setup and power control. After power is cut, execution does not resume at the next line of Rust. The next wake boots the firmware again. A seed counter stored only in RAM will therefore reset and repeat the same picture. Use the RTC time or persist a counter before shutdown to choose the next seed. During development, a fixed seed is useful; for a changing display, seed selection must survive the power cycle. On USB, the processor may remain powered after releasing the hold signal. Give the firmware a development loop that waits and redraws, while the battery path draws once, schedules a wake and powers down.
Check each layer independently
Start with checks that isolate one source of failure at a time:
- Pixel packing: set neighbouring pixels to different colours and check the byte value, not just the PNG. Encoding and decoding can share the same mistake.
- Orientation: draw different markers in all four corners, then compare the desktop and panel to expose rotation or mirroring.
- Hardware startup: blink an LED and check reset and BUSY transitions before transferring artwork.
- Palette: display solid fills for all seven inks to verify codes and controller configuration.
- Reproducibility: compare the same seed on the desktop and device. Match geometry first, then assess colour.
- Power: test a complete RTC wake, refresh and shutdown cycle on batteries after USB updates work.
Wrong geometry suggests addressing or orientation. Correct shapes with wrong colours suggest palette codes or nibble order. A reset during refresh can indicate weak power; a link failure near the RAM limit can indicate a duplicate buffer.
Develop a family of sketches
Once the full path works, replace the constellation with another algorithm while keeping the draw(frame, seed) interface. Circle packing, recursive subdivision and Truchet tiles all suit a limited palette. The contact sheet below shows circle-packing variations as an example of what to explore next. These are separate sketches, not output from the constellation code above.
Inspect batches for crowded edges, accidental gaps, weak contrast and excessive density. Change parameter ranges to improve the whole family instead of tuning only one attractive seed. Keep the packed buffer and seeded drawing interface stable as you experiment. Most of the creative work can then happen at your desk, with the physical display reserved for judging how the finished image looks in ink.