← EasyTool.me

My Graduation Cap Runs Rust: RP2040 + WS2812B LED Matrix Guide

Published: 2026-05-13

EasyTool.me12 min read中文版

"My graduation cap runs Rust." I've been waiting to say that ever since I came up with the idea — and thanks to the Rust embedded ecosystem, it's not just a gimmick. The project turned into a genuinely fun deep dive into embassy-rs, the RP2040 HAL, WS2812B LED timing, and the joy of writing safe firmware for a blinkenlights contraption that's going on my head.

Here's the full story of how I built a WS2812B RGB LED matrix into my graduation cap — the electronics, the Rust code, the battery setup, and why I think Rust is the best language for this kind of creative embedded project.

This project was inspired by Eric Park's original grad cap project (HN 40+ points), which I expanded with an RP2040-based approach using embassy-rs for async control.

Project Overview: What Are We Building?

The concept is simple: embed an RGB LED matrix on the underside of a graduation cap, write Rust firmware to drive it, and make the whole thing battery-powered and wearable. The LEDs display animations — a spinning globe, scrolling text like "Hello, World!", rainbow cycles — triggered when the tassel is moved from right to left at the ceremony.

The final bill of materials:

Total cost: about $35 in parts. Total build time: a weekend of soldering and a few hours of Rust coding.

Why Rust for Embedded? A Deeper Look

Before we dive into the build itself, it's worth understanding why Rust is such a compelling choice for embedded projects — especially one like this where reliability, battery life, and development ergonomics matter.

Type-level safety for hardware: The Rust type system catches pin misconfiguration at compile time. You can't accidentally use a pin in input mode when you meant output — the HAL (Hardware Abstraction Layer) encodes the pin state as a type parameter. This catches what would otherwise be hours of debugging with a logic analyzer.

Zero-cost abstractions: Rust's embedded ecosystem is built on traits (like embedded-hal) that compile down to the same machine code as hand-written C. There's no runtime overhead for using embassy-rs's async tasks compared to a traditional RTOS.

embassy-rs async executor: The game-changer for RP2040 development. embassy-rs provides an async runtime that runs entirely on the microcontroller — no operating system needed. You write concurrent animations as separate async tasks, and the executor handles cooperative scheduling with hardware-timer-based preemption. Tasks that would require state machines or interrupt handlers in C become simple async/await loops.

smart-leds crate: This is the standard Rust library for addressable LEDs. It provides HSV color space conversions, gamma correction, and animation helpers out of the box. The WS2812B driver from the RP2040 HAL handles the precise 800 kHz timing signal natively.

Rust won the "which language" question for three reasons:
  1. Safety: The borrow checker and type-system-encoded pin states prevent the kind of bugs that brick hardware
  2. Ecosystem: embassy-rs + smart-leds + RP2040 HAL is a mature, well-documented stack that just works
  3. Async: Cooperative multitasking with async/await is perfect for LED animations — each animation is a loop that yields to the executor between frames

For context on why maintenance costs matter in even small embedded projects, check out our analysis: AI Coding Agent Must Reduce Maintenance Costs — the same principle applies to firmware. Choose a language that minimizes future debugging time.

Hardware Build: Wiring 48 LEDs by Hand

This is the part that took the longest. Soldering 48 WS2812B LEDs into a matrix on the underside of a rental graduation cap is fiddly work, made harder by the fact that you can't permanently modify the cap (you have to return it).

The LED Matrix Layout

I arranged the LEDs in a 6×8 grid, each one about 1.5 cm apart, covering roughly a 9×12 cm area on the underside of the cap. The LEDs face downward toward the graduate's head — meaning the light bounces off the top of the wearer's head and creates a diffuse glow effect rather than direct blinding light.

Each WS2812B LED has four pins: VCC (5V), GND, DIN (data in), and DOUT (data out). They chain together: the DOUT of one LED connects to the DIN of the next. The RP2040 GPIO pin drives the first LED's DIN, and the data flows through all 48 LEDs.

Wiring Diagram (Simplified)

Power Bank (USB-C)
    |
USB-C PD Trigger Board (negotiates 5V)
    |
