My Graduation Cap Runs Rust: RP2040 + WS2812B LED Matrix Guide
Published: 2026-05-13
"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:
- Raspberry Pi Pico (RP2040) — the brains, running Rust firmware via the embassy-rs framework
- 48 × WS2812B RGB LEDs — arranged in a 6×8 matrix on the underside of the cap
- USB-C PD trigger board — to get clean 5V from a power bank
- Power bank — standard USB-C battery pack tucked into the cap lining
- Reed switch + small magnet — one on the cap, one on the tassel, to detect when it's moved
- Hook-up wire and solder — hand-wired matrix, no custom PCB
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.
- Safety: The borrow checker and type-system-encoded pin states prevent the kind of bugs that brick hardware
- Ecosystem: embassy-rs + smart-leds + RP2040 HAL is a mature, well-documented stack that just works
- 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
- Pre-tin every pad. WS2812B pads are tiny (about 2 mm square). Apply a small blob of solder to each pad before placing the LED.
- Use thin wire. I stripped wire from a dead USB-C cable. 28 AWG works well — flexible enough to route but thick enough to handle the current.
- Test in sections. After every 8 LEDs, power up and run a quick Rust test to verify the chain works before continuing.
- Hot glue for strain relief. A dab of hot glue over the solder joints prevents the wires from breaking when the cap flexes.
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:
- Power bank: A slim 5000 mAh USB-C power bank fits inside the cap's lining. It's velcro'd in place so it doesn't slide around.
- USB-C PD trigger: Not strictly necessary — many power banks deliver 5V natively. But the trigger board ensures clean power delivery and prevents the power bank from shutting off when the LEDs pulse.
- Cable management: A short 15 cm USB-C cable connects the power bank to the trigger board. Everything is tucked into the crown of the cap with a small access slit for the USB-C port (for recharging).
- Battery life: Full white brightness drains about 2.4 A. At rainbow-cycle brightness (half brightness = 128/255), it draws about 1.2 A. The 5000 mAh power bank gives about 3-4 hours of continuous animation. For a graduation ceremony (~2 hours), the cap runs at a comfortable 30% brightness, drawing ~700 mA — plenty of margin.
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.
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.
What I'd Do Differently
No project post is complete without the "what went wrong" section:
- Solder first, debug later: I soldered all 48 LEDs before testing any. One had a cold joint, and debugging which LED in the chain was broken took 45 minutes with a multimeter. Test in small batches.
- Battery voltage drop: At full brightness, the voltage on the 5V rail dropped enough to trigger the RP2040's brownout detector. A 470 µF capacitor across the 5V rail fixed it completely.
- Reed switch debouncing: The mechanical reed switch generates contact bounce. A simple 10 ms debounce delay (using
Timer::after) in the firmware handled it cleanly. - Make it removable: If I were doing this for a rental cap again, I'd use a small protoboard instead of directly soldering to the cap fabric. The soldered connections on fabric degrade if the cap is bent.
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.