c49fff9faf
The README carried a fragment, which is enough to show the shape but not enough to flash. These are complete configs: wiredsensor.yaml one node, every measurement and diagnostic register wiredsensor-node.yaml one node as a reusable package, parameterised by address bus-of-nodes.yaml three nodes on one segment, via that package All three validate against ESPHome 2026.7, and the expanded config was checked register by register rather than only for schema validity — a wrong address or value_type validates perfectly and then reports a plausible but wrong temperature, which is the failure mode worth guarding against. Two things the fragment in the README was missing and a real config cannot be: flow_control_pin on the modbus component. Without it the ESP32 never asserts DE, so nothing reaches the segment and the node looks dead. The generated fragment now carries it too. The read/holding split for diagnostics. ESPHome merges adjacent registers of one register_type into a single command, and a read overlapping 0x0000..0x0004 is refused when the sensor has never produced a reading — so diagnostics merged into the measurement command go unavailable exactly when they are needed. Asking for them as `holding` puts them in their own command. Serials are text_sensors with raw_encode: HEXBYTES rather than numeric sensors, because Home Assistant stores states as float32 and 24 bits of mantissa cannot hold a 32-bit serial. Uptime has the same limit and is left numeric with a note; quantising past 194 days does not matter for spotting a reboot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
622 lines
29 KiB
Markdown
622 lines
29 KiB
Markdown
# wiredsensor
|
||
|
||
RP2040 firmware for an RS485 temperature and humidity node. A Sensirion **SHT31**
|
||
on I2C, an RS485 transceiver on UART0, and **Modbus RTU** in which this node is
|
||
always the server (slave) — so ESPHome, Home Assistant or any PLC can read it
|
||
with no custom component.
|
||
|
||
Written in Rust on **RTIC 2** + `rp2040-hal`.
|
||
|
||
```
|
||
┌──────────────┐ I2C1 ┌────────┐
|
||
│ ├────────►│ SHT31 │
|
||
A/B │ RP2040 │ └────────┘
|
||
◄────►│ │ UART0 + DE
|
||
│ wiredsensor ├────────►┌──────────────┐
|
||
└──────────────┘ │ MAX485-class │
|
||
└──────────────┘
|
||
```
|
||
|
||
## Layout
|
||
|
||
| Path | Contents |
|
||
|------------|--------------------------------------------------------------------------|
|
||
| `core/` | `wiredsensor-core` — Modbus framing, CRCs, the register map, 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 Modbus master and end-to-end test suite. |
|
||
| `examples/`| ESPHome configurations, one node and a whole segment. |
|
||
|
||
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 `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 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 `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.
|
||
- 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
|
||
|
||
**Modbus RTU**, 19200 baud, 8N1, half duplex. The node is a Modbus *server*
|
||
(slave) and never speaks unless asked. Any off-the-shelf master can read it —
|
||
ESPHome's `modbus_controller`, a bus analyser, `pymodbus`, a PLC.
|
||
|
||
```
|
||
┌──────┬────┬──────────────┬────────┬────────┐
|
||
│ ADDR │ FC │ DATA │ CRC_LO │ CRC_HI │
|
||
└──────┴────┴──────────────┴────────┴────────┘
|
||
1 1 0..=252 1 1
|
||
```
|
||
|
||
- **ADDR** — `0x01`..`0xF7` addresses a unit; `0x00` is broadcast and is never
|
||
answered. A frame for any other address is ignored silently.
|
||
- **FC** — function code. In a response, bit 7 set marks a Modbus exception.
|
||
- **CRC** — CRC-16/MODBUS (poly `0xA001` reflected, init `0xFFFF`, no final XOR),
|
||
transmitted **low byte first**.
|
||
- Register values are **big-endian**. Quantities wider than 16 bits occupy two
|
||
consecutive registers, **high word first** — ESPHome's `S_DWORD` / `U_DWORD`.
|
||
|
||
Frames are delimited by an **idle line**, not by any byte value, so the data
|
||
field is fully binary-transparent with no escaping. 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.
|
||
|
||
There is deliberately **no length field**: RTU implies a request's length from its
|
||
function code and a response's from the byte-count field. The CRC is therefore
|
||
the only integrity check there is, and a frame arriving one byte short simply
|
||
fails it and is discarded — the master retries.
|
||
|
||
### Function codes
|
||
|
||
| Code | Name | Notes |
|
||
|--------|-------------------------|----------------------------------------------|
|
||
| `0x03` | Read Holding Registers | same table as `0x04` |
|
||
| `0x04` | Read Input Registers | the semantically correct one for measurements |
|
||
| `0x08` | Read Diagnostics | sub-function `0x0000` Return Query Data only |
|
||
|
||
Both read codes are served from one register table. Measured values are properly
|
||
*input* registers, but several masters only implement `0x03`, and answering both
|
||
costs one match arm. Anything else draws `ILLEGAL_FUNCTION`.
|
||
|
||
### Register map
|
||
|
||
Addresses are stable — new fields get appended rather than renumbered, or every
|
||
deployed master's configuration breaks at once.
|
||
|
||
| Address | Regs | Type | Field |
|
||
|---------|------|--------|----------------------------------------------------|
|
||
| `0x0000`| 2 | `i32` | temperature, milli-degrees Celsius |
|
||
| `0x0002`| 2 | `i32` | relative humidity, milli-percent (0..=100000) |
|
||
| `0x0004`| 1 | `u16` | age of the reading in ms, saturating at 65535 |
|
||
| `0x0005`| 1 | `u16` | flags, see below |
|
||
| `0x0006`| 1 | `u16` | raw SHT31 status register |
|
||
| `0x0007`| 1 | `u16` | I2C transfer errors |
|
||
| `0x0008`| 1 | `u16` | sensor CRC-8 errors |
|
||
| `0x0009`| 1 | `u16` | frame errors (length, framing, overrun) |
|
||
| `0x000A`| 1 | `u16` | frame CRC-16 errors |
|
||
| `0x000B`| 1 | `u16` | firmware major (high byte), minor (low byte) |
|
||
| `0x000C`| 1 | `u16` | firmware patch (high byte), protocol version (low) |
|
||
| `0x000D`| 2 | `u32` | board serial (build-time constant) |
|
||
| `0x000F`| 2 | `u32` | SHT31 factory serial, 0 if unavailable |
|
||
| `0x0011`| 2 | `u32` | uptime in seconds |
|
||
|
||
19 registers in total, `0x0000`..`0x0012`.
|
||
|
||
The node reports `age_ms` rather than enforcing a freshness policy of its own, so
|
||
the master decides what staleness its application tolerates.
|
||
|
||
Flags at `0x0005`:
|
||
|
||
| 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 |
|
||
|
||
### Exceptions
|
||
|
||
An exception response is the request's function code with bit 7 set, and a single
|
||
data byte:
|
||
|
||
| Code | Name | Cause |
|
||
|--------|------------------------|------------------------------------------------|
|
||
| `0x01` | `ILLEGAL_FUNCTION` | function code, or diagnostic sub-function, not implemented |
|
||
| `0x02` | `ILLEGAL_DATA_ADDRESS` | the requested range falls outside the map |
|
||
| `0x03` | `ILLEGAL_DATA_VALUE` | register count of 0 or above 125, or a misshaped request |
|
||
| `0x04` | `SERVER_DEVICE_FAILURE`| no reading has ever been taken, or the sensor is faulted |
|
||
|
||
`SERVER_DEVICE_FAILURE` is raised **only for reads that touch `0x0000`..`0x0004`**.
|
||
A read confined to the diagnostic and identity registers still succeeds even with
|
||
a dead sensor, which is deliberate: those are exactly the registers you need to
|
||
work out *why* it is silent. Reporting a stale or zeroed measurement as valid
|
||
would be the worse failure.
|
||
|
||
A frame that fails to parse is **counted and ignored**, never answered. With a bad
|
||
CRC the address byte cannot be trusted, so replying risks colliding with whichever
|
||
node was actually addressed — and the specification requires silence here anyway.
|
||
|
||
### Example exchanges
|
||
|
||
Unit `0x01`, bytes as they appear on the wire:
|
||
|
||
```
|
||
read temp + humidity + age (5 registers from 0x0000)
|
||
→ 01 04 00 00 00 05 30 09
|
||
← 01 04 0A 00 00 5B 9A 00 00 A0 F0 00 89 4C 6E
|
||
└ 23.450 °C, 41.200 %RH, 137 ms old
|
||
|
||
read the whole map (19 registers)
|
||
→ 01 04 00 00 00 13 B1 C7
|
||
|
||
read flags only → 01 04 00 05 00 01 21 CB
|
||
|
||
diagnostic echo "Hi"
|
||
→ 01 08 00 00 48 69 16 25
|
||
← 01 08 00 00 48 69 16 25
|
||
|
||
write single reg → 01 06 00 00 00 01 48 0A
|
||
(0x06) ← 01 86 01 83 A0 (ILLEGAL_FUNCTION)
|
||
|
||
read past the end → 01 04 00 13 00 01 C0 0F
|
||
← 01 84 02 C2 C1 (ILLEGAL_DATA_ADDRESS)
|
||
```
|
||
|
||
The diagnostic echo is the intended first bring-up step: it exercises framing,
|
||
CRC and the RS485 driver-enable turnaround without involving the sensor or the
|
||
register map at all.
|
||
|
||
## ESPHome
|
||
|
||
`modbus_controller` reads this node with no custom component and no external
|
||
library. Complete, flashable configs live in `examples/esphome/`:
|
||
|
||
| File | What it is |
|
||
|------|------------|
|
||
| `wiredsensor.yaml` | one node, with every measurement and diagnostic exposed. Start here. |
|
||
| `wiredsensor-node.yaml` | one node as a reusable package, parameterised by address |
|
||
| `bus-of-nodes.yaml` | three nodes on one segment, via that package |
|
||
|
||
All three validate against ESPHome 2026.7. `tools/wiredsensor.py esphome` prints
|
||
the same thing for an arbitrary unit address if you would rather generate it. The
|
||
essentials are:
|
||
|
||
```yaml
|
||
uart:
|
||
id: rs485
|
||
tx_pin: GPIO17 # whatever your transceiver is wired to
|
||
rx_pin: GPIO16
|
||
baud_rate: 19200 # must match core/src/timing.rs
|
||
data_bits: 8
|
||
parity: NONE
|
||
stop_bits: 1
|
||
|
||
modbus:
|
||
id: rs485_bus
|
||
uart_id: rs485
|
||
|
||
modbus_controller:
|
||
- id: wiredsensor
|
||
address: 0x01 # must match board.rs UNIT_ADDRESS
|
||
modbus_id: rs485_bus
|
||
update_interval: 30s
|
||
|
||
sensor:
|
||
- platform: modbus_controller
|
||
modbus_controller_id: wiredsensor
|
||
name: "Temperature"
|
||
register_type: read # ESPHome's name for function code 0x04
|
||
address: 0x0000
|
||
value_type: S_DWORD
|
||
unit_of_measurement: "°C"
|
||
device_class: temperature
|
||
filters:
|
||
- multiply: 0.001 # the node reports milli-degrees
|
||
```
|
||
|
||
Three things must agree between the two ends, and all three fail silently or
|
||
confusingly if they do not:
|
||
|
||
- **Baud rate.** `19200` here against `BAUD_RATE` in `core/src/timing.rs`. A
|
||
mismatch presents as random CRC failures.
|
||
- **Unit address.** `address:` here against `UNIT_ADDRESS` in
|
||
`firmware/src/board.rs`. A mismatch presents as a total silence that looks
|
||
identical to a wiring fault.
|
||
- **Value type.** `S_DWORD` with `multiply: 0.001`, not `S_WORD`. Reading half of
|
||
a 32-bit pair yields a plausible-looking but wrong number rather than an error.
|
||
|
||
ESPHome coalesces adjacent registers of the same `register_type` into a single
|
||
command, which the map is laid out for: temperature, humidity and age are
|
||
contiguous, so all three arrive in one transaction.
|
||
|
||
That coalescing has one consequence worth planning around. A read overlapping
|
||
`0x0000`..`0x0004` is refused with `SERVER_DEVICE_FAILURE` when the sensor has
|
||
never produced a reading, so any entity ESPHome merges into that command goes
|
||
unavailable with it — including, if you are not careful, the very flags that would
|
||
tell you why. The fix is to ask for the diagnostic registers under the *other*
|
||
function code: they are the same table, but a different `register_type` puts them
|
||
in their own command.
|
||
|
||
```yaml
|
||
binary_sensor:
|
||
- platform: modbus_controller
|
||
modbus_controller_id: wiredsensor
|
||
name: "Sensor OK"
|
||
register_type: holding # 0x03, so this is a separate command from the
|
||
address: 0x0005 # 0x04 read that carries the measurement
|
||
bitmask: 0x01
|
||
entity_category: diagnostic
|
||
```
|
||
|
||
This is what serving both function codes from one table buys, beyond mere
|
||
compatibility. Both `examples/esphome/wiredsensor.yaml` and the generated config
|
||
are already set up this way.
|
||
|
||
One more wiring detail the YAML has to get right: **`flow_control_pin` on the
|
||
`modbus:` component**, unless your transceiver switches direction itself. Without
|
||
it the ESP32 never asserts DE and nothing you send reaches the segment, which
|
||
looks exactly like a dead node.
|
||
|
||
## Configuration
|
||
|
||
Per-unit settings live 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 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:
|
||
|
||
```rust
|
||
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 written over the bus — which would mean
|
||
implementing `0x06` Write Single Register and a holding register for it, the
|
||
first writable thing in the map.
|
||
|
||
## Building
|
||
|
||
```bash
|
||
cargo build --release -p wiredsensor-fw # firmware, thumbv6m-none-eabi
|
||
cargo test -p wiredsensor-core --target x86_64-unknown-linux-gnu # 47 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
|
||
```
|
||
|
||
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 ~35 KiB of flash and ~2.6 KiB of RAM, of 2 MiB and 256 KiB.
|
||
Roughly half the flash is the USB stack. The RTU frame limit of 256 bytes is what
|
||
sizes the receive buffer, rather than the 21 bytes any real request needs.
|
||
|
||
## 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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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 Modbus RTU** from the specification rather than
|
||
sharing `wiredsensor-core`. Shared code would let a framing or CRC bug cancel
|
||
out and every test pass regardless. It is hand-written rather than built on
|
||
`pymodbus` for a concrete reason: half these checks inject *deliberately
|
||
malformed* frames and assert the node stays silent, and a conforming client
|
||
library exists precisely to make those frames unconstructable. Point `pymodbus`
|
||
at the bridge to cross-check the happy path against a third-party stack; use
|
||
this suite to verify the node behaves on a bus that is misbehaving.
|
||
|
||
### 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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
for d in /dev/ttyACM*; do udevadm info -q property -n $d | grep -q 27de && echo $d; done
|
||
```
|
||
|
||
```bash
|
||
./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
|
||
|
||
21 checks. Framing, CRC arithmetic and the register map 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 and
|
||
truncated frames must all produce *no reply*. A node that wrongly answered
|
||
would collide with whoever was actually addressed. With no length field, a
|
||
truncated frame can only be caught by the CRC, so this is the check that
|
||
confirms it is.
|
||
- **Driver-enable turnaround** — every intact reply is evidence that `DE` was
|
||
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, via the
|
||
diagnostic echo.
|
||
- **Register map consistency** — `0x03` and `0x04` must return the same values,
|
||
and a sub-range read must agree with the same addresses read as part of a
|
||
larger block. ESPHome coalesces adjacent registers into one transaction, so
|
||
disagreement there would surface as wrong values in Home Assistant and nowhere
|
||
else.
|
||
- **Exception codes** — an unimplemented function, a read past the end of the
|
||
map, a read straddling it, a zero or oversized register count and a misshaped
|
||
request each draw the specific code the specification prescribes.
|
||
- **Counter integrity** — inject one corrupt frame, assert `crc_errors` rises 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*.
|
||
|
||
Requests must fit one 64-byte USB packet, since each host write becomes exactly
|
||
one DE-bracketed transmission. Every real request is 8 bytes; only a deliberately
|
||
long diagnostic echo approaches the limit, and the suite sends one at exactly 64
|
||
bytes to exercise the case.
|
||
|
||
## 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 | `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 tries to parse a prefix — which fails the CRC,
|
||
since the bytes it read as a CRC are really payload. 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.
|
||
|
||
Under Modbus this trap is, if anything, better hidden. Every real request is 8
|
||
bytes, comfortably under the watermark, so the node would answer ESPHome
|
||
perfectly and fail only on a long diagnostic echo — and the failure would present
|
||
as a rising `crc_errors` count with no other symptom.
|
||
|
||
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 the `sensor_status` register instead of silently reverting the sensor's
|
||
configuration.
|