+-- 5V rail ---- [all 48 WS2812B VCC pins]
|
+-- GND rail --- [all 48 WS2812B GND pins + RP2040 GND]
|
RP2040 GPIO 28 ---- [LED #1 DIN]

LED #1 DOUT ---- [LED #2 DIN]
LED #2 DOUT ---- [LED #3 DIN]
... (chained through all 48)

RP2040 GPIO 15 ---- [Reed switch + pull-up resistor]
Reed switch ---- [GND] (closes when magnet near)

Magnet ---- [attached to tassel]

Power considerations: 48 WS2812B LEDs at full white draw about 2.4 A (50 mA each). This is too much for the RP2040's 3.3V regulator. The USB-C PD trigger board negotiates 5V at up to 3A from a standard power bank. The RP2040 gets its power through the VSYS pin connected to the 5V rail (the onboard regulator handles the 5V→3.3V step-down for the chip itself).

Tassel detection: The reed switch is normally open. A small neodymium magnet is glued to the tassel. When the tassel is moved at the ceremony, the magnet lifts away from the reed switch, the circuit opens, and the firmware detects the rising edge to trigger the light show.

Soldering Tips

Rust Firmware: The Code

The firmware is structured around embassy-rs's async executor. Let's walk through the key parts.

Project Setup (Cargo.toml)

[package]
name = "gradcap-rs"
version = "0.1.0"
edition = "2024"

[dependencies]
embassy-rp = "0.3"
embassy-executor = "0.7"
embassy-time = "0.5"
smart-leds = "0.4"
ws2812-pio = "0.5"
panic-halt = "0.2"
embedded-hal = "1.0"

[profile.release]
lto = true
opt-level = "s"
debug = false

Main Entry Point

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_rp::gpio::{Input, Pull, Output, Level};
use embassy_rp::pio::Pio;
use embassy_time::{Timer, Duration};
use smart_leds::{
    hsv::{Hsv, hsv2rgb},
    RGB8,
};
use ws2812_pio::Ws2812Pio;
use panic_halt as _;

const LED_COUNT: usize = 48;

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());

    // WS2812B driver via PIO (programmable I/O)
    let pio = Pio::new(p.PIO0);
    let led_pin = Output::new(p.PIN_28, Level::Low);
    let mut ws2812 = Ws2812Pio::new(
        pio.common,
        pio.sm0,
        led_pin,
        pio.dma,
    );

    // Reed switch input with internal pull-up
    let reed_switch = Input::new(p.PIN_15, Pull::Up);

    spawner.spawn(animation_task(ws2812, reed_switch)).unwrap();
}

Animation System (async tasks)

This is where embassy-rs really shines. Each animation is a separate async task. The executor runs them cooperatively — when one task awaits (waits for a timer), another task runs.

#[embassy_executor::task]
async fn animation_task(
    mut ws2812: Ws2812Pio,
    reed_switch: Input<'static>,
) {
    let mut leds = [RGB8::default(); LED_COUNT];
    let mut tassel_moved = false;

    loop {
        // Wait for tassel to move (rising edge on reed switch)
        loop {
            if reed_switch.is_high() {
                tassel_moved = true;
                break;
            }
            Timer::after(Duration::from_millis(10)).await;
        }

        // Run the show sequence
        spinning_globe(&mut ws2812, &mut leds).await;
        hello_world(&mut ws2812, &mut leds).await;
        rainbow_cycle(&mut ws2812, &mut leds).await;

        // Turn off after sequence completes
        leds = [RGB8::default(); LED_COUNT];
        ws2812.write(&leds).await;
        tassel_moved = false;
    }
}

Animation: Spinning Globe

The globe animation creates a rotating Earth effect. It uses HSV color cycling to create the ocean/land contrast and scrolls the pattern through the LED matrix using an offset counter.

async fn spinning_globe(
    ws2812: &mut Ws2812Pio,
    leds: &mut [RGB8; LED_COUNT],
) {
    let mut offset = 0;

    for _ in 0..256 {
        for (i, led) in leds.iter_mut().enumerate() {
            let pos = (i + offset) % LED_COUNT;
            // Blue ocean base with green continents at certain positions
            let is_land = (pos / 6 + pos % 6) % 3 != 0;
            *led = if is_land {
                RGB8::new(34, 139, 34)  // forest green
            } else {
                RGB8::new(30, 100, 200) // ocean blue
            };
        }
        ws2812.write(leds).await;
        offset = (offset + 1) % 6; // scroll each frame
        Timer::after(Duration::from_millis(50)).await;
    }
}

Animation: "Hello, World!" Scrolling Text

Text on a 6×8 matrix is cramped but still legible. I encoded each character as a 5×6 bitmap and scroll it across the display.

async fn hello_world(
    ws2812: &mut Ws2812Pio,
    leds: &mut [RGB8; LED_COUNT],
) {
    // Simple bitmap font (5x6 characters)
    let font: &[&[u8]] = &[
        // H
        &[0b10001, 0b10001, 0b11111, 0b10001, 0b10001],
        // ... (additional character definitions)
    ];

    for offset in 0..(font.len() * 6 + 8) {
        leds.fill(RGB8::default());

        // Render visible characters at this scroll offset
        for (col, char_idx) in ((offset / 6)..font.len()).enumerate() {
            if col >= 8 { break; }
            let char_data = font[char_idx];
            let char_offset = offset % 6;

            for row in 0..5 {
                if char_data[row] >> (6 - char_offset - 1) & 1 == 1 {
                    let led_idx = row * 8 + col;
                    if led_idx < LED_COUNT {
                        leds[led_idx] = RGB8::new(255, 0, 0); // red
                    }
                }
            }
        }

        ws2812.write(leds).await;
        Timer::after(Duration::from_millis(100)).await;
    }
}

