Initial commit: RP2040 RS485 temperature/humidity node

RTIC 2 firmware for an RP2040 acting as an RS485 slave, reading a
Sensirion SHT31 over I2C and answering a custom binary protocol.

Split into two crates so the protocol is testable without hardware:

- wiredsensor-core: framing, CRC-16/MODBUS, command dispatch and SHT3x
  data-sheet math. Pure computation, no I/O, no peripherals. Covered by
  42 host tests including the CRC-16/MODBUS catalogue vector, the SHT3x
  data-sheet CRC-8 example, and an exhaustive single-bit-corruption
  sweep over every byte of a frame.
- wiredsensor-fw: the RTIC application, PL011 register driver and SHT31
  I2C driver.

Requests are answered entirely from a cached reading. An SHT31
high-repeatability conversion takes up to 15 ms, well past the
turnaround a master expects, so the sensor is polled at the lowest
priority and the bus path never touches I2C. RTIC's priority ceilings
make "a conversion cannot delay a reply" a checked property rather than
an argument.

Two PL011 details drive the low-level approach, since rp2040-hal exposes
neither: the receive-timeout interrupt for prompt frame delimiting, and
the BUSY flag, which is the only way to know the final stop bit has left
the shift register before releasing DE. The HAL performs the fiddly
baud-divisor setup once, then the raw peripheral is reclaimed via free().

Note that RTIM cannot be the sole frame delimiter: it only fires while
the RX FIFO is non-empty, so a frame landing exactly on the FIFO
watermark is drained by the watermark interrupt and never times out. The
authoritative delimiter is a monotonic timer measured from the last
received byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Walter
2026-07-28 17:17:44 +02:00
commit 93b4e2c4f8
20 changed files with 3876 additions and 0 deletions
+537
View File
@@ -0,0 +1,537 @@
//! 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 | `heartbeat` | async | Blink the status LED |
//!
//! 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;
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 embedded_hal::digital::StatefulOutputPin;
use fugit::RateExtU32;
use rp2040_hal as hal;
use rp2040_hal::Clock;
use rtic::Mutex;
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;
/// 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 `READ_STATUS` and `READ_INFO` 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,
}
#[local]
struct Local {
sensor: Sht31<board::SensorI2c>,
led: board::StatusLedPin,
}
#[init]
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);
let led = pins.gpio25.reconfigure();
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();
heartbeat::spawn().ok();
(
Shared {
bus,
rx: RxBuffer::new(),
reading: None,
state: DeviceState::default(),
},
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 ISO 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, re-read the last arrival time and sleep again.
loop {
let deadline = cx.shared.rx.lock(|rx| rx.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 = 0u8;
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;
// 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),
}
let _ = sensor.clear_status();
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 in
// READ_STATUS instead of silently reverting the configuration.
if let Ok(status) = sensor.read_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();
}
}
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()
}
/// 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) {
loop {
cx.local.led.toggle().ok();
Mono::delay(board::Duration::millis(u64::from(board::HEARTBEAT_MS))).await;
}
}
}