Replace the plain status LED with a WS2812B on PIO

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>
This commit is contained in:
Oliver Walter
2026-07-28 21:26:14 +02:00
parent 46c26796cc
commit f2d06278af
6 changed files with 302 additions and 24 deletions
Generated
+1
View File
@@ -1236,6 +1236,7 @@ dependencies = [
"fugit",
"heapless",
"panic-probe",
"pio",
"portable-atomic",
"rp2040-boot2",
"rp2040-hal",
+33 -4
View File
@@ -39,7 +39,7 @@ performs no I/O and touches no peripherals; everything hardware-shaped lives in
| 2 | SIO output | transceiver `DE` **and** `/RE`, tied together |
| 14 | I2C1 SDA | SHT31 `SDA` |
| 15 | I2C1 SCL | SHT31 `SCL` |
| 25 | SIO output | status LED, active high |
| 16 | PIO0 | WS2812B status indicator, data in |
| — | SWD | `SWCLK`/`SWDIO` on the dedicated pads, for the probe |
Why this arrangement:
@@ -54,8 +54,10 @@ Why this arrangement:
- **I2C1 on GPIO 14/15, physically far from the RS485 block.** The sensor is the
noise-sensitive part and the differential driver is the noisy part. Using I2C1
also leaves I2C0 free.
- **GPIO 25 for the LED** matches the Raspberry Pi Pico, so this image blinks on
a bare Pico as well — handy for bring-up before the real board exists.
- **GPIO 16 for the WS2812B**, because that is where the addressable LED sits on
the boards this targets. Any GPIO would do, since PIO can drive the waveform
from any pin, but it is kept clear of the RS485 block so the LED's switching
current does not share a return path with the differential pair.
### Hardware notes the firmware cannot enforce
@@ -70,6 +72,33 @@ Why this arrangement:
reset command.
- `.boot2` is built for a **W25Q080-class** QSPI flash. Change
`BOOT2_FIRMWARE` in `firmware/src/main.rs` if the board carries something else.
- The **WS2812B** wants 100 nF of local decoupling and draws tens of mA at full
brightness. The firmware keeps it deliberately dim, which is both easier to
read on a bench and easier on that current.
## Status indicator
The WS2812B on GPIO16 reports health at a glance, with no serial port attached:
| Colour | Meaning | Condition |
|---|---|---|
| Green, dim | running | default |
| Blue | measurement taken | within 120 ms of a successful reading |
| Red | sensor error | any failed poll, or `SENSOR_OK` clear |
The most urgent condition wins: red over blue, blue over green. So a healthy node
reads as dim green with a distinct blue pip once a second, and a stalled or
failing one is obvious immediately.
Driven from PIO (`firmware/src/status_led.rs`) rather than via `ws2812-pio`, whose
current release pins `rp2040-hal` 0.11 against this project's 0.12 — see that
module's header. Generating the waveform in the state machine also means the bus
tasks can preempt the LED task freely without glitching it.
Note that **many parts sold as WS2812B expect red-first rather than green-first
byte order**, including the boards this targets. That is one constant,
`CHANNEL_ORDER`; if red and green come out swapped while blue is correct, it is
the only thing to change.
## Protocol
@@ -403,7 +432,7 @@ drives it from registers thereafter.
| 1 | `sensor_task` | async | Poll the SHT31 once a second |
| 1 | `usb_irq` | hardware | Service the USB CDC diagnostic port |
| 1 | `usb_report` | async | Emit a diagnostic line once a second |
| 1 | `heartbeat` | async | Blink the status LED |
| 1 | `status_led` | async | Repaint the WS2812B indicator |
The central decision is that **requests are answered entirely from a cached
reading**. An SHT31 high-repeatability conversion takes up to 15 ms — far longer
+5
View File
@@ -32,6 +32,11 @@ portable-atomic = { version = "1", features = ["critical-section"] }
embedded-hal = "1.0"
fugit = "0.3"
# WS2812 status indicator. Version must track rp2040-hal's own `pio` dependency
# so the assembled program type matches what its PIO driver accepts. The
# assembler proc macro comes along with it, so pio-proc needs no direct entry.
pio = "0.3"
defmt = { workspace = true }
defmt-rtt = "1.0"
panic-probe = { version = "1.0", features = ["print-defmt"] }
+30 -9
View File
@@ -11,7 +11,7 @@
//! │ 2 │ SIO out │ transceiver DE and /RE, tied together │
//! │ 14 │ I2C1 SDA │ SHT31 SDA │
//! │ 15 │ I2C1 SCL │ SHT31 SCL │
//! │ 25SIO outstatus LED, active high
//! │ 16PIO0 WS2812B status indicator, data in
//! │ SWD │ │ SWCLK/SWDIO on the dedicated pads, for probe-rs │
//! └────────┴───────────┴─────────────────────────────────────────────────────┘
//! ```
@@ -33,11 +33,17 @@
//! - **I2C1 on GPIO 14/15, physically distant from the RS485 block.** The
//! sensor is the noise-sensitive part; the differential driver is the noisy
//! part. Using I2C1 leaves I2C0 free.
//! - **GPIO 25 for the LED** matches the Raspberry Pi Pico, so this firmware
//! blinks on a bare Pico too, which is convenient for bring-up.
//! - **GPIO 16 for the WS2812B**, because that is where the addressable LED sits
//! on the boards this is built for. Any GPIO would do — PIO can drive the
//! waveform from any pin — but it is kept clear of the RS485 block so the LED's
//! switching current does not share a return path with the differential pair.
//!
//! # Hardware notes not expressible in firmware
//!
//! - The WS2812B needs its own decoupling (100 nF close to the package) and
//! draws tens of mA at full brightness. The firmware keeps it deliberately dim,
//! partly to be readable on a bench and partly to keep that current small.
//!
//! - Fit the 120 Ω termination only at the two physical ends of the segment, and
//! fail-safe bias resistors at one point on the segment.
//! - The SHT31 ADDR pin selects the I2C address: low for `0x44`, high for
@@ -50,8 +56,9 @@
use rp2040_hal::{
gpio::{
bank0::{Gpio0, Gpio1, Gpio14, Gpio15, Gpio2, Gpio25},
FunctionI2c, FunctionSioOutput, FunctionUart, Pin, PullDown, PullNone, PullUp,
bank0::{Gpio0, Gpio1, Gpio14, Gpio15, Gpio16, Gpio2},
FunctionI2c, FunctionPio0, FunctionSioOutput, FunctionUart, Pin, PullDown, PullNone,
PullUp,
},
i2c::I2C,
pac,
@@ -112,8 +119,16 @@ pub const STALE_AFTER_MS: u32 = 3_000;
/// starts attempting soft resets.
pub const SENSOR_FAULT_THRESHOLD: u32 = 5;
/// Status LED blink half-period.
pub const HEARTBEAT_MS: u32 = 500;
// ------------------------------------------------------------- status LED ---
/// How often the status indicator is repainted.
pub const LED_TICK_MS: u32 = 25;
/// How long the indicator stays blue after a measurement.
///
/// Short relative to [`SENSOR_PERIOD_MS`], so a healthy node reads as green with
/// a distinct blue pip once a second rather than a muddy blend of the two.
pub const LED_MEASURING_MS: u32 = 120;
// ------------------------------------------------------- USB diagnostics ---
@@ -160,5 +175,11 @@ pub type SclPin = Pin<Gpio15, FunctionI2c, PullUp>;
/// The configured I2C controller the sensor driver talks through.
pub type SensorI2c = I2C<pac::I2C1, (SdaPin, SclPin)>;
/// Status LED.
pub type StatusLedPin = Pin<Gpio25, FunctionSioOutput, PullDown>;
/// GPIO number carrying the WS2812B data line.
///
/// Must match [`StatusLedPin`]: PIO addresses pins by number, so the type alias
/// alone is not enough to configure the state machine.
pub const STATUS_LED_GPIO: u8 = 16;
/// WS2812B data line, driven by PIO0 rather than as a plain output.
pub type StatusLedPin = Pin<Gpio16, FunctionPio0, PullDown>;
+53 -11
View File
@@ -11,7 +11,7 @@
//! | 1 | `sensor_task` | async | Poll the SHT31 once a second |
//! | 1 | `usb_irq` | hardware | Service the USB CDC diagnostic port |
//! | 1 | `usb_report` | async | Emit a diagnostic line once a second |
//! | 1 | `heartbeat` | async | Blink the status LED |
//! | 1 | `status_led` | async | Repaint the WS2812B indicator |
//!
//! The USB port is a diagnostic aid, not part of the protocol: it lets the node
//! be verified over a plain USB cable with no SWD probe and no RS485 adapter. It
@@ -31,6 +31,7 @@
mod board;
mod rs485;
mod sensor;
mod status_led;
use defmt_rtt as _;
use panic_probe as _;
@@ -58,7 +59,6 @@ mod app {
use core::fmt::Write as _;
use embedded_hal::digital::StatefulOutputPin;
use fugit::RateExtU32;
use heapless::String;
use rp2040_hal as hal;
@@ -79,6 +79,7 @@ mod app {
use crate::board::{self, Instant};
use crate::rs485::{self, Rs485, RxBuffer, FIFO_DEPTH};
use crate::sensor::Sht31;
use crate::status_led::{Rgb, StatusLed};
/// A cached sensor reading and when it was taken.
///
@@ -128,7 +129,7 @@ mod app {
#[local]
struct Local {
sensor: Sht31<board::SensorI2c>,
led: board::StatusLedPin,
led: StatusLed,
}
// The USB bus allocator must outlive the device and class that borrow from
@@ -188,7 +189,13 @@ mod app {
);
let sensor = Sht31::new(i2c, board::SENSOR_I2C_ADDR);
let led = pins.gpio25.reconfigure();
// ---- WS2812B status indicator on PIO0 --------------------------------
let led = StatusLed::new(
cx.device.PIO0,
pins.gpio16.reconfigure(),
&mut cx.device.RESETS,
clocks.system_clock.freq(),
);
// ---- USB CDC diagnostics --------------------------------------------
// `force_vbus_detect_bit` is true because the Pico ties VBUS to the chip;
@@ -223,7 +230,7 @@ mod app {
);
sensor_task::spawn().ok();
heartbeat::spawn().ok();
status_led::spawn().ok();
usb_report::spawn().ok();
(
@@ -623,13 +630,48 @@ mod app {
sensor.read_measurement()
}
/// Blink the status LED. Says the executor is alive; says nothing about the
/// bus or the sensor, both of which are reported over the protocol instead.
#[task(priority = 1, local = [led])]
async fn heartbeat(cx: heartbeat::Context) {
/// Repaint the WS2812B status indicator.
///
/// Green for running, blue for a measurement, red for a sensor error, with
/// the most urgent condition winning: red over blue, blue over green. Read at
/// a glance, a healthy node is dim green with a blue pip once a second, so a
/// stalled or failing node is obvious without a serial port.
///
/// Derived entirely from existing shared state — the reading's own timestamp
/// already says when the last measurement landed, so nothing extra is
/// published just to drive an LED.
#[task(priority = 1, shared = [reading, state], local = [led])]
async fn status_led(mut cx: status_led::Context) {
let tick = board::Duration::millis(u64::from(board::LED_TICK_MS));
let flash = board::Duration::millis(u64::from(board::LED_MEASURING_MS));
loop {
cx.local.led.toggle().ok();
Mono::delay(board::Duration::millis(u64::from(board::HEARTBEAT_MS))).await;
let now = Mono::now();
let measured_recently = cx
.shared
.reading
.lock(|slot| slot.as_ref().map(|r| r.taken_at))
.and_then(|taken_at| now.checked_duration_since(taken_at))
.is_some_and(|age| age < flash);
let (sensor_ok, failures) = cx
.shared
.state
.lock(|s| (s.sensor_ok, s.consecutive_failures));
let colour = if failures > 0 || !sensor_ok {
// Any failed poll shows red, not just a latched hard fault: a
// sensor dropping the occasional reading is worth seeing.
Rgb::ERROR
} else if measured_recently {
Rgb::MEASURING
} else {
Rgb::RUNNING
};
cx.local.led.set(colour);
Mono::delay(tick).await;
}
}
+180
View File
@@ -0,0 +1,180 @@
//! 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);
}
}
}