Animation: Rainbow Cycle

A classic demo — cycling the entire LED strip through HSV hue, creating a smooth rainbow sweep. This is almost trivially simple with the smart-leds crate.

async fn rainbow_cycle(
    ws2812: &mut Ws2812Pio,
    leds: &mut [RGB8; LED_COUNT],
) {
    for hue in (0..=255).step_by(2) {
        for (i, led) in leds.iter_mut().enumerate() {
            let hsv = Hsv {
                hue: (hue + (255 / LED_COUNT * i) as u8) as u8,
                sat: 255,
                val: 128,
            };
            *led = hsv2rgb(hsv);
        }
        ws2812.write(leds).await;
        Timer::after(Duration::from_millis(20)).await;
    }
}

Building and Flashing

The RP2040 build process is straightforward with Rust. Here's the complete workflow:

# Install the RP2040 target
rustup target add thumbv6m-none-eabi

# Build the firmware (release for minimal binary size)
cargo build --release

# Enter bootloader mode on Pico (hold BOOTSEL, plug in USB)

# Flash with elf2uf2 or probe-rs
# Option 1: elf2uf2 (drag-and-drop)
cargo install elf2uf2-rs
elf2uf2-rs target/thumbv6m-none-eabi/release/gradcap-rs

# Option 2: probe-rs (requires debug probe)
cargo install probe-rs --features cli
probe-rs run --chip RP2040 target/thumbv6m-none-eabi/release/gradcap-rs

The release binary comes in at about 48 KB — well within the RP2040's 2 MB flash, with plenty of room for additional animations.

Battery and Wearability

The trickiest part of the entire project was battery management. Here's the setup that worked:

Weight: The total added weight is about 200 g — noticeable but not uncomfortable. The power bank is the heaviest component at ~100 g.

The Results: What It Looks Like

The finished effect is better than I expected. The LEDs bounce light off your hair/scalp, creating a soft diffuse glow that's clearly visible from 10+ feet away but not blinding. During daylight, the effect is subtle — a warm glow under the cap. In dimmer indoor lighting (like a graduation ceremony venue), the colors pop beautifully.

The sequence I settled on: the cap stays dark until the tassel is moved. Then it plays through three animations — spinning globe (10 seconds), "Hello, World!" scrolling text (15 seconds), and a 30-second rainbow cycle — before fading to a slow, subtle pulse animation for the rest of the ceremony. The slow pulse uses about 200 mA and lasts for hours.

"The most fun was explaining to friends that yes, my graduation cap literally says 'Hello, World!' in scrolling Red LEDs, and yes, it's running on embedded async Rust with a cooperative scheduler — that frame-accurate animation timing isn't just a coincidence, it's embassy-rs at work."

Key Takeaways for Embedded Rust Developers

If you're thinking about building your own Rust-powered embedded project, here's what I learned:

1. embassy-rs Changes the Game

Writing async code for microcontrollers feels like cheating. The embassy framework provides a Timer type, a Spawner for spawning tasks, and executor that works without an OS. For LED animations, this is ideal — you write each animation as a simple async loop, and the executor handles switching between them.

2. The RP2040 Is a Great Rust Target

The PIO (Programmable I/O) blocks are a perfect match for Rust's type safety. The embassy-rp crate exposes PIO as a type-checked resource — you specify the PIO block, state machine, and DMA channel at compile time, and the compiler catches mismatches.

3. WS2812B Timing Is Tricky (But PIO Makes It Easy)

The WS2812B uses a one-wire protocol with precise 800 kHz timing. Bit-banging this in software is possible but fragile. The PIO-based driver (ws2812-pio) generates the signal entirely in hardware, so the CPU is free to run animations while the PIO handles the timing.

4. The Rust Embedded Ecosystem Is Production-Ready

As of 2026, the Rust embedded ecosystem is mature. embassy-rs has excellent documentation. smart-leds handles all the color math. probe-rs provides debugging with GDB-like breakpoints right from VS Code. The toolchain is as smooth as C/C++ toolchains — and in some ways smoother, because cargo handles dependencies and versioning without the CMake pain.

Ready to start your own Rust embedded project? The rp-hal repo has excellent examples for getting started. The embassy-rs book walks through async patterns step by step. And the smart-leds documentation covers everything from gamma correction to animation chaining.

What I'd Do Differently

No project post is complete without the "what went wrong" section:

Go Build Something

This project was a weekend of work and a few hours of Rust coding, and the result is a graduation cap that genuinely made people smile. The Rust embedded ecosystem made it easy — not just possible, but genuinely pleasant.

If you're learning Rust and looking for a fun project, this is a great one. You'll touch on real-time async programming, hardware interfacing, I/O peripherals, and battery-aware optimization — all in a package that fits on your head.

The code is available on GitHub. Go fork it, adapt it, and make a better version. I'd love to see what you build.