Files
wiredsensor/firmware/src/main.rs
T
Oliver Walter 5b9c09df3b Speak Modbus RTU instead of a custom command protocol
ESPHome only has built-in support for Modbus RTU over RS485, so the
application layer moves to it. The transport already conformed: the CRC
was CRC-16/MODBUS, the line rate 19200 8N1, the delimiter a 3.5-character
idle gap pinned at 1750 us above 19200, and the address space 1..247 with
broadcast never answered. Only the bytes between the address and the CRC
change, and no external crate is needed for a read-only server.

frame.rs drops the explicit LEN byte, which RTU does not have — a
request's length is implied by its function code. The parser gets simpler
and MAX_FRAME grows to the RTU limit of 256. The CRC is now the only
integrity check there is, so a truncated frame fails it rather than a
length comparison; a test asserts every single-bit corruption is still
caught, and another pins our encoding against the published example
01 03 00 6B 00 03 74 17.

proto.rs becomes a Modbus server: function codes 0x03 and 0x04 over one
19-register table, 0x08 sub-function 0x0000 as the link test that
replaces PING, and the four standard exception codes. Registers are
big-endian with 32-bit values high word first, which is what masters and
ESPHome's S_DWORD assume. Snapshot::registers renders the whole map and
reads slice it, so a range read cannot disagree with a single-register
read of the same address — the alternative, assembling only the requested
registers per read, has no such guarantee.

Both read function codes serve the same table. That is not only for
masters that implement just 0x03: a read overlapping the measurement
block is refused with SERVER_DEVICE_FAILURE when the sensor has never
produced a reading, and ESPHome coalesces adjacent registers of one type
into a single command, so diagnostic entities merged into that command
would go unavailable along with the measurement they were meant to
explain. Requesting them under the other function code puts them in their
own command. The generated ESPHome config does this.

The firmware barely changes, because dispatch's signature does not: flags
widen to u16, buffers follow MAX_FRAME, and comments name registers
rather than commands. Now ~35 KiB flash and ~2.6 KiB RAM.

tools/wiredsensor.py is rewritten and grows from 15 checks to 21, adding
agreement between the two function codes, sub-range consistency, and a
per-code assertion for every exception. It stays a hand-written Modbus
implementation rather than moving to pymodbus: half these checks inject
deliberately malformed frames, and a conforming client library exists
precisely to make those unconstructable. It also keeps the wire format
independent of wiredsensor-core, so a shared bug cannot cancel itself
out.

Not yet exercised on hardware — the host tests and register-map
cross-checks pass, but the timing-dependent behaviour needs
`wiredsensor.py test` against a real bus.

This replaces the old protocol rather than joining it; command codes
0x01..0x04 are all valid Modbus function codes, so the two cannot share a
segment. PROTOCOL_VERSION is 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:04:21 +02:00

819 lines
32 KiB
Rust

