# ws2812-rmt
A minimal, `no_std` WS2812/NeoPixel driver over `esp-hal`'s RMT peripheral —
no third-party `smart-leds`/RMT abstraction in between.
## Why this exists
Extracted from real-hardware debugging on an ESP32-C6 after
[`esp-hal-smartled2`](https://crates.io/crates/esp-hal-smartled2) proved
unable to reliably update a WS2812 pixel to a *changing* value on repeated
writes — a single steady color always rendered correctly, but blink/breathe
patterns (i.e. anything other than "set it once and leave it") reliably
corrupted: either sticking on a stale frame, or bleeding a previously-sent
channel's value into later frames (a once-used blue value would visibly
tint every later color — red became pink, green became cyan).
Root cause: that crate — like most WS2812/RMT drivers, including an earlier
version of this one — relies on the RMT channel's idle-low state *between*
separate transmissions to serve as the WS2812's required ≥50µs reset/latch
pulse, rather than encoding that reset explicitly in the transmitted buffer
the way Espressif's own reference encoder
([`esp-idf/examples/peripherals/rmt/led_strip_simple_encoder`](https://github.com/espressif/esp-idf/blob/master/examples/peripherals/rmt/led_strip_simple_encoder/main/led_strip_example_main.c))
does. This crate always appends an explicit reset pulse, and transmits in
**blocking** mode (`Channel::transmit(..).wait()`, which busy-polls hardware
status registers — no interrupt/async-waker path at all) so a bug in an
async completion path can't be a variable either.
## RMT clock tick period is chip- and configuration-specific — verify it
Every `Timing` field is a count of RMT clock *ticks*, not nanoseconds, and
`Ws2812::new` takes an explicit `clk_divider` alongside it. The resulting
tick period depends on your chip's RMT clock source, which varies by chip
family (this crate exists partly *because* that assumption was wrong once
already, on an ESP32-C6) and on `Rmt::new`'s requested frequency combined
with that `clk_divider`.
Two presets are confirmed on real hardware so far:
| `Timing::WS2812_AT_12_5NS_TICK_ESP32C6` | ESP32-C6 | 80MHz | `2` | WS2812 (6-pin) |
| `Timing::WS2812B_AT_12_5NS_TICK_ESP32C3` | ESP32-C3 | 80MHz | `1` | WS2812B (4-pin) |
Both land on the same 12.5ns tick period, reached differently because the
two chips default their RMT clock source differently (a fixed 80MHz PLL on
C6, the APB clock — itself fixed at 80MHz on C3 — on C3). If you're on a
different chip or clock configuration, verify on real hardware — a logic
analyzer is the reliable way. Empirically, a wrong tick period here doesn't
fail loudly; it renders as anything from "stuck on one color" to
"completely dark".
## Usage
```rust
use ws2812_rmt::{buffer_len, Timing, Ws2812, RGB8};
let rmt = Rmt::new(peripherals.RMT, Rate::from_mhz(80))?;
let mut led = Ws2812::<{ buffer_len(1) }>::new(
rmt.channel0,
peripherals.GPIO1,
Timing::WS2812_AT_12_5NS_TICK_ESP32C6,
2, // clk_divider -- must match the constant's own doc comment
)?;
led.write(&[RGB8::new(0, 30, 0)])?;
```
For more than one pixel, size the buffer for however many you have:
```rust
const PIXELS: usize = 25;
let mut leds = Ws2812::<{ buffer_len(PIXELS) }>::new(
rmt.channel0,
peripherals.GPIO8,
Timing::WS2812B_AT_12_5NS_TICK_ESP32C3,
1,
)?;
leds.write(&[RGB8::new(0, 30, 0); PIXELS])?;
```
## Chip support
Gated behind Cargo features matching `esp-hal`'s own chip features — enable
the one matching your target:
`esp32`, `esp32c2`, `esp32c3`, `esp32c5`, `esp32c6`, `esp32c61`, `esp32h2`,
`esp32s2`, `esp32s3`.
CI only exercises `esp32c3`/`esp32c6` (RISC-V targets installable via plain
`rustup`, no Xtensa toolchain needed) — the others should work by the same
`esp-hal` feature-forwarding mechanism, but haven't been build-tested here.
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT license ([LICENSE-MIT](LICENSE-MIT))
at your option.