Ferriswheel - Same Core, Different Engine

A formula one driver does not care which car you put them in.

Hand them the keys to a fully kitted-out ESP-IDF machine with heap allocation, thread::sleep, and a pit crew on the radio — or strap them into a bare-metal esp-hal cockpit with nothing but a panic handler and a delay loop. Same lap times. Same smooth racing line.

In Formula 1, the driver adapts to the car — but a great driver posts competitive times in any chassis. That is exactly how rustyfarian-ws2812 works. The ferriswheel crate: More than a dozen LED ring animations, pure no_std Rust, no idea what engine is underneath. The two driver crates — one with all the telemetry and creature comforts of ESP-IDF, the other stripped to bare metal with esp-hal.

The workspace at a glance

The rustyfarian-ws2812 workspace has five crates. Three make up the driver — the talent that stays the same regardless of the car. Two are the cars themselves.

The driver (shared, no_std throughout):

  • ws2812-pure — color conversion and bit encoding for the WS2812 protocol. No hardware, no allocator.
  • led-effects — a StatusLed trait and a PulseEffect for single-LED status indicators. Also no_std.
  • ferriswheel — 14 ring effects: rainbow, pulse, breathe, meteor, twinkle, fire, cylon, knight rider, rainbow comet, chase, flash, progress, sections, and spinner. Every effect implements the Effect trait. All logic is pure Rust, no hardware dependencies, fully unit-testable on any machine with cargo test.

The cars (hardware-specific engine crates):

  • rustyfarian-esp-idf-ws2812 — the Mercedes. Full telemetry, pit crew radio, tyre strategy software. Uses the ESP-IDF RMT peripheral with esp-idf-hal, giving you the std world: heap allocation, threads, anyhow for errors.
  • rustyfarian-esp-hal-ws2812 — the stripped-down Red Bull. No radio, no power steering, just a steering wheel and raw speed. Uses esp-hal RMT, fully no_std, bare-metal, no operating system underneath. Targets ESP32-C3, ESP32-C6, and ESP32-WROOM-32.

Both cars implement SmartLedsWrite from the smart-leds-trait ecosystem. Same driver, different engine.

What the Effect trait looks like in practice

Every animation in ferriswheel implements this contract:

pub trait Effect {
    fn update(&mut self, colors: &mut [RGB8]) -> Result<(), EffectError>;
    fn reset(&mut self);
}

update writes the next frame into a slice of RGB8 values. reset puts the effect back to its initial state. There is no hardware in sight.

The Mercedes: ESP-IDF with all the comforts

The ESP-IDF car gives you the full standard library. You can use thread::sleep and anyhow.

use ferriswheel::{Direction, RainbowEffect};
use rgb::RGB8;
use rustyfarian_esp_idf_ws2812::WS2812RMT;
use std::thread;
use std::time::Duration;

fn main() -> anyhow::Result<()> {
    esp_idf_hal::sys::link_patches();

    let peripherals = esp_idf_hal::peripherals::Peripherals::take()?;

    const NUM_LEDS: usize = 12;
    let mut ws = WS2812RMT::new(peripherals.pins.gpio4)?;
    let mut effect = RainbowEffect::new(NUM_LEDS)
        .unwrap()
        .with_brightness(32)
        .with_speed(2)
        .unwrap()
        .with_direction(Direction::Clockwise);
    let mut colors = [RGB8::default(); NUM_LEDS];

    loop {
        effect.update(&mut colors).ok();
        ws.set_pixels_slice(&colors)?;
        thread::sleep(Duration::from_millis(50));
    }
}

The Red Bull: esp-hal, bare-metal, three circuits

The rustyfarian-esp-hal-ws2812 driver targets three circuits: ESP32-C3 (Monaco — tight, RISC-V), ESP32-C6 (Spa — modern, RISC-V, room to breathe), and ESP32-WROOM-32 (Silverstone — the classic Xtensa). The only difference between the C3 and C6 variants in the examples is the GPIO pin number.

Here is the C6 cylon example in full. It initializes the RMT peripheral at 80 MHz, configures a transmit channel on GPIO18, and then loops forever calling effect.update() followed by ws.set_pixels_slice():

#![no_std]
#![no_main]

esp_bootloader_esp_idf::esp_app_desc!();

use esp_hal::{
    delay::Delay,
    gpio::Level,
    main,
    rmt::{Rmt, TxChannelConfig, TxChannelCreator},
    time::Rate,
};
use esp_println::println;
use ferriswheel::CylonEffect;
use rgb::RGB8;
use rustyfarian_esp_hal_ws2812::{buffer_size, Ws2812Rmt, RMT_CLK_DIV};

const NUM_LEDS: usize = 12;
const N: usize = buffer_size(NUM_LEDS);

#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    println!("PANIC: {}", info);
    loop {}
}

#[main]
fn main() -> ! {
    let peripherals = esp_hal::init(esp_hal::Config::default());
    let rmt = Rmt::new(peripherals.RMT, Rate::from_mhz(80)).unwrap();
    let config = TxChannelConfig::default()
        .with_clk_divider(RMT_CLK_DIV)
        .with_idle_output_level(Level::Low)
        .with_idle_output(true)
        .with_carrier_modulation(false);
    let channel = rmt
        .channel0
        .configure_tx(peripherals.GPIO18, config)
        .unwrap();

    let mut ws = Ws2812Rmt::<N>::new(channel);
    let mut effect = CylonEffect::new(NUM_LEDS)
        .unwrap()
        .with_color(RGB8::new(0, 80, 180));
    let mut colors = [RGB8::default(); NUM_LEDS];
    let delay = Delay::new();

    loop {
        effect.update(&mut colors).unwrap();
        ws.set_pixels_slice(&colors).unwrap();
        delay.delay_millis(30u32);
    }
}

The C3 variant is identical except it uses GPIO4 instead of GPIO18. That is genuinely the whole diff.

The buffer_size helper is a const function that computes the RMT pulse buffer length at compile time, so there is no heap allocation anywhere in this binary.

Running an example in one command

The workspace uses just for task automation. To flash the C6 pulse example and open the serial monitor:

just run hal_c6_pulse

What landed in v0.3.0

Version 0.3.0 (2026-03-10) was the first release with the esp-hal driver. It added five new effects, SmartLedsWrite support across both drivers, and several cross-chip examples.

The v0.3.0 release notes have the full changelog.

The takeaway

The sans-io separation pays off in two directions at once: faster iteration (test effects on the laptop, flash only when the logic is already right) and genuine portability (same crate, different runtime, different chip).

Oh, and one more thing. There is a third car in the garage. An 8-bit ATmega328P — the vintage 1957 Maserati 250F of microcontrollers. No heap, no RMT peripheral, 2 KB of RAM, our driver is lacing up the gloves as we speak. Stay tuned.

Grab the code: datenkollektiv/rustyfarian-ws2812.