//! RS485 temperature and humidity node: RP2040 + SHT31.
//!
//! # Concurrency structure
//!
//! Three priority levels, chosen so the bus is never the thing that waits:
//!
//! | Prio | Task | Kind | Job |
//! |------|---------------|----------|--------------------------------------------|
//! | 3 | `uart0_irq` | hardware | Empty the 32-byte RX FIFO, timestamp bytes |
//! | 2 | `frame_gap` | async | Detect the frame gap, answer the request |
//! | 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 | `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
//! sits at the lowest priority so it can never delay a bus reply.
//!
//! The key design decision is that **a request is answered entirely from a
//! cached reading**. An SHT31 high-repeatability conversion takes up to 15 ms,
//! which is far longer than the turnaround a master expects, so the sensor is
//! polled on its own schedule at the lowest priority and `frame_gap` never
//! touches I2C. RTIC's priority ceilings then guarantee that a conversion in
//! progress cannot delay a reply, and the compiler checks the resulting resource
//! sharing rather than us reasoning about it.
#![no_std]
#![no_main]
mod board;
mod rs485;
mod sensor;
mod status_led;
use defmt_rtt as _;
use panic_probe as _;
use rtic_monotonics::rp2040::prelude::*;
// A 1 MHz, 64-bit monotonic on the RP2040 TIMER peripheral. This claims
// TIMER_IRQ_0, which is why the RTIC dispatchers below use the software-only
// vectors instead.
rp2040_timer_monotonic!(Mono);
/// Second-stage bootloader.
///
/// The boot ROM copies these 256 bytes into RAM and runs them to configure XIP
/// for the attached QSPI flash before any of our code executes. `W25Q080` covers
/// the parts fitted to the Pico and most RP2040 modules; change it if the board
/// carries something else.
#[link_section = ".boot2"]
#[no_mangle]
#[used]
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
#[rtic::app(device = rp2040_hal::pac, peripherals = true, dispatchers = [SW0_IRQ, SW1_IRQ])]
mod app {
use super::*;
use core::fmt::Write as _;
use fugit::RateExtU32;
use heapless::String;
use rp2040_hal as hal;
use rp2040_hal::usb::UsbBus;
use rp2040_hal::Clock;
use rtic::Mutex;
use usb_device::{
bus::UsbBusAllocator,
device::{StringDescriptors, UsbDevice, UsbDeviceBuilder, UsbVidPid},
};
use usbd_serial::SerialPort;
use wiredsensor_core::{
frame::{ParseError, MAX_FRAME},
proto::{self, flag, Info, Measurement, Outcome, Snapshot, Status},
sht3x,
};
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.
///
/// `pub` because RTIC's generated resource proxies name it in their public
/// signatures.
pub struct Reading {
temp_milli_c: i32,
rh_milli_pct: i32,
taken_at: Instant,
}
/// Everything behind the diagnostic and identity registers that is
/// discovered at runtime rather than fixed at build time.
#[derive(Default)]
pub struct DeviceState {
/// Factory serial read from the sensor at start-up, 0 if unavailable.
sensor_serial: u32,
/// Raw SHT31 status register from the most recent successful read.
sensor_status: u16,
i2c_errors: u16,
sensor_crc_errors: u16,
frame_errors: u16,
crc_errors: u16,
consecutive_failures: u32,
sensor_ok: bool,
ever_measured: bool,
uart_error: bool,
sensor_reset_seen: bool,
}
#[shared]
struct Shared {
/// The PL011 and the transceiver direction pin.
bus: Rs485,
/// Bytes received since the last inter-frame gap.
rx: RxBuffer,
/// Latest reading, `None` until the first successful poll.
reading: Option<Reading>,
/// Counters and health bits.
state: DeviceState,
/// USB CDC diagnostics. Independent of the RS485 protocol — these exist
/// so the node can be verified over a plain USB cable with no SWD probe.
usb_dev: UsbDevice<'static, UsbBus>,
serial: SerialPort<'static, UsbBus>,
}
#[local]
struct Local {
sensor: Sht31<board::SensorI2c>,
led: StatusLed,
}
// The USB bus allocator must outlive the device and class that borrow from
// it. An init-local resource gives us that `'static` without a `static mut`.
#[init(local = [usb_bus: Option<UsbBusAllocator<UsbBus>> = None])]
fn init(mut cx: init::Context) -> (Shared, Local) {
let mut watchdog = hal::watchdog::Watchdog::new(cx.device.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
board::XTAL_FREQ_HZ,
cx.device.XOSC,
cx.device.CLOCKS,
cx.device.PLL_SYS,
cx.device.PLL_USB,
&mut cx.device.RESETS,
&mut watchdog,
)
.expect("clock init failed — check XTAL_FREQ_HZ against the fitted crystal");
Mono::start(cx.device.TIMER, &cx.device.RESETS);
let sio = hal::Sio::new(cx.device.SIO);
let pins = hal::gpio::Pins::new(
cx.device.IO_BANK0,
cx.device.PADS_BANK0,
sio.gpio_bank0,
&mut cx.device.RESETS,
);
// ---- RS485 on UART0 ------------------------------------------------
// Let the HAL do the baud-divisor and line-control setup, which is
// fiddly and easy to get subtly wrong, then reclaim the raw peripheral:
// the runtime path needs RTIM and BUSY, which the HAL does not expose.
let uart_pins: board::Rs485Pins = (pins.gpio0.reconfigure(), pins.gpio1.reconfigure());
let uart =
hal::uart::UartPeripheral::new(cx.device.UART0, uart_pins, &mut cx.device.RESETS)
.enable(
hal::uart::UartConfig::new(
board::BAUD_RATE.Hz(),
hal::uart::DataBits::Eight,
None,
hal::uart::StopBits::One,
),
clocks.peripheral_clock.freq(),
)
.expect("uart baud rate not achievable from the peripheral clock");
let (uart0, uart_pins) = uart.free();
let bus = Rs485::new(uart0, uart_pins, pins.gpio2.reconfigure());
// ---- SHT31 on I2C1 --------------------------------------------------
let i2c = hal::i2c::I2C::new_controller(
cx.device.I2C1,
pins.gpio14.reconfigure(),
pins.gpio15.reconfigure(),
board::I2C_FREQ_HZ.Hz(),
&mut cx.device.RESETS,
clocks.system_clock.freq(),
);
let sensor = Sht31::new(i2c, board::SENSOR_I2C_ADDR);
// ---- 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;
// on a board with no VBUS sense this makes the block enumerate whenever
// it is powered, which is harmless but not free.
let usb_bus: &'static UsbBusAllocator<UsbBus> =
cx.local.usb_bus.insert(UsbBusAllocator::new(UsbBus::new(
cx.device.USBCTRL_REGS,
cx.device.USBCTRL_DPRAM,
clocks.usb_clock,
true,
&mut cx.device.RESETS,
)));
let serial = SerialPort::new(usb_bus);
let usb_dev = UsbDeviceBuilder::new(usb_bus, UsbVidPid(board::USB_VID, board::USB_PID))
.strings(&[StringDescriptors::default()
.manufacturer("wiredsensor")
.product("wiredsensor RS485 node")
.serial_number("0001")])
.expect("USB string descriptors")
.device_class(usbd_serial::USB_CLASS_CDC)
.build();
defmt::info!(
"wiredsensor v{}.{}.{} up: unit {:#04x}, {} baud, gap {} us",
board::FW_MAJOR,
board::FW_MINOR,
board::FW_PATCH,
board::UNIT_ADDRESS,
board::BAUD_RATE,
board::INTER_FRAME_GAP_US,
);
sensor_task::spawn().ok();
status_led::spawn().ok();
usb_report::spawn().ok();
(
Shared {
bus,
rx: RxBuffer::new(),
reading: None,
state: DeviceState::default(),
usb_dev,
serial,
},
Local { sensor, led },
)
}
/// Highest-priority work in the system: get bytes out of the FIFO before it
/// overruns and record when the last one arrived.
///
/// Deliberately does no parsing. At 19200 baud a character arrives every
/// ~520 µs — around 65,000 core cycles — so there is plenty of slack, but
/// keeping the ISR to a FIFO drain is what bounds the jitter that everything
/// else in the system sees.
#[task(binds = UART0_IRQ, priority = 3, shared = [bus, rx])]
fn uart0_irq(mut cx: uart0_irq::Context) {
let now = Mono::now();
let start_gap_timer = cx.shared.bus.lock(|bus| {
cx.shared.rx.lock(|rx| {
bus.drain_into(rx, now);
// One gap-detection task per frame. It re-reads `last_byte_at`
// itself, so bytes arriving later need no further spawn.
if rx.len > 0 && !rx.gap_pending {
rx.gap_pending = true;
true
} else {
false
}
})
});
if start_gap_timer {
frame_gap::spawn().ok();
}
}
/// Wait out the inter-frame gap, then answer the frame.
///
/// Runs above the sensor so a conversion in progress can never delay a reply,
/// and below the UART ISR so incoming bytes are never dropped while we work.
#[task(priority = 2, shared = [bus, rx, reading, state])]
async fn frame_gap(mut cx: frame_gap::Context) {
let gap = board::Duration::micros(board::INTER_FRAME_GAP_US as u64);
// Bytes may still be arriving. Rather than cancelling and respawning a
// timer on every byte, drain whatever has landed, re-read the last
// arrival time, and sleep again.
//
// Draining here as well as in the ISR is load-bearing, not belt and
// braces. The watermark interrupt fires mid-frame once 16 bytes are in
// the FIFO; the rest of the frame then sits *below* the watermark and
// raises no interrupt at all until the PL011's receive timeout
// eventually fires. Measuring the gap from the ISR's mid-frame
// timestamp would therefore declare the frame complete while its tail
// was still arriving, truncating every frame longer than the watermark.
// Draining before each decision means a byte that arrived can always
// push the deadline back.
//
// The cost is that the gap is measured from when we observed the byte
// rather than when it landed, so the reply is up to one gap period
// later than strictly necessary. Correctness is worth 1.8 ms.
loop {
let last_byte_at = cx.shared.bus.lock(|bus| {
cx.shared.rx.lock(|rx| {
bus.drain_into(rx, Mono::now());
rx.last_byte_at
})
});
let deadline = last_byte_at + gap;
if Mono::now() >= deadline {
break;
}
Mono::delay_until(deadline).await;
}
// Take the frame out of the shared buffer so the ISR can begin the next.
let mut req = [0u8; MAX_FRAME];
let (len, overflow, line_error) = cx.shared.rx.lock(|rx| {
let len = rx.len.min(MAX_FRAME);
req[..len].copy_from_slice(&rx.buf[..len]);
let taken = (len, rx.overflow, rx.line_error);
rx.reset();
taken
});
// A frame that outgrew the buffer or arrived with line errors is not
// trustworthy. Count it and stay off the wire: answering a frame whose
// address byte we cannot trust risks colliding with the node that was
// actually addressed.
if overflow || line_error {
cx.shared.state.lock(|s| {
s.uart_error |= line_error;
s.frame_errors = s.frame_errors.saturating_add(1);
});
defmt::warn!(
"discarded frame: {} bytes, overflow={}, line_error={}",
len,
overflow,
line_error
);
cx.shared.rx.lock(|rx| rx.gap_pending = false);
return;
}
let snapshot = snapshot(&mut cx);
let mut out = [0u8; MAX_FRAME];
let reply_len = match proto::dispatch(board::UNIT_ADDRESS, &req[..len], &snapshot, &mut out)
{
Outcome::Reply(reply) => Some(reply.len()),
Outcome::Silent => None,
Outcome::Malformed(e) => {
cx.shared.state.lock(|s| match e {
ParseError::BadCrc => s.crc_errors = s.crc_errors.saturating_add(1),
_ => s.frame_errors = s.frame_errors.saturating_add(1),
});
defmt::debug!("rejected frame of {} bytes: {}", len, e);
None
}
};
if let Some(n) = reply_len {
transmit(&mut cx, &out[..n]).await;
}
cx.shared.rx.lock(|rx| rx.gap_pending = false);
}
/// Assemble the device state `dispatch` is allowed to see.
///
/// Locks are taken one resource at a time: a reading and the counters need
/// not be mutually consistent, and holding both at once would raise the
/// ceiling for no benefit.
fn snapshot(cx: &mut frame_gap::Context<'_>) -> Snapshot {
let now = Mono::now();
let measurement = cx.shared.reading.lock(|slot| {
slot.as_ref().map(|r| {
let age_ms = now
.checked_duration_since(r.taken_at)
.map(|d| d.to_millis())
.unwrap_or(0);
Measurement {
temp_milli_c: r.temp_milli_c,
rh_milli_pct: r.rh_milli_pct,
// Saturating at ~65 s is fine: anything that old is already
// far past the staleness threshold.
age_ms: age_ms.min(u16::MAX as u64) as u16,
}
})
});
let stale = measurement
.map(|m| u32::from(m.age_ms) >= board::STALE_AFTER_MS)
.unwrap_or(true);
let (info, status) = cx.shared.state.lock(|s| {
let mut flags = 0u16;
if s.sensor_ok {
flags |= flag::SENSOR_OK;
}
if stale {
flags |= flag::DATA_STALE;
}
if s.consecutive_failures >= board::SENSOR_FAULT_THRESHOLD {
flags |= flag::SENSOR_FAULT;
}
if s.uart_error {
flags |= flag::UART_ERROR;
}
if s.ever_measured {
flags |= flag::EVER_MEASURED;
}
if s.sensor_reset_seen {
flags |= flag::SENSOR_RESET_SEEN;
}
if s.sensor_status & sht3x::status::HEATER_ON != 0 {
flags |= flag::HEATER_ON;
}
let info = Info {
fw_major: board::FW_MAJOR,
fw_minor: board::FW_MINOR,
fw_patch: board::FW_PATCH,
proto_version: proto::PROTOCOL_VERSION,
device_serial: board::DEVICE_SERIAL,
sensor_serial: s.sensor_serial,
uptime_s: (now.ticks() / 1_000_000).min(u64::from(u32::MAX)) as u32,
};
let status = Status {
flags,
sensor_status: s.sensor_status,
i2c_errors: s.i2c_errors,
sensor_crc_errors: s.sensor_crc_errors,
frame_errors: s.frame_errors,
crc_errors: s.crc_errors,
};
(info, status)
});
Snapshot {
measurement,
info,
status,
}
}
/// Put `reply` on the wire, holding DE for exactly as long as it takes.
///
/// Between FIFO refills this yields rather than spinning, so the sensor task
/// still gets to run. That is safe because DE stays asserted throughout: we
/// own the bus for the whole call, and nothing else in the system is allowed
/// to touch the transceiver.
async fn transmit(cx: &mut frame_gap::Context<'_>, reply: &[u8]) {
cx.shared.bus.lock(|bus| bus.tx_enable());
let mut remaining = reply;
while !remaining.is_empty() {
remaining = cx.shared.bus.lock(|bus| bus.push(remaining));
if !remaining.is_empty() {
// FIFO full — wait for roughly half of it to drain before
// topping up.
Mono::delay(board::Duration::micros(
rs485::tx_time_us(FIFO_DEPTH / 2) as u64
))
.await;
}
}
// Everything is queued; at most a full FIFO is still to go. Sleep through
// the bulk of it, then confirm with BUSY, which is the only signal that
// the final stop bit has actually left the shift register.
let queued = reply.len().min(FIFO_DEPTH);
Mono::delay(board::Duration::micros(rs485::tx_time_us(queued) as u64)).await;
while !cx.shared.bus.lock(|bus| bus.tx_complete()) {
Mono::delay(board::Duration::micros(board::CHAR_TIME_US as u64)).await;
}
cx.shared.bus.lock(|bus| bus.tx_disable());
}
/// Sensor bring-up followed by periodic polling.
///
/// Lowest priority on purpose: nothing here is time-critical, and the bus
/// must always be able to preempt it.
#[task(priority = 1, shared = [reading, state], local = [sensor])]
async fn sensor_task(mut cx: sensor_task::Context) {
let sensor = cx.local.sensor;
Mono::delay(board::Duration::millis(u64::from(sht3x::POWER_UP_MAX_MS))).await;
// Bring-up. Every command is followed by quiet time, because the sensor
// NACKs anything arriving while it is still busy with the previous one.
// Without the settle after `clear_status` the first measurement below
// failed on every boot, leaving a permanent i2c_errors=1 that a master
// polling the error counters could not distinguish from a real fault.
//
// Failures here are logged but deliberately not counted: the counters
// describe operational health, and an unreadable serial already shows up
// as a zero in the sensor-serial registers.
let settle = board::Duration::millis(u64::from(sht3x::COMMAND_SETTLE_MS));
// Clear whatever state a warm reset left behind, so the sensor's
// configuration is known regardless of how we got here.
let _ = sensor.soft_reset();
Mono::delay(board::Duration::millis(u64::from(sht3x::SOFT_RESET_MAX_MS))).await;
match sensor.read_serial() {
Ok(serial) => {
defmt::info!("SHT31 serial {=u32:#010x}", serial);
cx.shared.state.lock(|s| s.sensor_serial = serial);
}
Err(e) => defmt::warn!("SHT31 serial read failed: {}", e),
}
Mono::delay(settle).await;
let _ = sensor.clear_status();
Mono::delay(settle).await;
let period = board::Duration::millis(u64::from(board::SENSOR_PERIOD_MS));
let mut next = Mono::now();
loop {
next += period;
match measure(sensor).await {
Ok(raw) => {
let taken_at = Mono::now();
let temp_milli_c = sht3x::temp_milli_c(raw.temp_ticks);
let rh_milli_pct = sht3x::rh_milli_pct(raw.rh_ticks);
defmt::debug!("{} m°C, {} m%RH", temp_milli_c, rh_milli_pct);
cx.shared.reading.lock(|slot| {
*slot = Some(Reading {
temp_milli_c,
rh_milli_pct,
taken_at,
})
});
cx.shared.state.lock(|s| {
s.sensor_ok = true;
s.ever_measured = true;
s.consecutive_failures = 0;
});
}
Err(e) => {
defmt::warn!("sensor poll failed: {}", e);
let failures = cx.shared.state.lock(|s| {
s.sensor_ok = false;
s.consecutive_failures = s.consecutive_failures.saturating_add(1);
match e {
crate::sensor::Error::I2c => {
s.i2c_errors = s.i2c_errors.saturating_add(1)
}
crate::sensor::Error::Crc => {
s.sensor_crc_errors = s.sensor_crc_errors.saturating_add(1)
}
}
s.consecutive_failures
});
// Try to shake a wedged sensor loose rather than reporting a
// fault forever. The reading itself stays cached and ages,
// so a master watching `age_ms` sees the problem either way.
if failures >= board::SENSOR_FAULT_THRESHOLD {
defmt::warn!("{} consecutive failures, resetting sensor", failures);
let _ = sensor.soft_reset();
Mono::delay(board::Duration::millis(u64::from(sht3x::SOFT_RESET_MAX_MS)))
.await;
let _ = sensor.clear_status();
}
}
}
// Read the status register so an unexpected sensor reset shows up on
// the bus instead of silently reverting the configuration.
//
// Failures are counted rather than discarded. Swallowing them made a
// persistently unreadable status indistinguishable from a genuinely
// clean one, since both leave `sensor_status` at zero. They are kept
// out of the consecutive-failure count so only the measurement path
// drives the fault and reset logic.
match sensor.read_status() {
Ok(status) => {
let saw_reset = status & sht3x::status::SYSTEM_RESET_DETECTED != 0;
cx.shared.state.lock(|s| {
s.sensor_status = status;
s.sensor_reset_seen |= saw_reset;
});
if saw_reset {
defmt::warn!("SHT31 reported a reset (status {=u16:#06x})", status);
let _ = sensor.clear_status();
}
}
Err(e) => {
defmt::warn!("SHT31 status read failed: {}", e);
cx.shared.state.lock(|s| match e {
crate::sensor::Error::I2c => s.i2c_errors = s.i2c_errors.saturating_add(1),
crate::sensor::Error::Crc => {
s.sensor_crc_errors = s.sensor_crc_errors.saturating_add(1)
}
});
}
}
Mono::delay_until(next).await;
}
}
/// Start a conversion, wait out the data-sheet worst case, read it back.
async fn measure<I: embedded_hal::i2c::I2c>(
sensor: &mut Sht31<I>,
) -> Result<sht3x::Raw, crate::sensor::Error> {
sensor.start_measurement()?;
// Clock stretching is disabled, so the sensor NACKs a read until the
// conversion finishes. Waiting the worst case beats retry-until-ACK: it
// keeps the I2C bus idle and the timing predictable.
Mono::delay(board::Duration::millis(u64::from(
sht3x::MEASURE_HIGH_MAX_MS,
)))
.await;
sensor.read_measurement()
}
/// 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 {
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;
}
}
/// Service the USB stack.
///
/// Lowest priority: the RS485 bus must always win, and the host tolerates the
/// resulting jitter. This is safe even during a long reply because `transmit`
/// yields between FIFO refills rather than spinning.
#[task(binds = USBCTRL_IRQ, priority = 1, shared = [usb_dev, serial])]
fn usb_irq(mut cx: usb_irq::Context) {
cx.shared.usb_dev.lock(|dev| {
cx.shared.serial.lock(|serial| {
if dev.poll(&mut [serial]) {
// Output-only diagnostic, but the RX buffer still has to be
// drained or the host's writes would eventually stall it.
let mut discard = [0u8; 64];
let _ = serial.read(&mut discard);
}
})
});
}
/// Emit a diagnostic line on the USB serial port once a second.
///
/// Deliberately reports raw internal state rather than the protocol's flag
/// encoding: this exists to answer "is the sensor working and what does it
/// read", which is a different question from what a bus master asks.
#[task(priority = 1, shared = [reading, state, serial])]
async fn usb_report(mut cx: usb_report::Context) {
let mut line: String<256> = String::new();
line.clear();
let _ = write!(
line,
"\r\n=== wiredsensor v{}.{}.{} ===\r\n\
unit={:#04x} baud={} gap={}us\r\n\
SHT31 on I2C1 SDA=GP14 SCL=GP15 addr={:#04x} {}Hz\r\n",
board::FW_MAJOR,
board::FW_MINOR,
board::FW_PATCH,
board::UNIT_ADDRESS,
board::BAUD_RATE,
board::INTER_FRAME_GAP_US,
board::SENSOR_I2C_ADDR,
board::I2C_FREQ_HZ,
);
cx.shared
.serial
.lock(|serial| usb_write(serial, line.as_bytes()));
loop {
Mono::delay(board::Duration::millis(u64::from(board::USB_REPORT_MS))).await;
let now = Mono::now();
let reading = cx.shared.reading.lock(|slot| {
slot.as_ref().map(|r| {
let age = now
.checked_duration_since(r.taken_at)
.map(|d| d.to_millis())
.unwrap_or(0);
(r.temp_milli_c, r.rh_milli_pct, age)
})
});
let s = cx.shared.state.lock(|s| {
(
s.sensor_ok,
s.consecutive_failures,
s.sensor_status,
s.sensor_serial,
s.i2c_errors,
s.sensor_crc_errors,
s.frame_errors,
s.crc_errors,
)
});
let (ok, fails, sht_status, sht_serial, i2c_err, sht_crc_err, frame_err, bus_crc_err) =
s;
line.clear();
let _ = write!(line, "[{:>6}s] ", now.ticks() / 1_000_000);
match reading {
Some((temp, rh, age)) => {
let _ = write!(line, "t=");
write_milli(&mut line, temp);
let _ = write!(line, " C rh=");
write_milli(&mut line, rh);
let _ = write!(line, " % age={}ms", age);
}
None if fails > 0 => {
let _ = write!(line, "NO READING - sensor not responding");
}
None => {
let _ = write!(line, "waiting for first measurement");
}
}
let _ = write!(
line,
" | {} fails={} sht_serial={:#010x} sht_status={:#06x} \
err[i2c={} sht_crc={} frame={} bus_crc={}]\r\n",
if ok { "OK " } else { "ERR" },
fails,
sht_serial,
sht_status,
i2c_err,
sht_crc_err,
frame_err,
bus_crc_err,
);
cx.shared
.serial
.lock(|serial| usb_write(serial, line.as_bytes()));
}
}
/// Write `bytes` to the host, discarding whatever does not fit.
///
/// A diagnostic that stalls the firmware when nobody is draining the port
/// would be worse than no diagnostic at all, so a full buffer just drops the
/// rest of the line.
fn usb_write(serial: &mut SerialPort<'static, UsbBus>, mut bytes: &[u8]) {
while !bytes.is_empty() {
match serial.write(bytes) {
Ok(0) | Err(_) => break,
Ok(n) => bytes = &bytes[n..],
}
}
}
/// Render milli-units as a signed fixed-point decimal: `-1234` → `-1.234`.
fn write_milli<const N: usize>(out: &mut String<N>, milli: i32) {
let magnitude = milli.unsigned_abs();
let _ = write!(
out,
"{}{}.{:03}",
if milli < 0 { '-' } else { '+' },
magnitude / 1000,
magnitude % 1000
);
}
}