The boards this targets carry an addressable LED on GPIO16 rather than a plain LED, so the GPIO25 heartbeat had nothing to drive. Green means running, blue flashes on each measurement, red means the sensor is failing, with the most urgent condition winning. A healthy node reads as dim green with a blue pip once a second, so a stalled or failing one is obvious with no serial port attached. Driven from PIO directly rather than via ws2812-pio, whose current release pins rp2040-hal 0.11 against this project's 0.12. Those are semver incompatible: cargo would build both HALs and the driver would not accept our PIO, state machine or pin types. The alternative was pinning the whole project back a HAL version, for four instructions of PIO. Generating the waveform in the state machine matters beyond convenience. The bus tasks preempt the LED task freely, and a CPU-timed WS2812 would glitch the moment a frame arrived mid-update. The >50 us latch gap needs no explicit delay either, since repaints are 25 ms apart. Byte order is a named constant rather than two transposed shifts, because it is a property of the part fitted and not of the protocol: many parts sold as WS2812B, along with the WS2811 and SK6812 families, want red first. These do. The unused ChannelOrder variant stays so the choice, and the fix if a board differs, is legible. The task derives everything from existing shared state — the reading's own timestamp already says when the last measurement landed — so nothing is published purely to drive an LED. Colours confirmed on hardware after correcting the channel order; bus suite still 15/15 and sensor counters clean, so PIO0 disturbs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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, line rate. Pure, no I/O. |
firmware/ |
wiredsensor-fw — the RTIC application, drivers and register access. |
bridge/ |
wiredsensor-bridge — a spare RP2040 as a USB-to-RS485 bridge, for testing. |
tools/ |
wiredsensor.py — PC-side master and end-to-end test suite. |
The core/firmware 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 |
| 16 | PIO0 | WS2812B status indicator, data in |
| — | 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
DEaway from the sensor. DEand/REon a single pin./REis active low andDEactive 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 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
- 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
ADDRpin selects the I2C address: low →0x44, high →0x45. Strap it deliberately rather than leaving it floating, and keepboard::SENSOR_I2C_ADDRin 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
nRESETpin can simply be pulled high; the firmware uses the soft reset command. .boot2is built for a W25Q080-class QSPI flash. ChangeBOOT2_FIRMWAREinfirmware/src/main.rsif 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
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..0xF7addresses a unit;0x00is 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
0xA001reflected, init0xFFFF, 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.
Configuration
Per-unit settings live 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 SENSOR_I2C_ADDR: u8 = 0x44; // match the ADDR strap
pub const SENSOR_PERIOD_MS: u32 = 1_000;
The line rate is not among them. It belongs to the segment rather than to any
one board, so it is defined once in core/src/timing.rs and read by both the
node and the bridge:
pub const BAUD_RATE: u32 = 19_200; // wiredsensor_core::timing
A baud mismatch between two ends of a bus is silent and presents as random CRC
failures, which is a poor thing to debug — hence one definition rather than a
copy per firmware. INTER_FRAME_GAP_US and CHAR_TIME_US derive from it, and a
unit test asserts they cannot go stale if it changes.
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 # 43 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.
End-to-end bus testing
bridge/ turns a spare RP2040 into a USB-to-RS485 bridge, and
tools/wiredsensor.py drives the protocol from the PC through it. Together they
test the bus itself rather than just the node's internals.
Two design choices make this a usable test instrument rather than a mirror of the firmware's own assumptions:
- The bridge is protocol-agnostic. Bytes from USB go out on the pair, bytes from the pair come back up USB. It knows nothing of frames, addresses or CRCs, so it cannot have a bug that happens to agree with the node's.
- The PC side reimplements the wire format from the specification above
rather than sharing
wiredsensor-core. Shared code would let a framing or CRC bug cancel out and every test pass regardless.
Wiring
You need two transceiver modules. The bridge mirrors the node's pin assignment,
so one diagram covers both boards: GPIO0 → DI, GPIO1 ← RO, GPIO2 → DE+RE.
bridge A ────────── A node
B ────────── B
GND ────────── GND
[120Ω] [120Ω] ← across A-B at each end; both are segment ends
Ground is common if both boards are USB-powered from the same host. If only one
is, run VSYS → VSYS plus GND → GND between them — not 3V3(OUT), which
back-feeds the regulator on the unpowered board.
Running
cargo build --release -p wiredsensor-bridge
cp target/thumbv6m-none-eabi/release/wiredsensor-bridge /tmp/bridge.elf
picotool load -x /tmp/bridge.elf # hold BOOTSEL while plugging in
The bridge is 16c0:27de, the node 16c0:27dd. Find it with:
for d in /dev/ttyACM*; do udevadm info -q property -n $d | grep -q 27de && echo $d; done
./tools/wiredsensor.py --port /dev/ttyACM2 measure
./tools/wiredsensor.py --port /dev/ttyACM2 monitor
./tools/wiredsensor.py --port /dev/ttyACM2 -v test # -v dumps bus traffic
What the suite covers
15 checks. Framing and CRC arithmetic are already covered by the host tests; what only a real bus can verify is everything timing-dependent:
- Silence where required — frames for another unit, broadcasts, bad CRCs,
truncated frames and frames whose
LENlies must all produce no reply. A node that wrongly answered would collide with whoever was actually addressed. - Driver-enable turnaround — every intact reply is evidence that
DEwas held past the final stop bit. Release it early and the last byte truncates. - No state leakage between back-to-back requests.
- Binary transparency across the full legal frame-size range.
- Counter integrity — inject one corrupt frame, assert
crc_errorsrises by exactly one.
This earned its keep immediately: it caught a frame-truncation bug that both the host tests and the sensor verification were structurally incapable of reaching, because it needed real bus timing and a frame longer than the RX FIFO watermark. See the first item under Details that are easy to get wrong.
Frames must fit one 64-byte USB packet, since each host write becomes exactly one
DE-bracketed transmission. Every real command is at most 21 bytes; only an
oversized PING can approach the limit.
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
DEcorrectly requires polling theBUSYflag. - 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 | 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 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.
Details that are easy to get wrong
RTIM is not a sufficient frame delimiter, and an interrupt timestamp is not
a trustworthy end-of-frame. Two separate traps here, and the second one bit us
for real.
RTIM only fires while the RX FIFO is non-empty, so a frame drained exactly
empty by the watermark interrupt never produces a timeout at all.
Worse: once the watermark interrupt has fired mid-frame — at 16 bytes, with the
FIFO 32 deep — the remaining bytes sit below the watermark and raise no
interrupt whatsoever until the timeout eventually arrives. A gap measured from
the timestamp the ISR left behind therefore expires while the tail of the frame
is still arriving, and the node parses a prefix whose LEN disagrees with its
length. Every frame of 16 bytes or more was silently rejected this way; frames
under 16 never trip the watermark, so their only interrupt is the timeout, by
which point every byte is drained and the timestamp is honest. That is why all
five real commands worked and only an oversized PING exposed it.
So frame_gap drains the FIFO itself before each decision rather than trusting
the ISR's timestamp, which lets a byte that has landed push the deadline back.
The gap is then measured from when the byte was observed rather than when it
arrived, making the reply up to one gap period later than strictly necessary —
1.8 ms is a fair price. It re-reads 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.