//! WS2812B status indicator, driven from PIO. //! //! # Why this is hand-rolled //! //! `ws2812-pio` would be the obvious choice, but its latest release pins //! `rp2040-hal` 0.11 while this firmware is on 0.12. Those are semver //! incompatible, so its `PIO`, state-machine and `Pin` types will not unify with //! ours — cargo would build both HALs and the driver simply would not accept our //! peripherals. The wire protocol is four PIO instructions, so owning it is far //! cheaper than pinning the whole project to an older HAL. //! //! # How it works //! //! A WS2812B bit is 1.25 µs long, and the value is encoded in the duty cycle: a //! one is ~0.8 µs high then ~0.45 µs low, a zero ~0.4 µs high then ~0.85 µs low. //! Latching happens after the line is held low for >50 µs. //! //! The program below spends a fixed **10 state-machine cycles per bit**, split //! 2/5/3, and drives the line from the side-set pin. Clocking the machine at //! 8 MHz therefore yields the 1.25 µs bit period, with the high phase lasting //! either 2 or 7 cycles depending on the bit. Timing is generated entirely by //! the state machine, so it is immune to whatever the CPU is doing — which //! matters here, because the bus tasks preempt the LED task freely. //! //! The >50 µs latch gap needs no explicit delay: the caller updates at most //! every few tens of milliseconds, which is three orders of magnitude longer. use fugit::HertzU32; use rp2040_hal::{ pac, pio::{PIOBuilder, PIOExt, PinDir, Running, ShiftDirection, StateMachine, Tx, SM0}, }; use crate::board::{StatusLedPin, STATUS_LED_GPIO}; /// State-machine cycles per WS2812 bit, as laid out by the program below. const CYCLES_PER_BIT: u32 = 10; /// WS2812B bit rate: 1.25 µs per bit. const BIT_RATE_HZ: u32 = 800_000; /// Order in which a part expects its three colour bytes on the wire. /// /// Only one variant is selected in any given build, so the other is necessarily /// unconstructed. It stays because naming both is what makes the choice — and /// the fix, if a board turns out to differ — obvious. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub enum ChannelOrder { /// Green, red, blue — the canonical WS2812B order. Grb, /// Red, green, blue — used by many parts sold as WS2812B, and by the WS2811 /// and SK6812 families. Rgb, } /// Byte order of the part actually fitted to this board. /// /// Determined by testing, not from the datasheet name: with [`ChannelOrder::Grb`] /// this board's red and green come out swapped. Blue is unaffected either way, /// which is the giveaway that only the first two bytes are transposed. If a /// future board shows green where you asked for red, this is the line to change. const CHANNEL_ORDER: ChannelOrder = ChannelOrder::Rgb; /// A 24-bit colour. /// /// Kept local rather than using `smart-leds`, which would add a dependency and /// its own version churn for one three-field struct. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Rgb { /// Red channel. pub r: u8, /// Green channel. pub g: u8, /// Blue channel. pub b: u8, } impl Rgb { /// Dark. pub const OFF: Self = Self { r: 0, g: 0, b: 0 }; /// Running normally. pub const RUNNING: Self = Self { r: 0, g: 12, b: 0 }; /// A measurement was just taken. pub const MEASURING: Self = Self { r: 0, g: 0, b: 40 }; /// The sensor is failing. pub const ERROR: Self = Self { r: 48, g: 0, b: 0 }; /// Pack into the order the fitted part expects, each byte most-significant /// bit first and the whole thing left-aligned so a 24-bit autopull shifts /// exactly one pixel out and stops. const fn to_wire_word(self) -> u32 { let (first, second) = match CHANNEL_ORDER { ChannelOrder::Grb => (self.g, self.r), ChannelOrder::Rgb => (self.r, self.g), }; ((first as u32) << 24) | ((second as u32) << 16) | ((self.b as u32) << 8) } } /// A single WS2812B on a PIO state machine. pub struct StatusLed { tx: Tx<(pac::PIO0, SM0)>, /// Held so the state machine cannot be reclaimed or reconfigured elsewhere. /// Dropping it happens to leave the machine running today, but that is an /// absence of a `Drop` impl rather than a promise, so we keep ownership. _sm: StateMachine<(pac::PIO0, SM0), Running>, /// Last colour written, so a repeated set costs nothing. current: Option, } impl StatusLed { /// Claim PIO0's first state machine and drive `pin` with it. pub fn new( pio0: pac::PIO0, _pin: StatusLedPin, resets: &mut pac::RESETS, system_clock: HertzU32, ) -> Self { // 2 cycles high for a zero, 7 for a one, out of 10 per bit. The delays // are one less than the cycle count because the instruction itself takes // one cycle. let program = pio::pio_asm!( ".side_set 1", ".wrap_target", "bitloop:", " out x, 1 side 0 [2]", // low tail of the previous bit " jmp !x do_zero side 1 [1]", // every bit opens with a high pulse "do_one:", " jmp bitloop side 1 [4]", // a one holds the line high "do_zero:", " nop side 0 [4]", // a zero drops it early ".wrap", ); let (mut pio, sm0, _, _, _) = pio0.split(resets); let installed = pio .install(&program.program) .expect("WS2812 program does not fit in PIO0"); // Fixed-point 16.8 divisor from the system clock down to 8 MHz. At the // stock 125 MHz this is 15 + 160/256 = 15.625. let divisor = (u64::from(system_clock.to_Hz()) * 256) / u64::from(BIT_RATE_HZ * CYCLES_PER_BIT); let (mut sm, _rx, tx) = PIOBuilder::from_installed_program(installed) .side_set_pin_base(STATUS_LED_GPIO) // Colours are shifted out most-significant bit first, and autopull // at 24 bits means one FIFO word is exactly one pixel. .out_shift_direction(ShiftDirection::Left) .autopull(true) .pull_threshold(24) .clock_divisor_fixed_point((divisor / 256) as u16, (divisor % 256) as u8) .build(sm0); sm.set_pindirs([(STATUS_LED_GPIO, PinDir::Output)]); Self { tx, _sm: sm.start(), current: None, } } /// Show `colour`. /// /// Dropped rather than blocked if the FIFO is somehow full, since a missed /// status update is not worth stalling a task over — the next tick repaints. pub fn set(&mut self, colour: Rgb) { if self.current == Some(colour) { return; } if self.tx.write(colour.to_wire_word()) { self.current = Some(colour); } } }