Oliver Walter 25dba97c06 Stop miscounting SHT31 bring-up and status-read failures
Two ways the error counters lied about sensor health.

The first measurement failed on every boot, leaving a permanent
i2c_errors=1 that a master polling READ_STATUS could not tell apart from
a real fault. The sensor NACKs a command arriving while it is still busy
with the previous one, and the bring-up sequence issued a measurement
immediately after clear_status with no quiet time. Commands are now
spaced by sht3x::COMMAND_SETTLE_MS. Bring-up failures stay uncounted on
purpose: the counters describe operational health, and an unreadable
serial already surfaces as a zero in READ_INFO.

read_status() failures were discarded via `if let Ok`, which made a
persistently unreadable status register indistinguishable from a
genuinely clean one — both leave sensor_status at zero. They are now
counted, but kept out of the consecutive-failure tally so only the
measurement path drives the fault and soft-reset logic.

Confirmed on hardware: a fresh boot now holds err[i2c=0 sht_crc=0
frame=0 bus_crc=0] indefinitely, where it previously showed i2c=1 from
the first poll onwards. After a full bus test run the counters read
exactly frame=2 bus_crc=2, accounting for the four faults the suite
injects deliberately and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:41:07 +02:00

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
  • ADDR0x01..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:

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

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:

cargo run --release -p wiredsensor-fw      # runner is probe-rs

Readings are logged at debug level while .cargo/config.toml pins DEFMT_LOG=info, so use DEFMT_LOG=debug cargo run … to see them.

Resource use is ~34.5 KiB of flash and ~2.5 KiB of RAM, of 2 MiB and 256 KiB. Roughly half the flash is the USB stack.

USB diagnostics

The node also presents a USB CDC serial port, so it can be verified with nothing but a USB cable — no SWD probe and no RS485 adapter. This is a diagnostic aid, not part of the protocol, and it runs at the lowest priority so it can never delay a bus reply.

With no debug probe available, flash over the bootloader instead: hold BOOTSEL while plugging in, then

cp target/thumbv6m-none-eabi/release/wiredsensor-fw /tmp/fw.elf
picotool load -x /tmp/fw.elf        # picotool requires a known file extension

The board appears as 16c0:27dd "wiredsensor RS485 node". Read it with:

stty -F /dev/ttyACM0 raw -echo && cat /dev/ttyACM0
=== wiredsensor v0.1.0 ===
unit=0x01 baud=19200 gap=1822us
SHT31 on I2C1 SDA=GP14 SCL=GP15 addr=0x44 100000Hz
[    82s] t=+27.349 C  rh=+43.486 %  age=985ms  | OK  fails=0
          sht_serial=0x2d5ac752 sht_status=0x0000 err[i2c=1 sht_crc=0 frame=0 bus_crc=0]

A non-zero sht_serial is the useful signal: it is read from the SHT31's factory serial register and CRC-8 validated, so a missing or miswired sensor cannot fake it. Small jitter in the last digits of the readings is the genuine noise floor of a live 16-bit conversion — an identical repeated value would suggest a stuck cache instead.

The banner prints once at start-up. Output is dropped when no host is draining the port, rather than blocking the firmware, so hold the port open across a reset if you want to see it.

Note 16c0:27dd is the pid.codes generic CDC-ACM pair, intended for development. Replace it before shipping.

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 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

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 / 65535no -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.

S
Description
No description provided
Readme 338 KiB
Languages
Rust 79.8%
Python 19.4%
Linker Script 0.8%