Speak Modbus RTU instead of a custom command protocol

ESPHome only has built-in support for Modbus RTU over RS485, so the
application layer moves to it. The transport already conformed: the CRC
was CRC-16/MODBUS, the line rate 19200 8N1, the delimiter a 3.5-character
idle gap pinned at 1750 us above 19200, and the address space 1..247 with
broadcast never answered. Only the bytes between the address and the CRC
change, and no external crate is needed for a read-only server.

frame.rs drops the explicit LEN byte, which RTU does not have — a
request's length is implied by its function code. The parser gets simpler
and MAX_FRAME grows to the RTU limit of 256. The CRC is now the only
integrity check there is, so a truncated frame fails it rather than a
length comparison; a test asserts every single-bit corruption is still
caught, and another pins our encoding against the published example
01 03 00 6B 00 03 74 17.

proto.rs becomes a Modbus server: function codes 0x03 and 0x04 over one
19-register table, 0x08 sub-function 0x0000 as the link test that
replaces PING, and the four standard exception codes. Registers are
big-endian with 32-bit values high word first, which is what masters and
ESPHome's S_DWORD assume. Snapshot::registers renders the whole map and
reads slice it, so a range read cannot disagree with a single-register
read of the same address — the alternative, assembling only the requested
registers per read, has no such guarantee.

Both read function codes serve the same table. That is not only for
masters that implement just 0x03: a read overlapping the measurement
block is refused with SERVER_DEVICE_FAILURE when the sensor has never
produced a reading, and ESPHome coalesces adjacent registers of one type
into a single command, so diagnostic entities merged into that command
would go unavailable along with the measurement they were meant to
explain. Requesting them under the other function code puts them in their
own command. The generated ESPHome config does this.

The firmware barely changes, because dispatch's signature does not: flags
widen to u16, buffers follow MAX_FRAME, and comments name registers
rather than commands. Now ~35 KiB flash and ~2.6 KiB RAM.

tools/wiredsensor.py is rewritten and grows from 15 checks to 21, adding
agreement between the two function codes, sub-range consistency, and a
per-code assertion for every exception. It stays a hand-written Modbus
implementation rather than moving to pymodbus: half these checks inject
deliberately malformed frames, and a conforming client library exists
precisely to make those unconstructable. It also keeps the wire format
independent of wiredsensor-core, so a shared bug cannot cancel itself
out.

Not yet exercised on hardware — the host tests and register-map
cross-checks pass, but the timing-dependent behaviour needs
`wiredsensor.py test` against a real bus.

This replaces the old protocol rather than joining it; command codes
0x01..0x04 are all valid Modbus function codes, so the two cannot share a
segment. PROTOCOL_VERSION is 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Walter
2026-07-29 17:04:21 +02:00
parent f2d06278af
commit 5b9c09df3b
10 changed files with 1293 additions and 628 deletions
+218 -112
View File
@@ -1,8 +1,9 @@
# 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.
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`.
@@ -20,10 +21,10 @@ Written in Rust on **RTIC 2** + `rp2040-hal`.
| Path | Contents |
|------------|--------------------------------------------------------------------------|
| `core/` | `wiredsensor-core` — framing, CRCs, dispatch, SHT3x math, line rate. Pure, no I/O. |
| `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 master and end-to-end test suite. |
| `tools/` | `wiredsensor.py` — PC-side Modbus 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`
@@ -102,77 +103,75 @@ 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.
**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 │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │
└──────┴─────┴─────┴──────────────┴────────┴────────┘
1 1 1 0..=64 1 1
┌──────┬────┬──────────────┬────────┬────────┐
│ 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.
- **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.
- **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**. 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**.
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`.
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.
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.
### Commands
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.
| 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 |
### Function codes
**`READ_MEASUREMENT`** reply:
| 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 |
| 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 |
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. If no reading has
ever succeeded, the reply is an error: `SENSOR_UNAVAILABLE`, or `SENSOR_FAULT`
once the failure threshold is passed.
the master decides what staleness its application tolerates.
**`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:
Flags at `0x0005`:
| Bit | Name | Meaning |
|-----|---------------------|----------------------------------------------------|
@@ -184,52 +183,134 @@ Flags:
| 5 | `SENSOR_RESET_SEEN` | the SHT31 reported an unexpected reset |
| 6 | `HEATER_ON` | the SHT31 internal heater is on |
### Errors
### Exceptions
An error reply is the request's command code with bit 7 set, and a single
payload byte:
An exception response is the request's function code with bit 7 set, and a single
data 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 |
| 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 |
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.
`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.
`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".
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_MEASUREMENT → 01 01 00 21 90
01 01 0A 9A 5B 00 00 F0 A0 00 00 89 00 87 66
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_INFO → 01 02 00 21 60
READ_STATUS → 01 03 00 20 F0
read the whole map (19 registers)
→ 01 04 00 00 00 13 B1 C7
PING "Hi" → 01 04 02 48 69 4F 1E
← 01 04 02 48 69 4F 1E
read flags only → 01 04 00 05 00 01 21 CB
SET_ADDRESS 0x22 → 01 10 01 22 81 94
01 90 01 05 C0 66 (UNSUPPORTED)
diagnostic echo "Hi"
01 08 00 00 48 69 16 25
← 01 08 00 00 48 69 16 25
unknown cmd 0x7E → 01 7E 00 01 A0
← 01 FE 01 01 A0 78 (ILLEGAL_COMMAND)
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)
```
`PING` is the intended first bring-up step: it exercises framing, CRC and the
RS485 driver-enable turnaround without involving the sensor at all.
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. `tools/wiredsensor.py esphome` prints a complete config for a given unit
address; 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. `tools/wiredsensor.py esphome` generates a config already set up
this way.
## Configuration
@@ -260,14 +341,15 @@ 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.
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 # 43 host tests
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
@@ -282,8 +364,9 @@ 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.
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
@@ -339,9 +422,14 @@ 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.
- **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
@@ -381,16 +469,27 @@ for d in /dev/ttyACM*; do udevadm info -q property -n $d | grep -q 27de && echo
### 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:
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,
truncated frames and frames whose `LEN` lies must all produce *no reply*. A
node that wrongly answered would collide with whoever was actually addressed.
- **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.
- **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.
@@ -399,9 +498,10 @@ 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.
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
@@ -457,11 +557,16 @@ Worse: once the watermark interrupt has fired mid-frame — at 16 bytes, with th
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.
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.
@@ -497,4 +602,5 @@ 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.
in the `sensor_status` register instead of silently reverting the sensor's
configuration.