diff --git a/Cargo.lock b/Cargo.lock index 78e3743..bee4c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1236,6 +1236,7 @@ dependencies = [ "fugit", "heapless", "panic-probe", + "pio", "portable-atomic", "rp2040-boot2", "rp2040-hal", diff --git a/README.md b/README.md index 383930f..0c73357 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index d070279..d75c1d5 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -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"] } diff --git a/firmware/src/board.rs b/firmware/src/board.rs index e57f4eb..cc4d724 100644 --- a/firmware/src/board.rs +++ b/firmware/src/board.rs @@ -11,7 +11,7 @@ //! │ 2 │ SIO out │ transceiver DE and /RE, tied together │ //! │ 14 │ I2C1 SDA │ SHT31 SDA │ //! │ 15 │ I2C1 SCL │ SHT31 SCL │ -//! │ 25 │ SIO out │ status LED, active high │ +//! │ 16 │ PIO0 │ 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; /// The configured I2C controller the sensor driver talks through. pub type SensorI2c = I2C; -/// Status LED. -pub type StatusLedPin = Pin; +/// 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; diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 4cb6527..cb1c16d 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -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, - 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; } } diff --git a/firmware/src/status_led.rs b/firmware/src/status_led.rs new file mode 100644 index 0000000..7007c9c --- /dev/null +++ b/firmware/src/status_led.rs @@ -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, +} + +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); + } + } +}