Skip to content

ws2812

WS2812 / SK6812 addressable LEDs.

Layer 3: this knows the LED’s wire protocol and nothing about the chip producing the pulses. It turns colours into (level, duration) pairs and hands them to whatever can emit them — on an ESP32 that is the RMT peripheral, but nothing here says so.

One wire, no clock. Each bit is a fixed-period pulse whose high time encodes the value:

Bit High Low Tolerance
0 350 ns 800 ns ±150 ns
1 700 ns 600 ns ±150 ns

Then a reset: the line held low for at least 50 µs, which latches the frame. Bits are sent most-significant first, and the byte order is GRB, not RGB — the single most common way to get this wrong, and it fails by showing the right brightness in the wrong colour, which reads like a hardware fault.

The tolerances are why this cannot be bit-banged from a task on a preemptive kernel: a timer interrupt landing mid-bit stretches a pulse by microseconds, and the LED reads whatever that turns into.

struct Timing {
pub zero_high_ns: u32,
pub zero_low_ns: u32,
pub one_high_ns: u32,
pub one_low_ns: u32,
}

Nanosecond timings for one bit, either value.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

struct Ws2812<E, const N: usize> { /* private fields */ }

A WS2812/SK6812 strip of N pixels, driven through E.

This is the type that keeps the [LedStrip] promise. It stages pixels and encodes the whole frame on [LedStrip::show], because these parts are clocked as one frame – there is no way to update a single pixel on the wire.

Implements: LedStrip

trait PulseEmitter {
fn ns_per_tick(&self) -> u32
fn begin(&mut self) -> Result<(), StripError>
fn emit(&mut self, pulses: &[(u16, u16)]) -> Result<(), StripError>
fn finish(&mut self) -> Result<(), StripError>
}

Something that can emit (high, low) pulse pairs with the timing this protocol needs.

This is the seam between the chip and the board. On an ESP32 it is the RMT peripheral; on another part it might be SPI or a timer. The driver states what it needs emitted and stays ignorant of what emits it. A frame is staged and then sent, rather than sent piece by piece. That split is not an implementation detail: a chained strip is one continuous pulse train, and a pause long enough to latch ends the frame. Emitting per-pixel would send N separate one-pixel frames and light only the first LED. The first version of this trait had exactly that bug, and it survived until something tried to drive a real panel with it.

Staging in the emitter rather than the driver is also what keeps the frame buffer out of the caller’s stack – 25 pixels is 600 entries, and a whole-frame scratch array is what overflowed a 4 KiB task stack once already.

const fn bit_ticks(bit: bool, t: Timing, ns_per_tick: u32) -> (u16, u16)

One bit as a (high ticks, low ticks) pair, in units of ns_per_tick.

Separated from any peripheral so the conversion can be tested: a rounding error here is a pulse outside tolerance, and the symptom is a wrong colour rather than an error.

References: Timing

fn encode(colours: &[Rgb], timing: Timing, ns_per_tick: u32, out: &mut [(u16, u16)]) -> Option<usize>

Expand colours into per-bit (high, low) tick pairs, most-significant bit first, in GRB order.

Returns how many entries were written, or None if out is too small — never a partial frame. A truncated frame lights some LEDs and leaves the rest holding their previous colour, which looks like a wiring fault.

References: Rgb, Timing

const fn frame_bits(led_count: usize) -> usize

Number of bits a frame of led_count LEDs occupies.

const fn to_grb(c: Rgb) -> [u8; 3]

Wire order for this family: green, red, blue.

The single most common way to get these parts wrong, and it fails by showing the right brightness in the wrong colour, which reads like a hardware fault.

References: Rgb