f2d06278af
The boards this targets carry an addressable LED on GPIO16 rather than a plain LED, so the GPIO25 heartbeat had nothing to drive. Green means running, blue flashes on each measurement, red means the sensor is failing, with the most urgent condition winning. A healthy node reads as dim green with a blue pip once a second, so a stalled or failing one is obvious with no serial port attached. Driven from PIO directly rather than via ws2812-pio, whose current release pins rp2040-hal 0.11 against this project's 0.12. Those are semver incompatible: cargo would build both HALs and the driver would not accept our PIO, state machine or pin types. The alternative was pinning the whole project back a HAL version, for four instructions of PIO. Generating the waveform in the state machine matters beyond convenience. The bus tasks preempt the LED task freely, and a CPU-timed WS2812 would glitch the moment a frame arrived mid-update. The >50 us latch gap needs no explicit delay either, since repaints are 25 ms apart. Byte order is a named constant rather than two transposed shifts, because it is a property of the part fitted and not of the protocol: many parts sold as WS2812B, along with the WS2811 and SK6812 families, want red first. These do. The unused ChannelOrder variant stays so the choice, and the fix if a board differs, is legible. The task derives everything from existing shared state — the reading's own timestamp already says when the last measurement landed — so nothing is published purely to drive an LED. Colours confirmed on hardware after correcting the channel order; bus suite still 15/15 and sensor counters clean, so PIO0 disturbs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.7 KiB
Rust
181 lines
6.7 KiB
Rust
//! 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<Rgb>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|