An Introduction to Embedded Rust Development on an E-Ink Device

I picked up a Pimoroni Badger 2040 some time ago and never really did anything with it. It is a RP2040 (dual Cortex-M0+, 264K of SRAM, 2MB of flash) with a 296×128 one-bit e-ink panel via a UC8151 controller and six buttons along the bottom edge. It ships running MicroPython. But I wanted to try using bare-metal Rust for it. Here is what it involved.

The Pimoroni Badger device

Setup

I could document it here, but there isn't much point. I setup a new Rust project Cargo and all the defaults. Then here is an initial prompt I used with Claude

I have a eink badger 2040 plugged into this Macbook via usb-c

I would like to create and run a hello world project in Rust on it

probably using the uc8151 crate

please do this (giving me instructions for any external steps)

It worked first time. From there I did ask for it to setup separate binaries (so I could run different 'apps'). Here are a few things I built.

Hello world

embedded-graphics gives you fonts, primitives and a Drawable trait; uc8151 implements its DrawTarget for the panel. So drawing is the same shape as drawing anywhere:

#![no_std]
#![no_main]

use badgeable::{Board, InkCanvas, UpdateSpeed, WIDTH};

#[entry]
fn main() -> ! {
    let mut board = Board::init(pac::Peripherals::take().unwrap(), UpdateSpeed::Normal);

    board.display.fill_paper();

    board.display.bounding_box()
        .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 2))
        .draw(&mut board.display)
        .unwrap();

    Text::with_alignment(
        "Hello, world!",
        Point::new((WIDTH / 2) as i32, 58),
        MonoTextStyle::new(&FONT_10X20, BinaryColor::On),
        Alignment::Center,
    )
    .draw(&mut board.display)
    .unwrap();

    board.display.update().unwrap();

    loop {
        board.led.set_high().unwrap();
        board.timer.delay_ms(500u32);
        board.led.set_low().unwrap();
        board.timer.delay_ms(500u32);
    }
}

The LED blink at the end is basically there to reassure things have worked. main returns !. There is nothing to return to.

Generative art

Creating Generative Art is often a good way to explore new hardware or software UI libraries.

As you can see above we are now basically doing ordinary Rust programming; but we have two constraints: no allocator and the Cortex-M0+ has no floating point unit. Here are a few of the things that I built for it.

Generative art with Pimoroni Badger

Hashing (for 'Random' tiles)

Every generator is a pure function of (seed, scale), so the same settings always redraw the same image and you can get back to one you liked. Where a desktop version would allocate a grid of tile states, a hash lets each tile decide its own contents from its coordinates:

/// Deterministic hash of a grid cell. Lets a generator decide a tile's
/// contents from `(seed, x, y)` alone, with no per-tile state to store.
pub fn hash2(seed: u32, x: i32, y: i32) -> u32 {
    let mut h = seed ^ (x as u32).wrapping_mul(0x9e37_79b9)
                     ^ (y as u32).wrapping_mul(0x85eb_ca6b);
    h ^= h >> 16;
    h = h.wrapping_mul(0x7feb_352d);
    h ^= h >> 15;
    h = h.wrapping_mul(0x846c_a68b);
    h ^ (h >> 16)
}

Truchet tiles

Truchet tiles then need no state at all. Each tile carries two quarter-arcs centred on opposite corners, so however the neighbours land the curves meet at the edges and join into long meanders:

fn truchet(d: &mut Display, seed: u32, s: i32, fg: bool) {
    let r = s / 2;
    // The arc band has to stay thin relative to the tile. Fatten it and the
    // two arcs swallow the tile and the pattern collapses into a uniform grid.
    let t = s / 14;
    for y in 0..H {
        let (ty, fy) = (y.div_euclid(s), y.rem_euclid(s));
        for x in 0..W {
            let (tx, fx) = (x.div_euclid(s), x.rem_euclid(s));
            let flip = hash2(seed, tx, ty) & 1 == 1;
            let (ax, ay, bx, by) = if flip { (s, 0, 0, s) } else { (0, 0, s, s) };
            if (dist(fx - ax, fy - ay) - r).abs() <= t
                || (dist(fx - bx, fy - by) - r).abs() <= t
            {
                d.set_ink(x, y, fg);
            }
        }
    }
}

dist uses integer isqrt rather than f32::sqrt to avoid software floating point operations on then M0+. Where a generator genuinely wants trigonometry — three radial sine sources summed and thresholded into contour bands — libm::sinf provides it, and 296×128 pixels of it is comfortably fast enough. Randomness that isn't a hash comes from a nine-line xorshift, and text formatting comes from heapless::String<48>, a String with its capacity in the type:

let mut left: String<48> = String::new();
write!(left, "{}  scale {}  seed {:04x}", p.gen.name(), p.scale + 1, p.seed & 0xffff).ok();

