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
+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;
}
}