93b4e2c4f8
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>
317 lines
15 KiB
Markdown
317 lines
15 KiB
Markdown
# wiredsensor
|
||
|
||
RP2040 firmware for an RS485 temperature and humidity node. A Sensirion **SHT31**
|
||
on I2C, an RS485 transceiver on UART0, and a custom binary protocol in which this
|
||
node is always the slave.
|
||
|
||
Written in Rust on **RTIC 2** + `rp2040-hal`.
|
||
|
||
```
|
||
┌──────────────┐ I2C1 ┌────────┐
|
||
│ ├────────►│ SHT31 │
|
||
A/B │ RP2040 │ └────────┘
|
||
◄────►│ │ UART0 + DE
|
||
│ wiredsensor ├────────►┌──────────────┐
|
||
└──────────────┘ │ MAX485-class │
|
||
└──────────────┘
|
||
```
|
||
|
||
## Layout
|
||
|
||
| Path | Contents |
|
||
|------------|--------------------------------------------------------------------------|
|
||
| `core/` | `wiredsensor-core` — framing, CRCs, dispatch, SHT3x math. Pure, no I/O. |
|
||
| `firmware/`| `wiredsensor-fw` — the RTIC application, drivers and register access. |
|
||
|
||
The split exists so the entire protocol can be exercised by ordinary host unit
|
||
tests (`cargo test`) with no hardware and no emulator. `core` performs no I/O and
|
||
touches no peripherals; everything hardware-shaped lives in `firmware`.
|
||
|
||
## Pinout
|
||
|
||
| GPIO | Function | Connects to |
|
||
|------|------------|---------------------------------------------------|
|
||
| 0 | UART0 TX | transceiver `DI` |
|
||
| 1 | UART0 RX | transceiver `RO` |
|
||
| 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 |
|
||
| — | SWD | `SWCLK`/`SWDIO` on the dedicated pads, for the probe |
|
||
|
||
Why this arrangement:
|
||
|
||
- **GPIO 0/1/2 as one contiguous block.** All three RS485 signals sit at the
|
||
corner of the package, so the transceiver can be placed right beside it with
|
||
short traces and no crossings. It also keeps the fast switching edges of `DE`
|
||
away from the sensor.
|
||
- **`DE` and `/RE` on a single pin.** `/RE` is active low and `DE` active high,
|
||
so one line puts the transceiver in transmit when high and receive when low.
|
||
Saves a GPIO and makes it structurally impossible to drive and listen at once.
|
||
- **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.
|
||
|
||
### Hardware notes the firmware cannot enforce
|
||
|
||
- Fit **120 Ω termination only at the two physical ends** of the segment, and
|
||
fail-safe bias resistors at exactly one point on the segment.
|
||
- The SHT31 `ADDR` pin selects the I2C address: low → `0x44`, high → `0x45`.
|
||
Strap it deliberately rather than leaving it floating, and keep
|
||
`board::SENSOR_I2C_ADDR` in agreement.
|
||
- Fit **4.7 kΩ external I2C pull-ups**. The internal pulls the firmware enables
|
||
are ~50 kΩ and are a fallback, not a substitute.
|
||
- The SHT31 `nRESET` pin can simply be pulled high; the firmware uses the soft
|
||
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.
|
||
|
||
## Protocol
|
||
|
||
19200 baud, 8N1, half duplex. Frames are delimited by an **idle line**, not by
|
||
any byte value, so payloads are fully binary-transparent with no escaping.
|
||
|
||
```
|
||
┌──────┬─────┬─────┬──────────────┬────────┬────────┐
|
||
│ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │
|
||
└──────┴─────┴─────┴──────────────┴────────┴────────┘
|
||
1 1 1 0..=64 1 1
|
||
```
|
||
|
||
- **ADDR** — `0x01`..`0xF7` addresses a unit; `0x00` is broadcast and is never
|
||
answered. A frame for any other address is ignored silently.
|
||
- **LEN** — payload length. Carried explicitly as well as implied by the frame
|
||
length, which lets a receiver reject a frame on length grounds before spending
|
||
time on the CRC, and makes captures readable by eye.
|
||
- **CRC** — CRC-16/MODBUS (poly `0xA001` reflected, init `0xFFFF`, no final XOR),
|
||
transmitted **low byte first**. Identical to Modbus RTU, so off-the-shelf bus
|
||
analysers validate our frames without being taught anything.
|
||
- All multi-byte payload fields are **little-endian**.
|
||
|
||
Frame timing follows the Modbus rules: a frame ends after **3.5 character times**
|
||
of idle line, pinned to a fixed 1750 µs above 19200 baud. At 19200 that is
|
||
1822 µs.
|
||
|
||
### Commands
|
||
|
||
| Code | Name | Request payload | Reply payload |
|
||
|--------|--------------------|-----------------|---------------|
|
||
| `0x01` | `READ_MEASUREMENT` | none | 10 bytes |
|
||
| `0x02` | `READ_INFO` | none | 16 bytes |
|
||
| `0x03` | `READ_STATUS` | none | 11 bytes |
|
||
| `0x04` | `PING` | 0..64 bytes | echoed back |
|
||
| `0x10` | `SET_ADDRESS` | 1 byte | *error* — see below |
|
||
|
||
**`READ_MEASUREMENT`** reply:
|
||
|
||
| Offset | Type | Field |
|
||
|--------|-------|----------------------------------------------------|
|
||
| 0 | `i32` | temperature, milli-degrees Celsius |
|
||
| 4 | `i32` | relative humidity, milli-percent (0..=100000) |
|
||
| 8 | `u16` | age of the reading in ms, saturating at 65535 |
|
||
|
||
The node reports `age_ms` rather than enforcing a freshness policy of its own, so
|
||
the master decides what staleness its application tolerates. If no reading has
|
||
ever succeeded, the reply is an error: `SENSOR_UNAVAILABLE`, or `SENSOR_FAULT`
|
||
once the failure threshold is passed.
|
||
|
||
**`READ_INFO`** reply:
|
||
|
||
| Offset | Type | Field |
|
||
|--------|-------|----------------------------------------------------------|
|
||
| 0 | `u8` | firmware major |
|
||
| 1 | `u8` | firmware minor |
|
||
| 2 | `u8` | firmware patch |
|
||
| 3 | `u8` | protocol version |
|
||
| 4 | `u32` | board serial (build-time constant) |
|
||
| 8 | `u32` | SHT31 factory serial, read over I2C at start-up, 0 if unavailable |
|
||
| 12 | `u32` | uptime in seconds |
|
||
|
||
**`READ_STATUS`** reply:
|
||
|
||
| Offset | Type | Field |
|
||
|--------|-------|----------------------------------------------|
|
||
| 0 | `u8` | flags, see below |
|
||
| 1 | `u16` | raw SHT31 status register |
|
||
| 3 | `u16` | I2C transfer errors |
|
||
| 5 | `u16` | sensor CRC-8 errors |
|
||
| 7 | `u16` | frame errors (length, framing, overrun) |
|
||
| 9 | `u16` | frame CRC-16 errors |
|
||
|
||
Flags:
|
||
|
||
| Bit | Name | Meaning |
|
||
|-----|---------------------|----------------------------------------------------|
|
||
| 0 | `SENSOR_OK` | the most recent poll succeeded |
|
||
| 1 | `DATA_STALE` | cached reading older than `STALE_AFTER_MS` |
|
||
| 2 | `SENSOR_FAULT` | consecutive failures past the threshold |
|
||
| 3 | `UART_ERROR` | a line error has been seen since power-up |
|
||
| 4 | `EVER_MEASURED` | at least one reading has succeeded since power-up |
|
||
| 5 | `SENSOR_RESET_SEEN` | the SHT31 reported an unexpected reset |
|
||
| 6 | `HEATER_ON` | the SHT31 internal heater is on |
|
||
|
||
### Errors
|
||
|
||
An error reply is the request's command code with bit 7 set, and a single
|
||
payload byte:
|
||
|
||
| Code | Meaning |
|
||
|--------|--------------------------------------------------|
|
||
| `0x01` | `ILLEGAL_COMMAND` — command not implemented |
|
||
| `0x02` | `ILLEGAL_LENGTH` — wrong payload length |
|
||
| `0x03` | `SENSOR_UNAVAILABLE` — no reading yet |
|
||
| `0x04` | `SENSOR_FAULT` — sensor persistently failing |
|
||
| `0x05` | `UNSUPPORTED` — recognised but disabled in this build |
|
||
|
||
A frame that fails to parse is **counted and ignored**, never answered: with a
|
||
bad CRC the address byte cannot be trusted, and replying risks colliding with
|
||
whichever node was actually addressed.
|
||
|
||
`SET_ADDRESS` is deliberately reserved rather than removed. This build takes its
|
||
address from a compile-time constant, so the command answers `UNSUPPORTED`
|
||
instead of silently doing nothing — a master can tell the difference between "not
|
||
implemented here" and "wrong command code".
|
||
|
||
### Example exchanges
|
||
|
||
Unit `0x01`, bytes as they appear on the wire:
|
||
|
||
```
|
||
READ_MEASUREMENT → 01 01 00 21 90
|
||
← 01 01 0A 9A 5B 00 00 F0 A0 00 00 89 00 87 66
|
||
└ 23.450 °C, 41.200 %RH, 137 ms old
|
||
|
||
READ_INFO → 01 02 00 21 60
|
||
READ_STATUS → 01 03 00 20 F0
|
||
|
||
PING "Hi" → 01 04 02 48 69 4F 1E
|
||
← 01 04 02 48 69 4F 1E
|
||
|
||
SET_ADDRESS 0x22 → 01 10 01 22 81 94
|
||
← 01 90 01 05 C0 66 (UNSUPPORTED)
|
||
|
||
unknown cmd 0x7E → 01 7E 00 01 A0
|
||
← 01 FE 01 01 A0 78 (ILLEGAL_COMMAND)
|
||
```
|
||
|
||
`PING` is the intended first bring-up step: it exercises framing, CRC and the
|
||
RS485 driver-enable turnaround without involving the sensor at all.
|
||
|
||
## Per-unit configuration
|
||
|
||
Everything a unit needs is in `firmware/src/board.rs`:
|
||
|
||
```rust
|
||
pub const UNIT_ADDRESS: u8 = 0x01; // this node's bus address
|
||
pub const DEVICE_SERIAL: u32 = 0x0000_0001;
|
||
pub const BAUD_RATE: u32 = 19_200;
|
||
pub const SENSOR_I2C_ADDR: u8 = 0x44; // match the ADDR strap
|
||
pub const SENSOR_PERIOD_MS: u32 = 1_000;
|
||
```
|
||
|
||
The address is compile-time by design: no flash wear, no commissioning protocol,
|
||
and no way for a bus glitch to renumber a live node. The cost is one image per
|
||
unit, so keep `UNIT_ADDRESS` and `DEVICE_SERIAL` the only things that differ.
|
||
|
||
If that becomes unwieldy, the two natural upgrades are GPIO address straps read
|
||
at boot, or a flash-stored address with `SET_ADDRESS` wired up — the command code
|
||
is already reserved for it.
|
||
|
||
## Building
|
||
|
||
```bash
|
||
cargo build --release -p wiredsensor-fw # firmware, thumbv6m-none-eabi
|
||
cargo test -p wiredsensor-core --target x86_64-unknown-linux-gnu # 42 host tests
|
||
```
|
||
|
||
The host-target flag is needed because `.cargo/config.toml` defaults the whole
|
||
workspace to the ARM target.
|
||
|
||
Flash and watch `defmt` logs over SWD:
|
||
|
||
```bash
|
||
cargo run --release -p wiredsensor-fw # runner is probe-rs
|
||
```
|
||
|
||
Resource use is ~17 KiB of flash and ~1.2 KiB of RAM, of 2 MiB and 256 KiB.
|
||
|
||
## Design notes
|
||
|
||
### Why RTIC rather than Embassy
|
||
|
||
This node's hard requirements are two pieces of precise register-level UART
|
||
behaviour plus one scheduling guarantee, and RTIC provides all three directly:
|
||
|
||
- The PL011 **receive-timeout interrupt** (`RTIM`) fires after 32 idle bit
|
||
periods with unread data in the FIFO — very close to the 3.5-character gap the
|
||
protocol uses as a delimiter. Binding it needs raw register access.
|
||
- The PL011 has **no transmit-complete interrupt**, only FIFO-level ones, so
|
||
releasing `DE` correctly requires polling the `BUSY` flag.
|
||
- The bus must **preempt** the sensor, and the resulting sharing should be
|
||
checked rather than argued about.
|
||
|
||
`rp2040-hal`'s `Uart` exposes neither `RTIM` nor a hook for post-stop-bit `DE`
|
||
release, so the firmware lets the HAL do the fiddly baud-divisor and
|
||
line-control setup once, then calls `.free()` to reclaim the raw peripheral and
|
||
drives it from registers thereafter.
|
||
|
||
### Task structure
|
||
|
||
| 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 central decision is that **requests are answered entirely from a cached
|
||
reading**. An SHT31 high-repeatability conversion takes up to 15 ms — far longer
|
||
than the turnaround a master expects — so the sensor is polled on its own
|
||
schedule at the lowest priority and the bus path never touches I2C. RTIC's
|
||
priority ceilings then guarantee a conversion in progress cannot delay a reply.
|
||
|
||
The ISR deliberately does no parsing. At 19200 baud a character arrives every
|
||
~520 µs, roughly 65,000 core cycles, so there is ample slack; keeping the handler
|
||
to a FIFO drain is what bounds the jitter everything else sees.
|
||
|
||
### Three details that are easy to get wrong
|
||
|
||
**`RTIM` alone is not a sufficient frame delimiter.** It only fires while the RX
|
||
FIFO is non-empty, so a frame whose length lands exactly on the FIFO watermark
|
||
can be drained by the watermark interrupt and never produce a timeout. The
|
||
authoritative delimiter is therefore a monotonic timer measured from the last
|
||
received byte; `RTIM` merely makes the common case prompt. `frame_gap` re-reads
|
||
the arrival timestamp and sleeps again rather than cancelling and respawning a
|
||
timer per byte.
|
||
|
||
**`DE` must be held until the last stop bit is gone.** Releasing it when the TX
|
||
FIFO empties truncates the final character for every listener — a fault that
|
||
shows up as a CRC error at the master and is completely invisible at the slave.
|
||
`transmit` sleeps through the bulk of the transmission, then confirms with
|
||
`BUSY`. It yields rather than spins, which is safe because `DE` stays asserted
|
||
throughout: the node owns the bus for the whole call.
|
||
|
||
**The RX FIFO is flushed after transmitting.** While `DE` is asserted the
|
||
transceiver's `/RE` is disabled and `RO` is not driven, so the edge as it
|
||
re-enables can clock a spurious character in. Left in place that byte would
|
||
become the first byte of the next frame and break it.
|
||
|
||
### SHT3x, not SHT4x
|
||
|
||
Worth stating explicitly because the families are easy to confuse and the
|
||
firmware would appear to work while reading wrong:
|
||
|
||
- Commands are **16-bit** words, not a single byte.
|
||
- Humidity is `100 × raw / 65535` — **no** `-6 + 125 ×` offset term. Temperature
|
||
is `-45 + 175 × raw / 65535`, the same as SHT4x.
|
||
- High-repeatability conversion takes up to **15 ms**, not 8.3 ms.
|
||
|
||
Measurements use the clock-stretch-**disabled** command and an explicit timed
|
||
wait. The alternative has the sensor hold SCL for up to 15 ms, blocking the bus
|
||
and making the transfer duration depend on the controller's stretch timeout.
|
||
|
||
Each poll also reads the status register, so an unexpected sensor reset surfaces
|
||
in `READ_STATUS` instead of silently reverting the sensor's configuration.
|