What to use for a seed? No network, no RTC and no ADC noise wired up, the only entropy on the board is when the human pressed the button, so a new seed is hash2(board.micros() as u32, old_seed, scale). The main loop is then about as small as an interactive app gets:

loop {
    if dirty {
        // LED stays lit while we generate and refresh, which is most of the
        // wall clock time — without it the badge looks frozen.
        board.led.set_high().ok();
        art::render(&mut board.display, &p, "A:gen B:seed C:inv");
        board.display.update().unwrap();
        board.led.set_low().ok();
    }
    let press = board.wait_for_press();
    dirty = p.handle(press, board.micros() as u32);
}

handle returns whether anything actually changed, so a press that does nothing doesn't cost a refresh. On e-ink that matters more than it sounds.

Making it feel like a UI (or the trade offs of e-ink)

A primitive Pimoroni Badger UI

display.update() blocks — it spins on the BUSY line for the whole refresh, and the waveform tables (LUTs) in uc8151 budget 4500ms for a Normal full refresh, 2000ms for Medium, 800ms for Fast and 250ms for Ultrafast. Faster waveforms ghost more. A full refresh also strobes the entire panel to black and back, which is fine once and unbearable per keypress. To make it work better as an interactive UI we will use partial_update, refreshing a rectangle without a flash.

// Every band boundary below is a multiple of 8, and that is not cosmetic:
// `UpdateRegion::new` rejects any y or height that isn't.
const TITLE_H: i32 = 16;
const ROW_H: i32 = 24;
const FOOTER_H: i32 = 16;

Pick the row height first and every band becomes independently refreshable. Moving the caret then repaints two rows and refreshes only those:

fn move_to(&mut self, board: &mut Board, next: usize) {
    let prev = self.selected;
    if prev == next { return }
    self.selected = next;
    draw_row(&mut board.display, prev, false);
    draw_row(&mut board.display, next, true);

    // Partial refreshes don't fully reset the pixels, so faint remnants
    // accumulate. Spend a full refresh to clear them after this many.
    if self.partials >= GHOST_LIMIT {
        board.display.update().unwrap();
        self.partials = 0;
        return;
    }

    let (lo, hi) = if prev < next { (prev, next) } else { (next, prev) };
    if hi - lo == 1 {
        // Neighbours: one region spanning both beats two waveform cycles.
        board.display.partial_update(rows_region(lo, hi)).unwrap();
        self.partials += 1;
    } else {
        board.display.partial_update(rows_region(prev, prev)).unwrap();
        board.display.partial_update(rows_region(next, next)).unwrap();
        self.partials += 2;
    }
}

Future steps

One of the great things about these devices is that they can use very little power. You can setup programs that draw something then turn off and the e-ink screen keeps its configuration. You could have it running for months on a battery if it isn't actually running most of the time. The buttons on the device would also lend it well to making a game.

It is a lot more expensive, but Pimoroni have a refreshed range of 'Badgeware' that has a variety of screen options. They include batteries and a case. And now can connect via WiFi.

Conclusion

If you have a microcontroller in a drawer, try letting Claude set things up for you. Create some Generative Art with it to push things. Or make a little game.

Appendix: Some things to watch out for

I didn't actually run into any of these, as Claude already addressed, but they maybe of use if you run into the issue.

  1. The framebuffer powers up all black. The crate maps BinaryColor::On to ink, and a zeroed buffer means ink everywhere. Draw without blanking first and you get a black rectangle with invisible text. A tiny trait keeps the inversion in one place:
pub trait InkCanvas {
    fn set_ink(&mut self, x: i32, y: i32, ink: bool);
    fn fill(&mut self, ink: bool);
    fn fill_paper(&mut self) { self.fill(false) }
}

impl InkCanvas for Display {
    fn set_ink(&mut self, x: i32, y: i32, ink: bool) {
        if x >= 0 && y >= 0 { self.pixel(x as u32, y as u32, !ink) }
    }
    // …
}
  1. One button is wired backwards. A, B, C, Up and Down sit on pull-downs and read high when pressed. USER sits on a pull-up and reads low. Normalise it once, at the edge, and everything above can say "true means pressed":
Buttons {
    a: self.a.is_high().unwrap_or(false),
    // …
    user: self.user.is_low().unwrap_or(false),
}
  1. Partial updates only align to 8 pixels vertically. UpdateRegion::new rejects any y or height that isn't a multiple of 8, because the framebuffer packs 8 vertical pixels into a byte. x and width are unconstrained. This is the sort of constraint you want to know before you design a layout, not after.

  2. The dependency versions are important. uc8151 0.2 is written against embedded-hal 0.2, so it needs a HAL implementing those traits, and rp2040-hal is therefore pinned to 0.9.2 — the last release before it moved to embedded-hal 1.0. Bump it and you get a trait-bound error at Uc8151::new that reads as though it is about something else. This one in particular is where I would have lost the afternoon; the ecosystem is mid-migration and half the tutorials online straddle the split.