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
+1
View File
@@ -1 +1,2 @@
/target
__pycache__/
+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.
+3 -2
View File
@@ -18,8 +18,9 @@
//! A frame split across two USB packets would therefore be sent as two bursts
//! with an inter-frame gap between them, and the node would see two malformed
//! frames rather than one good one. USB full-speed bulk packets are 64 bytes, so
//! keep frames at or under 64 bytes — every real command in this protocol is at
//! most 21, and only an oversized `PING` payload could reach the limit.
//! keep frames at or under 64 bytes. Every real Modbus request is 8 bytes, so this
//! only constrains a deliberately long diagnostic echo — the RTU limit itself is
//! 256.
#![no_std]
#![no_main]
+88 -84
View File
@@ -1,34 +1,41 @@
//! Frame layout and validation.
//! Modbus RTU ADU layout and validation.
//!
//! ```text
//! ┌──────┬─────┬─────┬───────────────┬────────┬────────┐
//! │ 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
//! ```
//!
//! Frames are delimited by an idle line, Modbus-RTU style, rather than by any
//! byte value — so the payload is fully binary-transparent with no escaping.
//! `LEN` is carried explicitly as well, which lets a receiver reject a frame on
//! length grounds before spending time on the CRC and makes captures easy to
//! read by eye.
//! Frames are delimited by an idle line rather than by any byte value, so the
//! data field is fully binary-transparent with no escaping.
//!
//! Note what is *absent*: there is no explicit length field. In Modbus RTU the
//! length of a request is implied by its function code, and the length of a
//! response by the byte-count field the function code prescribes. A receiver
//! therefore cannot reject a frame on length grounds before checking the CRC —
//! the idle gap decides where the frame ends and the CRC decides whether it
//! survived. A frame that arrives one byte short simply fails the CRC and is
//! discarded, and the master retries.
use crate::crc;
/// Largest payload a frame may carry.
pub const MAX_PAYLOAD: usize = 64;
/// Largest data field a frame may carry: the 256-byte RTU limit less `ADDR`,
/// `FC` and the CRC.
pub const MAX_DATA: usize = MAX_FRAME - HEADER_LEN - CRC_LEN;
/// Bytes preceding the payload: `ADDR`, `CMD`, `LEN`.
pub const HEADER_LEN: usize = 3;
/// Bytes preceding the data field: `ADDR`, `FC`.
pub const HEADER_LEN: usize = 2;
/// Bytes following the payload.
/// Bytes following the data field.
pub const CRC_LEN: usize = 2;
/// Shortest possible frame (empty payload).
/// Shortest possible frame: address, function code and CRC.
pub const MIN_FRAME: usize = HEADER_LEN + CRC_LEN;
/// Longest possible frame.
pub const MAX_FRAME: usize = HEADER_LEN + MAX_PAYLOAD + CRC_LEN;
/// Longest possible frame, fixed by the Modbus RTU specification at 256 bytes
/// regardless of what any particular function code needs.
pub const MAX_FRAME: usize = 256;
/// Reserved address meaning "every node on the segment". Nodes must never reply
/// to it, or the bus would collide.
@@ -37,7 +44,7 @@ pub const ADDR_BROADCAST: u8 = 0x00;
/// Lowest assignable unit address.
pub const ADDR_MIN: u8 = 0x01;
/// Highest assignable unit address (247, matching the Modbus convention).
/// Highest assignable unit address (247, per the Modbus specification).
pub const ADDR_MAX: u8 = 0xF7;
/// Why a received byte sequence is not a usable frame.
@@ -48,8 +55,6 @@ pub enum ParseError {
TooShort,
/// More bytes than the largest legal frame.
TooLong,
/// The `LEN` field disagrees with how many bytes actually arrived.
LengthMismatch,
/// The trailing CRC does not cover the received bytes.
BadCrc,
}
@@ -58,8 +63,8 @@ pub enum ParseError {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum EncodeError {
/// The payload exceeds [`MAX_PAYLOAD`].
PayloadTooLong,
/// The data field exceeds [`MAX_DATA`].
DataTooLong,
/// The destination buffer is too small for the resulting frame.
BufferTooSmall,
}
@@ -69,10 +74,11 @@ pub enum EncodeError {
pub struct Frame<'a> {
/// Destination address as it appeared on the wire.
pub addr: u8,
/// Command code.
pub cmd: u8,
/// Command payload, already length-checked.
pub payload: &'a [u8],
/// Function code.
pub fc: u8,
/// Everything between the function code and the CRC. Interpreting it is the
/// function code's business; this layer only guarantees it arrived intact.
pub data: &'a [u8],
}
impl<'a> Frame<'a> {
@@ -89,11 +95,6 @@ impl<'a> Frame<'a> {
return Err(ParseError::TooLong);
}
let len = buf[2] as usize;
if HEADER_LEN + len + CRC_LEN != buf.len() {
return Err(ParseError::LengthMismatch);
}
let (body, tail) = buf.split_at(buf.len() - CRC_LEN);
if crc::checksum(body) != u16::from_le_bytes([tail[0], tail[1]]) {
return Err(ParseError::BadCrc);
@@ -101,28 +102,27 @@ impl<'a> Frame<'a> {
Ok(Frame {
addr: buf[0],
cmd: buf[1],
payload: &buf[HEADER_LEN..HEADER_LEN + len],
fc: buf[1],
data: &body[HEADER_LEN..],
})
}
}
/// Build a frame into `out`, returning how many bytes were written.
pub fn encode(addr: u8, cmd: u8, payload: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
if payload.len() > MAX_PAYLOAD {
return Err(EncodeError::PayloadTooLong);
pub fn encode(addr: u8, fc: u8, data: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
if data.len() > MAX_DATA {
return Err(EncodeError::DataTooLong);
}
let total = HEADER_LEN + payload.len() + CRC_LEN;
let total = HEADER_LEN + data.len() + CRC_LEN;
if out.len() < total {
return Err(EncodeError::BufferTooSmall);
}
out[0] = addr;
out[1] = cmd;
out[2] = payload.len() as u8;
out[HEADER_LEN..HEADER_LEN + payload.len()].copy_from_slice(payload);
out[1] = fc;
out[HEADER_LEN..HEADER_LEN + data.len()].copy_from_slice(data);
let crc = crc::checksum(&out[..HEADER_LEN + payload.len()]);
let crc = crc::checksum(&out[..HEADER_LEN + data.len()]);
out[total - 2..total].copy_from_slice(&crc.to_le_bytes());
Ok(total)
@@ -138,78 +138,81 @@ pub const fn is_valid_unit_address(addr: u8) -> bool {
mod tests {
use super::*;
fn build(addr: u8, cmd: u8, payload: &[u8]) -> ([u8; MAX_FRAME], usize) {
fn build(addr: u8, fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
let mut buf = [0u8; MAX_FRAME];
let n = encode(addr, cmd, payload, &mut buf).unwrap();
let n = encode(addr, fc, data, &mut buf).unwrap();
(buf, n)
}
#[test]
fn encode_parse_roundtrip() {
let payload = [0xDE, 0xAD, 0xBE, 0xEF];
let (buf, n) = build(0x2A, 0x01, &payload);
let data = [0x00, 0x00, 0x00, 0x02];
let (buf, n) = build(0x2A, 0x04, &data);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.addr, 0x2A);
assert_eq!(f.cmd, 0x01);
assert_eq!(f.payload, &payload);
assert_eq!(f.fc, 0x04);
assert_eq!(f.data, &data);
}
#[test]
fn empty_payload_roundtrip() {
let (buf, n) = build(0x01, 0x01, &[]);
fn empty_data_roundtrip() {
let (buf, n) = build(0x01, 0x03, &[]);
assert_eq!(n, MIN_FRAME);
let f = Frame::parse(&buf[..n]).unwrap();
assert!(f.payload.is_empty());
assert!(f.data.is_empty());
}
#[test]
fn max_payload_roundtrip() {
let payload = [0x5Au8; MAX_PAYLOAD];
let (buf, n) = build(0x01, 0x04, &payload);
fn max_data_roundtrip() {
let data = [0x5Au8; MAX_DATA];
let (buf, n) = build(0x01, 0x04, &data);
assert_eq!(n, MAX_FRAME);
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &data);
}
#[test]
fn payload_is_binary_transparent() {
fn data_is_binary_transparent() {
// Bytes that would need escaping in a delimiter-based protocol.
let payload = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E];
let (buf, n) = build(0x01, 0x04, &payload);
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
let data = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E];
let (buf, n) = build(0x01, 0x08, &data);
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &data);
}
/// The canonical published RTU example: unit 1, read holding registers,
/// start 0x006B, count 3, which every Modbus reference renders as
/// `01 03 00 6B 00 03 74 17`. Anchors our byte order and CRC placement
/// against an external reference rather than only against ourselves.
#[test]
fn matches_a_published_rtu_example() {
let (buf, n) = build(0x01, 0x03, &[0x00, 0x6B, 0x00, 0x03]);
assert_eq!(&buf[..n], &[0x01, 0x03, 0x00, 0x6B, 0x00, 0x03, 0x74, 0x17]);
}
#[test]
fn rejects_short_and_long() {
assert_eq!(Frame::parse(&[]), Err(ParseError::TooShort));
assert_eq!(Frame::parse(&[0, 0, 0, 0]), Err(ParseError::TooShort));
assert_eq!(Frame::parse(&[0, 0, 0]), Err(ParseError::TooShort));
assert_eq!(
Frame::parse(&[0u8; MAX_FRAME + 1]),
Err(ParseError::TooLong)
);
}
#[test]
fn rejects_length_field_mismatch() {
let (mut buf, n) = build(0x01, 0x01, &[1, 2, 3]);
buf[2] = 2; // claim one byte fewer than actually present
assert_eq!(Frame::parse(&buf[..n]), Err(ParseError::LengthMismatch));
}
/// With no length field the CRC is the only integrity check there is, so it
/// has to catch every single-bit corruption on its own.
#[test]
fn rejects_single_bit_corruption_anywhere() {
let payload = [0x11, 0x22, 0x33];
let (good, n) = build(0x07, 0x02, &payload);
let data = [0x11, 0x22, 0x33];
let (good, n) = build(0x07, 0x03, &data);
for byte in 0..n {
for bit in 0..8 {
let mut bad = good;
bad[byte] ^= 1 << bit;
// A flip in the LEN byte is caught by the length check first;
// everything else must be caught by the CRC. Either way the
// frame must not parse as valid.
assert!(
Frame::parse(&bad[..n]).is_err(),
assert_eq!(
Frame::parse(&bad[..n]),
Err(ParseError::BadCrc),
"flip of byte {byte} bit {bit} was accepted"
);
}
@@ -218,28 +221,29 @@ mod tests {
#[test]
fn truncation_is_rejected() {
let (buf, n) = build(0x01, 0x01, &[9, 9, 9, 9]);
let (buf, n) = build(0x01, 0x03, &[0x00, 0x00, 0x00, 0x13]);
for shorter in MIN_FRAME..n {
assert!(Frame::parse(&buf[..shorter]).is_err());
assert_eq!(
Frame::parse(&buf[..shorter]),
Err(ParseError::BadCrc),
"a frame truncated to {shorter} bytes was accepted"
);
}
}
#[test]
fn oversized_payload_is_refused() {
fn oversized_data_is_refused() {
let mut out = [0u8; MAX_FRAME + 8];
assert_eq!(
encode(1, 1, &[0u8; MAX_PAYLOAD + 1], &mut out),
Err(EncodeError::PayloadTooLong)
encode(1, 3, &[0u8; MAX_DATA + 1], &mut out),
Err(EncodeError::DataTooLong)
);
}
#[test]
fn small_buffer_is_refused() {
let mut out = [0u8; 4];
assert_eq!(
encode(1, 1, &[], &mut out),
Err(EncodeError::BufferTooSmall)
);
let mut out = [0u8; 3];
assert_eq!(encode(1, 3, &[], &mut out), Err(EncodeError::BufferTooSmall));
}
#[test]
+2 -2
View File
@@ -1,7 +1,7 @@
//! Hardware-independent core of the wiredsensor RS485 temperature/humidity node.
//!
//! Everything in this crate is pure computation: framing, CRCs, command dispatch
//! and SHT3x data-sheet conversions. It performs no I/O and touches no
//! Everything in this crate is pure computation: Modbus RTU framing, CRCs, the
//! register map and SHT3x data-sheet conversions. It performs no I/O and touches no
//! peripherals, which keeps the protocol testable on the host with a plain
//! `cargo test` while the firmware crate owns all register access.
+512 -270
View File
@@ -1,73 +1,161 @@
//! Application protocol: command codes, payload layouts and request dispatch.
//! Modbus RTU server: function codes, the register map and request dispatch.
//!
//! All multi-byte payload fields are **little-endian**, matching the CRC byte
//! order on the wire and the native order of both the RP2040 and any x86/ARM
//! host talking to it.
//! The node is a Modbus **server** (slave) speaking RTU over RS485. It answers
//! two function codes — `0x03` Read Holding Registers and `0x04` Read Input
//! Registers — over one shared register table, plus `0x08` Read Diagnostics as a
//! link test. Everything the device knows is exposed as 16-bit registers, which
//! is what makes it readable by any off-the-shelf master, ESPHome's
//! `modbus_controller` among them.
//!
//! [`dispatch`] is the whole slave-side behaviour of the node, expressed as a
//! Register values are **big-endian**, high byte first, as the Modbus
//! specification requires. Quantities wider than 16 bits occupy two consecutive
//! registers, **high word first** — the order masters conventionally assume for
//! a 32-bit value, and what ESPHome calls `S_DWORD` / `U_DWORD`.
//!
//! Measured values and identity live in one flat table rather than being split
//! between input and holding space. Serving both function codes from it costs one
//! extra match arm and means a master that only implements `0x03` still works,
//! which several do.
//!
//! [`dispatch`] is the whole server-side behaviour of the node, expressed as a
//! pure function of the received bytes plus a [`Snapshot`] of device state. The
//! firmware supplies the snapshot and puts the returned bytes on the wire; that
//! split is what lets the entire protocol be exercised by host unit tests.
use crate::frame::{self, Frame, ParseError, MAX_FRAME};
/// Protocol revision reported by [`cmd::READ_INFO`]. Bump on any wire-visible
/// change.
pub const PROTOCOL_VERSION: u8 = 1;
/// Register-map revision, reported by [`reg::FW_PATCH_PROTO`]. Bump on any
/// wire-visible change.
///
/// Version 1 was a custom command protocol; version 2 is Modbus RTU. The two
/// cannot coexist on a segment — the old command codes `0x01`..`0x04` are all
/// valid Modbus function codes, so a register read would be indistinguishable
/// from a legacy command.
pub const PROTOCOL_VERSION: u8 = 2;
/// Command codes.
pub mod cmd {
/// Return the latest temperature and humidity reading.
pub const READ_MEASUREMENT: u8 = 0x01;
/// Return firmware version, serial numbers and uptime.
pub const READ_INFO: u8 = 0x02;
/// Return health flags and error counters.
pub const READ_STATUS: u8 = 0x03;
/// Echo the payload back unchanged; a pure link test.
pub const PING: u8 = 0x04;
/// Reserved for runtime address assignment. This build takes its address
/// from a compile-time constant, so the command answers
/// [`err::UNSUPPORTED`] rather than pretending to succeed.
pub const SET_ADDRESS: u8 = 0x10;
/// Function codes this server implements.
pub mod fc {
/// Read Holding Registers.
pub const READ_HOLDING_REGISTERS: u8 = 0x03;
/// Read Input Registers. Served from the same table as `0x03`.
pub const READ_INPUT_REGISTERS: u8 = 0x04;
/// Read Diagnostics — see [`super::DIAG_RETURN_QUERY_DATA`].
pub const DIAGNOSTIC: u8 = 0x08;
}
/// Set on the command byte of a reply to mark it as an error response, so a
/// master can tell success from failure without inspecting the payload.
pub const ERROR_FLAG: u8 = 0x80;
/// Set on the function code of a response to mark it an exception, so a master
/// can tell success from failure without inspecting the data field.
pub const EXCEPTION_FLAG: u8 = 0x80;
/// Error codes carried as the single payload byte of an error reply.
pub mod err {
/// The command code is not implemented.
pub const ILLEGAL_COMMAND: u8 = 0x01;
/// The command does not take a payload of the supplied length.
pub const ILLEGAL_LENGTH: u8 = 0x02;
/// No valid reading has been obtained since power-up.
pub const SENSOR_UNAVAILABLE: u8 = 0x03;
/// The sensor is present but persistently failing.
pub const SENSOR_FAULT: u8 = 0x04;
/// The command is recognised but disabled in this build.
pub const UNSUPPORTED: u8 = 0x05;
/// Modbus exception codes, carried as the single data byte of an exception
/// response.
pub mod exception {
/// The function code is not implemented here.
pub const ILLEGAL_FUNCTION: u8 = 0x01;
/// The requested register range falls outside the map.
pub const ILLEGAL_DATA_ADDRESS: u8 = 0x02;
/// A field of the request is not a value this function accepts.
pub const ILLEGAL_DATA_VALUE: u8 = 0x03;
/// The device cannot answer: no valid reading has ever been taken, or the
/// sensor is persistently failing.
pub const SERVER_DEVICE_FAILURE: u8 = 0x04;
}
/// Bit masks for the `flags` byte of [`Status`].
/// The one [`fc::DIAGNOSTIC`] sub-function implemented: echo the data field back
/// unchanged.
///
/// This is the intended first bring-up step, and the equivalent of the `PING`
/// command the pre-Modbus protocol had. It exercises framing, CRC and the RS485
/// driver-enable turnaround without involving the sensor or the register map at
/// all.
pub const DIAG_RETURN_QUERY_DATA: u16 = 0x0000;
/// Most registers a single read may ask for: `(256 - 5) / 2`, rounded down, as
/// the specification fixes it.
///
/// Larger than the whole map on purpose. The limit is a property of the RTU
/// frame size, and a master asking for 200 registers has made a different
/// mistake from one asking for 5 registers past the end — so the two get
/// different exception codes.
pub const MAX_READ_REGISTERS: u16 = 125;
/// The register map.
///
/// Addresses are stable: append to the end rather than renumbering, or every
/// deployed master's configuration breaks at once.
pub mod reg {
/// Temperature in milli-degrees Celsius, signed, 2 registers.
pub const TEMP_MILLI_C: u16 = 0x0000;
/// Relative humidity in milli-percent, signed, 2 registers.
pub const RH_MILLI_PCT: u16 = 0x0002;
/// Age of the cached reading in milliseconds, saturating at [`u16::MAX`].
pub const AGE_MS: u16 = 0x0004;
/// Health flags; see the [`flag`](super::flag) masks.
pub const FLAGS: u16 = 0x0005;
/// Raw SHT3x status register from the last successful read.
pub const SENSOR_STATUS: u16 = 0x0006;
/// I2C transfer failures since power-up.
pub const I2C_ERRORS: u16 = 0x0007;
/// Sensor CRC-8 mismatches since power-up.
pub const SENSOR_CRC_ERRORS: u16 = 0x0008;
/// Frames rejected on length or framing grounds since power-up.
pub const FRAME_ERRORS: u16 = 0x0009;
/// Frames rejected on CRC-16 grounds since power-up.
pub const CRC_ERRORS: u16 = 0x000A;
/// Firmware version: major in the high byte, minor in the low byte.
pub const FW_MAJOR_MINOR: u16 = 0x000B;
/// Firmware patch in the high byte, [`PROTOCOL_VERSION`](super::PROTOCOL_VERSION)
/// in the low byte.
pub const FW_PATCH_PROTO: u16 = 0x000C;
/// Board serial, fixed at build time, 2 registers.
pub const DEVICE_SERIAL: u16 = 0x000D;
/// SHT3x factory serial number, 2 registers, zero if it could not be read.
pub const SENSOR_SERIAL: u16 = 0x000F;
/// Seconds since power-up, saturating, 2 registers.
pub const UPTIME_S: u16 = 0x0011;
/// One past the last valid register address.
pub const COUNT: u16 = 0x0013;
/// One past the last register whose value depends on a reading existing.
///
/// A read touching `0x0000..MEASUREMENT_END` fails with
/// [`SERVER_DEVICE_FAILURE`](super::exception::SERVER_DEVICE_FAILURE) when
/// the sensor has never produced a reading. Reads confined to the diagnostic
/// and identity registers above it still succeed, which is deliberate: those
/// are exactly the registers you need to work out *why* the sensor is
/// silent. Reporting a zeroed measurement as valid would be the worse
/// failure — a master cannot tell it from a real 0 °C.
///
/// Note the interaction with masters that coalesce adjacent registers into
/// one request, ESPHome among them: a merged read that happens to span this
/// boundary is refused as a whole. Serving both `0x03` and `0x04` from one
/// table is what gives such a master a way out — the same registers under
/// the other function code land in a separate request.
pub const MEASUREMENT_END: u16 = 0x0005;
}
/// Bit masks for [`reg::FLAGS`].
pub mod flag {
/// The most recent sensor poll succeeded.
pub const SENSOR_OK: u8 = 1 << 0;
pub const SENSOR_OK: u16 = 1 << 0;
/// The cached reading is older than the node's freshness threshold.
pub const DATA_STALE: u8 = 1 << 1;
pub const DATA_STALE: u16 = 1 << 1;
/// Consecutive sensor failures have exceeded the fault threshold.
pub const SENSOR_FAULT: u8 = 1 << 2;
pub const SENSOR_FAULT: u16 = 1 << 2;
/// The UART reported a receive overrun or line error since power-up.
pub const UART_ERROR: u8 = 1 << 3;
pub const UART_ERROR: u16 = 1 << 3;
/// At least one valid reading has been taken since power-up.
pub const EVER_MEASURED: u8 = 1 << 4;
pub const EVER_MEASURED: u16 = 1 << 4;
/// The sensor reported an unexpected reset, and was re-initialised.
pub const SENSOR_RESET_SEEN: u8 = 1 << 5;
pub const SENSOR_RESET_SEEN: u16 = 1 << 5;
/// The sensor's internal heater is on.
pub const HEATER_ON: u8 = 1 << 6;
pub const HEATER_ON: u16 = 1 << 6;
}
/// Payload of a successful [`cmd::READ_MEASUREMENT`] reply.
/// The latest reading, as it appears in the measurement registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Measurement {
/// Temperature in milli-degrees Celsius.
@@ -82,27 +170,18 @@ pub struct Measurement {
}
impl Measurement {
/// Encoded length in bytes.
pub const LEN: usize = 10;
/// Serialise into the wire layout.
pub fn encode(&self, out: &mut [u8; Self::LEN]) {
out[0..4].copy_from_slice(&self.temp_milli_c.to_le_bytes());
out[4..8].copy_from_slice(&self.rh_milli_pct.to_le_bytes());
out[8..10].copy_from_slice(&self.age_ms.to_le_bytes());
}
/// Parse the wire layout. Provided for host-side masters and tests.
pub fn decode(raw: &[u8; Self::LEN]) -> Self {
/// Read a measurement back out of a register image. For host-side masters
/// and tests.
pub fn from_registers(regs: &[u16; 5]) -> Self {
Self {
temp_milli_c: i32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]),
rh_milli_pct: i32::from_le_bytes([raw[4], raw[5], raw[6], raw[7]]),
age_ms: u16::from_le_bytes([raw[8], raw[9]]),
temp_milli_c: get_u32(regs, reg::TEMP_MILLI_C) as i32,
rh_milli_pct: get_u32(regs, reg::RH_MILLI_PCT) as i32,
age_ms: regs[reg::AGE_MS as usize],
}
}
}
/// Payload of a [`cmd::READ_INFO`] reply.
/// Identity and version, as it appears in the info registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Info {
/// Firmware major version.
@@ -121,27 +200,11 @@ pub struct Info {
pub uptime_s: u32,
}
impl Info {
/// Encoded length in bytes.
pub const LEN: usize = 16;
/// Serialise into the wire layout.
pub fn encode(&self, out: &mut [u8; Self::LEN]) {
out[0] = self.fw_major;
out[1] = self.fw_minor;
out[2] = self.fw_patch;
out[3] = self.proto_version;
out[4..8].copy_from_slice(&self.device_serial.to_le_bytes());
out[8..12].copy_from_slice(&self.sensor_serial.to_le_bytes());
out[12..16].copy_from_slice(&self.uptime_s.to_le_bytes());
}
}
/// Payload of a [`cmd::READ_STATUS`] reply.
/// Health flags and counters, as they appear in the diagnostic registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Status {
/// Bitwise OR of the [`flag`] masks.
pub flags: u8,
pub flags: u16,
/// Raw SHT3x status register from the last successful read.
pub sensor_status: u16,
/// I2C transfer failures since power-up.
@@ -154,21 +217,6 @@ pub struct Status {
pub crc_errors: u16,
}
impl Status {
/// Encoded length in bytes.
pub const LEN: usize = 11;
/// Serialise into the wire layout.
pub fn encode(&self, out: &mut [u8; Self::LEN]) {
out[0] = self.flags;
out[1..3].copy_from_slice(&self.sensor_status.to_le_bytes());
out[3..5].copy_from_slice(&self.i2c_errors.to_le_bytes());
out[5..7].copy_from_slice(&self.sensor_crc_errors.to_le_bytes());
out[7..9].copy_from_slice(&self.frame_errors.to_le_bytes());
out[9..11].copy_from_slice(&self.crc_errors.to_le_bytes());
}
}
/// Everything [`dispatch`] is allowed to know about the device.
#[derive(Debug, Clone, Copy)]
pub struct Snapshot {
@@ -180,6 +228,54 @@ pub struct Snapshot {
pub status: Status,
}
impl Snapshot {
/// Render the whole register map.
///
/// Built in full and then sliced, rather than each read assembling only the
/// registers it was asked for. 38 bytes of stack buys the guarantee that a
/// range read cannot disagree with a single-register read of the same
/// address.
pub fn registers(&self) -> [u16; reg::COUNT as usize] {
let mut r = [0u16; reg::COUNT as usize];
// Left at zero when there is no reading. A read that would expose them
// is refused in `read_registers`, so the zeros are never observable.
if let Some(m) = self.measurement {
put_u32(&mut r, reg::TEMP_MILLI_C, m.temp_milli_c as u32);
put_u32(&mut r, reg::RH_MILLI_PCT, m.rh_milli_pct as u32);
r[reg::AGE_MS as usize] = m.age_ms;
}
r[reg::FLAGS as usize] = self.status.flags;
r[reg::SENSOR_STATUS as usize] = self.status.sensor_status;
r[reg::I2C_ERRORS as usize] = self.status.i2c_errors;
r[reg::SENSOR_CRC_ERRORS as usize] = self.status.sensor_crc_errors;
r[reg::FRAME_ERRORS as usize] = self.status.frame_errors;
r[reg::CRC_ERRORS as usize] = self.status.crc_errors;
r[reg::FW_MAJOR_MINOR as usize] =
(u16::from(self.info.fw_major) << 8) | u16::from(self.info.fw_minor);
r[reg::FW_PATCH_PROTO as usize] =
(u16::from(self.info.fw_patch) << 8) | u16::from(self.info.proto_version);
put_u32(&mut r, reg::DEVICE_SERIAL, self.info.device_serial);
put_u32(&mut r, reg::SENSOR_SERIAL, self.info.sensor_serial);
put_u32(&mut r, reg::UPTIME_S, self.info.uptime_s);
r
}
}
/// Write a 32-bit value across two registers, high word first.
fn put_u32(regs: &mut [u16], at: u16, v: u32) {
regs[at as usize] = (v >> 16) as u16;
regs[at as usize + 1] = v as u16;
}
/// Read back what [`put_u32`] wrote.
fn get_u32(regs: &[u16], at: u16) -> u32 {
(u32::from(regs[at as usize]) << 16) | u32::from(regs[at as usize + 1])
}
/// What the firmware should do with a received byte sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome<'o> {
@@ -190,7 +286,8 @@ pub enum Outcome<'o> {
Silent,
/// The bytes were not a usable frame. The firmware should bump the matching
/// counter and stay silent — replying to a frame whose address we cannot
/// trust risks a collision with the node that was actually addressed.
/// trust risks a collision with the node that was actually addressed, and
/// the specification requires silence here.
Malformed(ParseError),
}
@@ -214,79 +311,104 @@ pub fn dispatch<'o>(
};
// This also covers the broadcast address, since a valid unit address is
// never 0 — there are no write commands, so a broadcast asks nothing of us.
// never 0 — the map is read-only, so a broadcast asks nothing of us.
if f.addr != unit {
return Outcome::Silent;
}
match f.cmd {
cmd::READ_MEASUREMENT => {
if !f.payload.is_empty() {
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
}
match snap.measurement {
Some(m) => {
let mut p = [0u8; Measurement::LEN];
m.encode(&mut p);
reply(out, unit, f.cmd, &p)
}
None if snap.status.flags & flag::SENSOR_FAULT != 0 => {
error(out, unit, f.cmd, err::SENSOR_FAULT)
}
None => error(out, unit, f.cmd, err::SENSOR_UNAVAILABLE),
}
match f.fc {
fc::READ_HOLDING_REGISTERS | fc::READ_INPUT_REGISTERS => {
read_registers(out, unit, f.fc, f.data, snap)
}
cmd::READ_INFO => {
if !f.payload.is_empty() {
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
}
let mut p = [0u8; Info::LEN];
snap.info.encode(&mut p);
reply(out, unit, f.cmd, &p)
}
cmd::READ_STATUS => {
if !f.payload.is_empty() {
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
}
let mut p = [0u8; Status::LEN];
snap.status.encode(&mut p);
reply(out, unit, f.cmd, &p)
}
// Echo verbatim. Proves the full round trip — framing, CRC and RS485
// driver-enable turnaround — without involving the sensor at all.
cmd::PING => reply(out, unit, f.cmd, f.payload),
cmd::SET_ADDRESS => error(out, unit, f.cmd, err::UNSUPPORTED),
_ => error(out, unit, f.cmd, err::ILLEGAL_COMMAND),
fc::DIAGNOSTIC => diagnostic(out, unit, f.data),
_ => exception(out, unit, f.fc, exception::ILLEGAL_FUNCTION),
}
}
/// Encode a success reply.
fn reply<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, payload: &[u8]) -> Outcome<'o> {
let n = match frame::encode(addr, cmd, payload, out.as_mut_slice()) {
/// Serve `0x03` and `0x04`: `start` and `count`, both big-endian, in and a
/// byte-counted block of registers out.
fn read_registers<'o>(
out: &'o mut [u8; MAX_FRAME],
unit: u8,
fc: u8,
data: &[u8],
snap: &Snapshot,
) -> Outcome<'o> {
if data.len() != 4 {
return exception(out, unit, fc, exception::ILLEGAL_DATA_VALUE);
}
let start = u16::from_be_bytes([data[0], data[1]]);
let count = u16::from_be_bytes([data[2], data[3]]);
// Order matters. An impossible count is a malformed request whatever the
// map looks like, so it is rejected before the range check — otherwise
// `start + count` overflowing the map would mask it as a bad address.
if count == 0 || count > MAX_READ_REGISTERS {
return exception(out, unit, fc, exception::ILLEGAL_DATA_VALUE);
}
// Widened so a start near 0xFFFF cannot wrap into a range that looks valid.
if u32::from(start) + u32::from(count) > u32::from(reg::COUNT) {
return exception(out, unit, fc, exception::ILLEGAL_DATA_ADDRESS);
}
if start < reg::MEASUREMENT_END && snap.measurement.is_none() {
return exception(out, unit, fc, exception::SERVER_DEVICE_FAILURE);
}
let regs = snap.registers();
// The checks above bound `count` by `reg::COUNT`, so the whole map plus its
// byte-count byte is always enough room.
let mut pdu = [0u8; 1 + 2 * reg::COUNT as usize];
let byte_count = count as usize * 2;
pdu[0] = byte_count as u8;
for i in 0..count as usize {
let v = regs[start as usize + i];
pdu[1 + 2 * i..3 + 2 * i].copy_from_slice(&v.to_be_bytes());
}
reply(out, unit, fc, &pdu[..1 + byte_count])
}
/// Serve `0x08`: echo the data field, sub-function included.
///
/// The specification's Return Query Data carries exactly two data bytes, but any
/// length is echoed here. A longer echo is a strictly better link test — it
/// reaches frame lengths past the UART's FIFO watermark, where the frame-gap
/// logic behaves differently — and no real master sends anything else.
fn diagnostic<'o>(out: &'o mut [u8; MAX_FRAME], unit: u8, data: &[u8]) -> Outcome<'o> {
if data.len() < 2 {
return exception(out, unit, fc::DIAGNOSTIC, exception::ILLEGAL_DATA_VALUE);
}
if u16::from_be_bytes([data[0], data[1]]) != DIAG_RETURN_QUERY_DATA {
// The function code is implemented but this sub-function is not, which
// from the master's side is the same fact: the diagnostic is unavailable
// here.
return exception(out, unit, fc::DIAGNOSTIC, exception::ILLEGAL_FUNCTION);
}
reply(out, unit, fc::DIAGNOSTIC, data)
}
/// Encode a success response.
fn reply<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, fc: u8, data: &[u8]) -> Outcome<'o> {
let n = match frame::encode(addr, fc, data, out.as_mut_slice()) {
Ok(n) => n,
// Unreachable: every payload built above is at most MAX_PAYLOAD, and a
// PING echo is bounded by the request it mirrors. Staying silent is the
// safe failure mode on a shared bus.
// Unreachable: every response built above is at most MAX_DATA, and a
// diagnostic echo is bounded by the request it mirrors. Staying silent
// is the safe failure mode on a shared bus.
Err(_) => return Outcome::Silent,
};
Outcome::Reply(&out[..n])
}
/// Encode an error reply: the command code with [`ERROR_FLAG`] set, and a
/// one-byte reason.
fn error<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, code: u8) -> Outcome<'o> {
reply(out, addr, cmd | ERROR_FLAG, &[code])
/// Encode an exception response: the function code with [`EXCEPTION_FLAG`] set,
/// and a one-byte reason.
fn exception<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, fc: u8, code: u8) -> Outcome<'o> {
reply(out, addr, fc | EXCEPTION_FLAG, &[code])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::MAX_PAYLOAD;
const UNIT: u8 = 0x11;
@@ -317,91 +439,235 @@ mod tests {
}
}
/// Build a request and run it through dispatch, returning the parsed reply.
fn exchange(unit: u8, cmd: u8, payload: &[u8]) -> (Snapshot, [u8; MAX_FRAME], usize) {
/// Build a request and run it through dispatch, returning the raw response.
fn exchange_with(snap: &Snapshot, fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(unit, cmd, payload, &mut req).unwrap();
let snap = snapshot();
let n = frame::encode(UNIT, fc, data, &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
match dispatch(UNIT, &req[..n], &snap, &mut out) {
match dispatch(UNIT, &req[..n], snap, &mut out) {
Outcome::Reply(r) => {
let len = r.len();
let mut copy = [0u8; MAX_FRAME];
copy[..len].copy_from_slice(r);
(snap, copy, len)
(copy, len)
}
other => panic!("expected a reply, got {other:?}"),
}
}
fn exchange(fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
exchange_with(&snapshot(), fc, data)
}
/// A register read as a master issues it.
fn read(fc: u8, start: u16, count: u16) -> ([u8; MAX_FRAME], usize) {
let s = start.to_be_bytes();
let c = count.to_be_bytes();
exchange(fc, &[s[0], s[1], c[0], c[1]])
}
/// Decode a `0x03`/`0x04` response body into registers.
fn registers_of(buf: &[u8]) -> Vec<u16> {
let f = Frame::parse(buf).expect("response did not parse");
assert_eq!(f.fc & EXCEPTION_FLAG, 0, "unexpected exception response");
let byte_count = f.data[0] as usize;
assert_eq!(
byte_count,
f.data.len() - 1,
"byte count disagrees with the response length"
);
f.data[1..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect()
}
/// The exception code of a response, or `None` if it was a success.
fn exception_of(buf: &[u8], expect_fc: u8) -> Option<u8> {
let f = Frame::parse(buf).expect("response did not parse");
if f.fc & EXCEPTION_FLAG == 0 {
return None;
}
assert_eq!(f.fc, expect_fc | EXCEPTION_FLAG);
assert_eq!(f.data.len(), 1, "an exception carries exactly one byte");
Some(f.data[0])
}
#[test]
fn read_measurement_roundtrips_through_the_wire() {
let (snap, buf, n) = exchange(UNIT, cmd::READ_MEASUREMENT, &[]);
fn measurement_roundtrips_through_the_wire() {
let snap = snapshot();
for fc in [fc::READ_HOLDING_REGISTERS, fc::READ_INPUT_REGISTERS] {
let (buf, n) = read(fc, reg::TEMP_MILLI_C, 5);
let regs = registers_of(&buf[..n]);
let image: [u16; 5] = regs.try_into().expect("expected 5 registers");
assert_eq!(
Measurement::from_registers(&image),
snap.measurement.unwrap(),
"function code {fc:#04x}"
);
}
}
/// Registers are big-endian and 32-bit values are high word first. This is
/// the single most likely thing to get wrong, and a master would silently
/// report a wildly wrong temperature rather than fail.
#[test]
fn register_byte_order_is_big_endian_high_word_first() {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, reg::TEMP_MILLI_C, 2);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.addr, UNIT);
assert_eq!(f.cmd, cmd::READ_MEASUREMENT);
assert_eq!(f.cmd & ERROR_FLAG, 0);
let mut raw = [0u8; Measurement::LEN];
raw.copy_from_slice(f.payload);
assert_eq!(Measurement::decode(&raw), snap.measurement.unwrap());
// 23_450 = 0x00005B9A
assert_eq!(&f.data[1..5], &[0x00, 0x00, 0x5B, 0x9A]);
}
#[test]
fn negative_temperature_survives_the_encoding() {
let m = Measurement {
let mut snap = snapshot();
snap.measurement = Some(Measurement {
temp_milli_c: -12_345,
rh_milli_pct: 0,
age_ms: 0,
};
let mut raw = [0u8; Measurement::LEN];
m.encode(&mut raw);
assert_eq!(Measurement::decode(&raw), m);
});
let s = reg::TEMP_MILLI_C.to_be_bytes();
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], 0, 5]);
let image: [u16; 5] = registers_of(&buf[..n]).try_into().unwrap();
assert_eq!(Measurement::from_registers(&image).temp_milli_c, -12_345);
}
#[test]
fn read_info_payload_layout() {
let (snap, buf, n) = exchange(UNIT, cmd::READ_INFO, &[]);
fn info_and_status_registers_carry_the_snapshot() {
let snap = snapshot();
let (buf, n) = read(fc::READ_HOLDING_REGISTERS, 0, reg::COUNT);
let r = registers_of(&buf[..n]);
assert_eq!(r.len(), reg::COUNT as usize);
assert_eq!(r[reg::FLAGS as usize], snap.status.flags);
assert_eq!(r[reg::SENSOR_STATUS as usize], snap.status.sensor_status);
assert_eq!(r[reg::CRC_ERRORS as usize], snap.status.crc_errors);
assert_eq!(
r[reg::FW_PATCH_PROTO as usize] & 0xFF,
u16::from(PROTOCOL_VERSION)
);
assert_eq!(get_u32(&r, reg::DEVICE_SERIAL), snap.info.device_serial);
assert_eq!(get_u32(&r, reg::SENSOR_SERIAL), snap.info.sensor_serial);
assert_eq!(get_u32(&r, reg::UPTIME_S), snap.info.uptime_s);
}
/// A sub-range read must agree with the same registers read as part of a
/// larger block, which is what lets a master coalesce reads freely.
#[test]
fn subrange_reads_agree_with_the_whole_map() {
let (whole, wn) = read(fc::READ_INPUT_REGISTERS, 0, reg::COUNT);
let all = registers_of(&whole[..wn]);
for start in 0..reg::COUNT {
for count in 1..=(reg::COUNT - start) {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, start, count);
let part = registers_of(&buf[..n]);
assert_eq!(
part,
all[start as usize..(start + count) as usize],
"start {start}, count {count}"
);
}
}
}
#[test]
fn read_past_the_end_is_an_illegal_address() {
for (start, count) in [(reg::COUNT, 1), (reg::COUNT - 1, 2), (0, reg::COUNT + 1)] {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, start, count);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_ADDRESS),
"start {start}, count {count}"
);
}
}
/// A start address high enough that `start + count` would wrap in 16-bit
/// arithmetic must still be refused rather than looping back into the map.
#[test]
fn a_wrapping_range_is_refused() {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, 0xFFFF, 2);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_ADDRESS)
);
}
#[test]
fn an_impossible_count_is_an_illegal_value() {
for count in [0, MAX_READ_REGISTERS + 1, 0xFFFF] {
let (buf, n) = read(fc::READ_INPUT_REGISTERS, 0, count);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_VALUE),
"count {count}"
);
}
}
#[test]
fn a_misshaped_request_is_an_illegal_value() {
for data in [&[][..], &[0x00][..], &[0, 0, 0][..], &[0, 0, 0, 1, 0][..]] {
let (buf, n) = exchange(fc::READ_INPUT_REGISTERS, data);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::ILLEGAL_DATA_VALUE),
"request data {data:02x?}"
);
}
}
#[test]
fn unknown_function_code_is_refused() {
for fc in [0x01, 0x02, 0x06, 0x10, 0x7E] {
let (buf, n) = exchange(fc, &[0, 0, 0, 1]);
assert_eq!(
exception_of(&buf[..n], fc),
Some(exception::ILLEGAL_FUNCTION),
"function code {fc:#04x}"
);
}
}
#[test]
fn diagnostic_echoes_the_data_field() {
let probe = [0x00, 0x00, 0xFF, 0x55, 0xAA, 0x0D, 0x0A];
let (buf, n) = exchange(fc::DIAGNOSTIC, &probe);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.payload.len(), Info::LEN);
assert_eq!(f.payload[3], PROTOCOL_VERSION);
assert_eq!(f.fc, fc::DIAGNOSTIC);
assert_eq!(f.data, &probe);
}
#[test]
fn diagnostic_echoes_a_maximum_length_field() {
let mut probe = [0x5Au8; frame::MAX_DATA];
probe[0] = 0;
probe[1] = 0;
let (buf, n) = exchange(fc::DIAGNOSTIC, &probe);
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &probe);
assert_eq!(n, MAX_FRAME);
}
#[test]
fn unknown_diagnostic_subfunction_is_refused() {
let (buf, n) = exchange(fc::DIAGNOSTIC, &[0x00, 0x0A]);
assert_eq!(
u32::from_le_bytes([f.payload[4], f.payload[5], f.payload[6], f.payload[7]]),
snap.info.device_serial
);
assert_eq!(
u32::from_le_bytes([f.payload[8], f.payload[9], f.payload[10], f.payload[11]]),
snap.info.sensor_serial
exception_of(&buf[..n], fc::DIAGNOSTIC),
Some(exception::ILLEGAL_FUNCTION)
);
}
#[test]
fn read_status_payload_layout() {
let (snap, buf, n) = exchange(UNIT, cmd::READ_STATUS, &[]);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.payload.len(), Status::LEN);
assert_eq!(f.payload[0], snap.status.flags);
fn a_truncated_diagnostic_is_an_illegal_value() {
let (buf, n) = exchange(fc::DIAGNOSTIC, &[0x00]);
assert_eq!(
u16::from_le_bytes([f.payload[1], f.payload[2]]),
snap.status.sensor_status
exception_of(&buf[..n], fc::DIAGNOSTIC),
Some(exception::ILLEGAL_DATA_VALUE)
);
}
#[test]
fn ping_echoes_payload_verbatim() {
let probe = [0x00, 0xFF, 0x55, 0xAA, 0x0D, 0x0A];
let (_, buf, n) = exchange(UNIT, cmd::PING, &probe);
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &probe);
}
#[test]
fn ping_echoes_a_maximum_payload() {
let probe = [0x5Au8; MAX_PAYLOAD];
let (_, buf, n) = exchange(UNIT, cmd::PING, &probe);
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &probe);
}
/// The one behaviour that matters most on a shared bus: never transmit
/// unless the frame was addressed to us specifically.
#[test]
@@ -414,7 +680,8 @@ mod tests {
frame::ADDR_MAX,
] {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(addr, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
let n =
frame::encode(addr, fc::READ_INPUT_REGISTERS, &[0, 0, 0, 5], &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
assert_eq!(
dispatch(UNIT, &req[..n], &snap, &mut out),
@@ -430,12 +697,12 @@ mod tests {
let mut out = [0u8; MAX_FRAME];
assert_eq!(
dispatch(UNIT, &[0x11, 0x01], &snap, &mut out),
dispatch(UNIT, &[0x11, 0x03], &snap, &mut out),
Outcome::Malformed(ParseError::TooShort)
);
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
let n = frame::encode(UNIT, fc::READ_INPUT_REGISTERS, &[0, 0, 0, 5], &mut req).unwrap();
req[n - 1] ^= 0xFF; // corrupt the CRC
assert_eq!(
dispatch(UNIT, &req[..n], &snap, &mut out),
@@ -444,80 +711,55 @@ mod tests {
}
#[test]
fn unknown_command_yields_illegal_command() {
let (_, buf, n) = exchange(UNIT, 0x7E, &[]);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.cmd, 0x7E | ERROR_FLAG);
assert_eq!(f.payload, &[err::ILLEGAL_COMMAND]);
}
#[test]
fn set_address_is_refused_rather_than_ignored() {
let (_, buf, n) = exchange(UNIT, cmd::SET_ADDRESS, &[0x22]);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.cmd, cmd::SET_ADDRESS | ERROR_FLAG);
assert_eq!(f.payload, &[err::UNSUPPORTED]);
}
#[test]
fn readers_reject_unexpected_payloads() {
for c in [cmd::READ_MEASUREMENT, cmd::READ_INFO, cmd::READ_STATUS] {
let (_, buf, n) = exchange(UNIT, c, &[0xAA]);
let f = Frame::parse(&buf[..n]).unwrap();
assert_eq!(f.cmd, c | ERROR_FLAG, "command {c:#04x}");
assert_eq!(f.payload, &[err::ILLEGAL_LENGTH], "command {c:#04x}");
}
}
#[test]
fn missing_reading_reports_unavailable() {
fn missing_reading_fails_only_the_measurement_registers() {
let mut snap = snapshot();
snap.measurement = None;
snap.status.flags = 0;
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
match dispatch(UNIT, &req[..n], &snap, &mut out) {
Outcome::Reply(r) => {
let f = Frame::parse(r).unwrap();
assert_eq!(f.payload, &[err::SENSOR_UNAVAILABLE]);
}
other => panic!("expected an error reply, got {other:?}"),
// Anything overlapping the measurement block is refused...
for (start, count) in [(reg::TEMP_MILLI_C, 1), (reg::AGE_MS, 2), (0, reg::COUNT)] {
let s = start.to_be_bytes();
let c = count.to_be_bytes();
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], c[0], c[1]]);
assert_eq!(
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
Some(exception::SERVER_DEVICE_FAILURE),
"start {start}, count {count}"
);
}
// ...but the diagnostic and identity registers still read, which is how
// a master works out why the sensor is quiet.
let s = reg::FLAGS.to_be_bytes();
let count = reg::COUNT - reg::FLAGS;
let c = count.to_be_bytes();
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], c[0], c[1]]);
let r = registers_of(&buf[..n]);
assert_eq!(r.len(), count as usize);
assert_eq!(r[0], 0, "flags");
}
/// Every response we emit must itself be a frame our own parser accepts,
/// whatever function code and data field arrive.
#[test]
fn faulted_sensor_reports_fault_instead_of_unavailable() {
let mut snap = snapshot();
snap.measurement = None;
snap.status.flags = flag::SENSOR_FAULT;
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
match dispatch(UNIT, &req[..n], &snap, &mut out) {
Outcome::Reply(r) => {
let f = Frame::parse(r).unwrap();
assert_eq!(f.payload, &[err::SENSOR_FAULT]);
}
other => panic!("expected an error reply, got {other:?}"),
}
}
/// Every reply we emit must itself be a frame our own parser accepts.
#[test]
fn all_replies_are_well_formed() {
fn all_responses_are_well_formed() {
let snap = snapshot();
for cmd in 0x00u8..=0xFF {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, cmd, &[], &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
let f = Frame::parse(r).expect("emitted an unparseable reply");
assert_eq!(f.addr, UNIT);
for fc in 0x00u8..=0xFF {
for data in [&[][..], &[0, 0][..], &[0, 0, 0, 1][..], &[0xFF; 8][..]] {
let mut req = [0u8; MAX_FRAME];
let n = frame::encode(UNIT, fc, data, &mut req).unwrap();
let mut out = [0u8; MAX_FRAME];
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
let f = Frame::parse(r).expect("emitted an unparseable response");
assert_eq!(f.addr, UNIT);
// Either the request's own function code, or that code with
// the exception flag set. Nothing else is a legal response.
assert!(
f.fc == fc || f.fc == fc | EXCEPTION_FLAG,
"function code {fc:#04x} answered as {:#04x}",
f.fc
);
}
}
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ use wiredsensor_core::{sht3x, timing};
/// that differs between them.
pub const UNIT_ADDRESS: u8 = 0x01;
/// Board serial number reported by `READ_INFO`. Distinct from the sensor's own
/// Board serial number exposed in the identity registers. Distinct from the sensor's own
/// factory serial, which is read over I2C at start-up.
pub const DEVICE_SERIAL: u32 = 0x0000_0001;
+7 -7
View File
@@ -91,8 +91,8 @@ mod app {
taken_at: Instant,
}
/// Everything behind `READ_STATUS` and `READ_INFO` that is discovered at
/// runtime rather than fixed at build time.
/// Everything behind the diagnostic and identity registers that is
/// discovered at runtime rather than fixed at build time.
#[derive(Default)]
pub struct DeviceState {
/// Factory serial read from the sensor at start-up, 0 if unavailable.
@@ -398,7 +398,7 @@ mod app {
.unwrap_or(true);
let (info, status) = cx.shared.state.lock(|s| {
let mut flags = 0u8;
let mut flags = 0u16;
if s.sensor_ok {
flags |= flag::SENSOR_OK;
}
@@ -499,11 +499,11 @@ mod app {
// NACKs anything arriving while it is still busy with the previous one.
// Without the settle after `clear_status` the first measurement below
// failed on every boot, leaving a permanent i2c_errors=1 that a master
// polling READ_STATUS could not distinguish from a real fault.
// polling the error counters could not distinguish from a real fault.
//
// Failures here are logged but deliberately not counted: the counters
// describe operational health, and an unreadable serial already shows up
// as a zero in READ_INFO.
// as a zero in the sensor-serial registers.
let settle = board::Duration::millis(u64::from(sht3x::COMMAND_SETTLE_MS));
// Clear whatever state a warm reset left behind, so the sensor's
@@ -580,8 +580,8 @@ mod app {
}
}
// Read the status register so an unexpected sensor reset shows up in
// READ_STATUS instead of silently reverting the configuration.
// Read the status register so an unexpected sensor reset shows up on
// the bus instead of silently reverting the configuration.
//
// Failures are counted rather than discarded. Swallowing them made a
// persistently unreadable status indistinguishable from a genuinely
Binary file not shown.
+461 -150
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env python3
"""RS485 master and end-to-end test suite for the wiredsensor protocol.
"""Modbus RTU master and end-to-end test suite for the wiredsensor node.
This is deliberately an **independent** implementation of the wire format. The
framing and CRC below were written from the specification in README.md rather
than sharing code with `wiredsensor-core`. If both ends shared an
implementation, a bug in it would cancel out and every test here would pass
regardless — so the duplication is the point, not an oversight.
This is deliberately an **independent** implementation of Modbus RTU. The
framing and CRC below were written from the specification rather than sharing
code with `wiredsensor-core`. If both ends shared an implementation, a bug in it
would cancel out and every test here would pass regardless — so the duplication
is the point, not an oversight.
It is hand-written rather than built on pymodbus for one concrete reason: half
these checks inject *deliberately malformed* frames — a bad CRC, a truncated
frame, a frame for another unit — and assert the node stays silent. A conforming
client library exists precisely to make those frames unconstructable. Use
pymodbus to cross-check the happy path against a third-party stack; use this to
verify the node behaves on a bus that is misbehaving.
Talks to the bus through the USB-to-RS485 bridge firmware (`bridge/`), which is
protocol-agnostic and just moves bytes.
@@ -33,28 +40,53 @@ except ImportError:
ADDR_BROADCAST = 0x00
ADDR_MIN, ADDR_MAX = 0x01, 0xF7
MAX_PAYLOAD = 64
HEADER_LEN = 3
HEADER_LEN = 2 # ADDR, FC
CRC_LEN = 2
MIN_FRAME = HEADER_LEN + CRC_LEN
MAX_FRAME = HEADER_LEN + MAX_PAYLOAD + CRC_LEN
MAX_FRAME = 256 # the RTU limit
MAX_DATA = MAX_FRAME - HEADER_LEN - CRC_LEN
CMD_READ_MEASUREMENT = 0x01
CMD_READ_INFO = 0x02
CMD_READ_STATUS = 0x03
CMD_PING = 0x04
CMD_SET_ADDRESS = 0x10
# Each host write becomes exactly one DE-bracketed transmission by the bridge,
# and a USB full-speed bulk packet is 64 bytes, so a request must fit in one.
MAX_BRIDGE_FRAME = 64
ERROR_FLAG = 0x80
FC_READ_HOLDING = 0x03
FC_READ_INPUT = 0x04
FC_DIAGNOSTIC = 0x08
ERR_NAMES = {
0x01: "ILLEGAL_COMMAND",
0x02: "ILLEGAL_LENGTH",
0x03: "SENSOR_UNAVAILABLE",
0x04: "SENSOR_FAULT",
0x05: "UNSUPPORTED",
DIAG_RETURN_QUERY_DATA = 0x0000
EXCEPTION_FLAG = 0x80
EXCEPTION_NAMES = {
0x01: "ILLEGAL_FUNCTION",
0x02: "ILLEGAL_DATA_ADDRESS",
0x03: "ILLEGAL_DATA_VALUE",
0x04: "SERVER_DEVICE_FAILURE",
}
MAX_READ_REGISTERS = 125
# ------------------------------------------------------------ register map ---
REG_TEMP_MILLI_C = 0x0000 # 2 registers, signed
REG_RH_MILLI_PCT = 0x0002 # 2 registers, signed
REG_AGE_MS = 0x0004
REG_FLAGS = 0x0005
REG_SENSOR_STATUS = 0x0006
REG_I2C_ERRORS = 0x0007
REG_SENSOR_CRC_ERRORS = 0x0008
REG_FRAME_ERRORS = 0x0009
REG_CRC_ERRORS = 0x000A
REG_FW_MAJOR_MINOR = 0x000B
REG_FW_PATCH_PROTO = 0x000C
REG_DEVICE_SERIAL = 0x000D # 2 registers
REG_SENSOR_SERIAL = 0x000F # 2 registers
REG_UPTIME_S = 0x0011 # 2 registers
REG_COUNT = 0x0013
REG_MEASUREMENT_END = 0x0005
FLAG_NAMES = [
(1 << 0, "SENSOR_OK"),
(1 << 1, "DATA_STALE"),
@@ -65,6 +97,28 @@ FLAG_NAMES = [
(1 << 6, "HEATER_ON"),
]
REGISTER_NAMES = {
REG_TEMP_MILLI_C: "temp_milli_c (hi)",
REG_TEMP_MILLI_C + 1: "temp_milli_c (lo)",
REG_RH_MILLI_PCT: "rh_milli_pct (hi)",
REG_RH_MILLI_PCT + 1: "rh_milli_pct (lo)",
REG_AGE_MS: "age_ms",
REG_FLAGS: "flags",
REG_SENSOR_STATUS: "sensor_status",
REG_I2C_ERRORS: "i2c_errors",
REG_SENSOR_CRC_ERRORS: "sensor_crc_errors",
REG_FRAME_ERRORS: "frame_errors",
REG_CRC_ERRORS: "crc_errors",
REG_FW_MAJOR_MINOR: "fw_major_minor",
REG_FW_PATCH_PROTO: "fw_patch_proto",
REG_DEVICE_SERIAL: "device_serial (hi)",
REG_DEVICE_SERIAL + 1: "device_serial (lo)",
REG_SENSOR_SERIAL: "sensor_serial (hi)",
REG_SENSOR_SERIAL + 1: "sensor_serial (lo)",
REG_UPTIME_S: "uptime_s (hi)",
REG_UPTIME_S + 1: "uptime_s (lo)",
}
def crc16(data: bytes) -> int:
"""CRC-16/MODBUS: reflected poly 0xA001, init 0xFFFF, no final XOR."""
@@ -76,51 +130,91 @@ def crc16(data: bytes) -> int:
return crc
def build_frame(addr: int, cmd: int, payload: bytes = b"") -> bytes:
if len(payload) > MAX_PAYLOAD:
raise ValueError(f"payload of {len(payload)} exceeds {MAX_PAYLOAD}")
body = bytes([addr, cmd, len(payload)]) + payload
def build_frame(addr: int, fc: int, data: bytes = b"") -> bytes:
"""An RTU ADU: address, function code, data, CRC low byte first."""
if len(data) > MAX_DATA:
raise ValueError(f"data field of {len(data)} exceeds {MAX_DATA}")
body = bytes([addr, fc]) + data
return body + struct.pack("<H", crc16(body))
def read_request(start: int, count: int) -> bytes:
"""The data field of a register read: start and count, both big-endian."""
return struct.pack(">HH", start, count)
@dataclass
class Frame:
addr: int
cmd: int
payload: bytes
fc: int
data: bytes
@property
def is_error(self) -> bool:
return bool(self.cmd & ERROR_FLAG)
def is_exception(self) -> bool:
return bool(self.fc & EXCEPTION_FLAG)
@property
def error_name(self) -> str | None:
if not self.is_error or len(self.payload) != 1:
def exception_name(self) -> str | None:
if not self.is_exception or len(self.data) != 1:
return None
return ERR_NAMES.get(self.payload[0], f"unknown({self.payload[0]:#04x})")
return EXCEPTION_NAMES.get(self.data[0], f"unknown({self.data[0]:#04x})")
class ProtocolError(Exception):
pass
"""The bytes on the wire were not a usable response."""
class ExceptionResponse(Exception):
"""The node answered, and the answer was a Modbus exception."""
def __init__(self, fc: int, code: int):
self.fc = fc
self.code = code
name = EXCEPTION_NAMES.get(code, "unknown")
super().__init__(f"function {fc:#04x} -> {name} ({code:#04x})")
def parse_frame(raw: bytes) -> Frame:
"""Validate a received ADU.
There is no length field to check: in RTU the frame is delimited by the idle
line and the CRC is the only integrity check there is.
"""
if len(raw) < MIN_FRAME:
raise ProtocolError(f"short frame: {len(raw)} bytes ({raw.hex(' ')})")
if len(raw) > MAX_FRAME:
raise ProtocolError(f"long frame: {len(raw)} bytes")
declared = raw[2]
if HEADER_LEN + declared + CRC_LEN != len(raw):
raise ProtocolError(
f"LEN field {declared} disagrees with {len(raw)} received bytes "
f"({raw.hex(' ')})"
)
body, tail = raw[:-CRC_LEN], raw[-CRC_LEN:]
expected = struct.unpack("<H", tail)[0]
actual = crc16(body)
if actual != expected:
raise ProtocolError(f"CRC {actual:#06x} != {expected:#06x} ({raw.hex(' ')})")
return Frame(raw[0], raw[1], raw[HEADER_LEN : HEADER_LEN + declared])
return Frame(raw[0], raw[1], body[HEADER_LEN:])
def decode_registers(data: bytes) -> list[int]:
"""Unpack the byte-counted register block of an 0x03/0x04 response."""
if not data:
raise ProtocolError("register response has an empty data field")
byte_count = data[0]
if byte_count != len(data) - 1:
raise ProtocolError(
f"byte count {byte_count} disagrees with {len(data) - 1} bytes of payload"
)
if byte_count % 2:
raise ProtocolError(f"odd byte count {byte_count}")
return list(struct.unpack(f">{byte_count // 2}H", data[1:]))
def reg_u32(regs: list[int], at: int, base: int = 0) -> int:
"""A 32-bit register pair, high word first."""
i = at - base
return (regs[i] << 16) | regs[i + 1]
def reg_i32(regs: list[int], at: int, base: int = 0) -> int:
v = reg_u32(regs, at, base)
return v - (1 << 32) if v & (1 << 31) else v
# ------------------------------------------------------------------ decode ---
@@ -133,11 +227,14 @@ class Measurement:
age_ms: int
@classmethod
def decode(cls, p: bytes) -> Measurement:
if len(p) != 10:
raise ProtocolError(f"measurement payload is {len(p)} bytes, expected 10")
t, rh, age = struct.unpack("<iiH", p)
return cls(t / 1000.0, rh / 1000.0, age)
def decode(cls, regs: list[int], base: int = REG_TEMP_MILLI_C) -> Measurement:
if len(regs) < 5:
raise ProtocolError(f"expected 5 measurement registers, got {len(regs)}")
return cls(
reg_i32(regs, REG_TEMP_MILLI_C, base) / 1000.0,
reg_i32(regs, REG_RH_MILLI_PCT, base) / 1000.0,
regs[REG_AGE_MS - base],
)
def __str__(self) -> str:
return f"{self.temp_c:+.3f} °C {self.rh_pct:.3f} %RH age {self.age_ms} ms"
@@ -152,11 +249,18 @@ class Info:
uptime_s: int
@classmethod
def decode(cls, p: bytes) -> Info:
if len(p) != 16:
raise ProtocolError(f"info payload is {len(p)} bytes, expected 16")
maj, mnr, pat, proto, dev, sens, up = struct.unpack("<BBBBIII", p)
return cls((maj, mnr, pat), proto, dev, sens, up)
def decode(cls, regs: list[int], base: int = REG_FW_MAJOR_MINOR) -> Info:
if len(regs) < REG_COUNT - REG_FW_MAJOR_MINOR:
raise ProtocolError(f"expected 8 info registers, got {len(regs)}")
major_minor = regs[REG_FW_MAJOR_MINOR - base]
patch_proto = regs[REG_FW_PATCH_PROTO - base]
return cls(
(major_minor >> 8, major_minor & 0xFF, patch_proto >> 8),
patch_proto & 0xFF,
reg_u32(regs, REG_DEVICE_SERIAL, base),
reg_u32(regs, REG_SENSOR_SERIAL, base),
reg_u32(regs, REG_UPTIME_S, base),
)
def __str__(self) -> str:
return (
@@ -176,10 +280,17 @@ class Status:
crc_errors: int
@classmethod
def decode(cls, p: bytes) -> Status:
if len(p) != 11:
raise ProtocolError(f"status payload is {len(p)} bytes, expected 11")
return cls(*struct.unpack("<BHHHHH", p))
def decode(cls, regs: list[int], base: int = REG_FLAGS) -> Status:
if len(regs) < REG_FW_MAJOR_MINOR - REG_FLAGS:
raise ProtocolError(f"expected 6 status registers, got {len(regs)}")
return cls(
regs[REG_FLAGS - base],
regs[REG_SENSOR_STATUS - base],
regs[REG_I2C_ERRORS - base],
regs[REG_SENSOR_CRC_ERRORS - base],
regs[REG_FRAME_ERRORS - base],
regs[REG_CRC_ERRORS - base],
)
@property
def flag_names(self) -> list[str]:
@@ -187,7 +298,7 @@ class Status:
def __str__(self) -> str:
return (
f"flags {self.flags:#04x} [{' '.join(self.flag_names) or '-'}] "
f"flags {self.flags:#06x} [{' '.join(self.flag_names) or '-'}] "
f"sht_status {self.sensor_status:#06x} "
f"err i2c={self.i2c_errors} sht_crc={self.sensor_crc_errors} "
f"frame={self.frame_errors} bus_crc={self.crc_errors}"
@@ -198,7 +309,7 @@ class Status:
class Bus:
"""A master on the segment, reached through the USB-RS485 bridge."""
"""A Modbus master on the segment, reached through the USB-RS485 bridge."""
def __init__(self, port: str, unit: int = 0x01, verbose: bool = False):
# The bridge is a CDC device, so the baud rate here is ignored; the RS485
@@ -225,9 +336,9 @@ class Bus:
self.ser.flush()
def read_raw(self, timeout: float = 0.5, gap: float = 0.02) -> bytes:
"""Collect a reply, using an idle gap to decide it is complete.
"""Collect a response, using an idle gap to decide it is complete.
Mirrors how the node itself delimits frames, so a reply that arrives in
Mirrors how the node itself delimits frames, so a response arriving in
several USB chunks is still reassembled into one frame.
"""
deadline = time.monotonic() + timeout
@@ -246,40 +357,64 @@ class Bus:
print(f" RX {bytes(buf).hex(' ')}", file=sys.stderr)
return bytes(buf)
def request(self, cmd: int, payload: bytes = b"", addr: int | None = None) -> Frame:
"""Send a command and return the parsed reply, raising if none arrives."""
raw = self.expect_raw(cmd, payload, addr)
if not raw:
raise ProtocolError(f"no reply to command {cmd:#04x}")
return parse_frame(raw)
def expect_raw(
self, cmd: int, payload: bytes = b"", addr: int | None = None
) -> bytes:
self.send_raw(build_frame(self.unit if addr is None else addr, cmd, payload))
def expect_raw(self, fc: int, data: bytes = b"", addr: int | None = None) -> bytes:
"""Send a request and return whatever bytes come back, if any."""
self.send_raw(build_frame(self.unit if addr is None else addr, fc, data))
return self.read_raw()
def measurement(self) -> Measurement:
return Measurement.decode(self._ok(CMD_READ_MEASUREMENT).payload)
def request(self, fc: int, data: bytes = b"", addr: int | None = None) -> Frame:
"""Send a request and return the parsed response, raising if none comes."""
raw = self.expect_raw(fc, data, addr)
if not raw:
raise ProtocolError(f"no response to function {fc:#04x}")
return parse_frame(raw)
def info(self) -> Info:
return Info.decode(self._ok(CMD_READ_INFO).payload)
def transact(self, fc: int, data: bytes = b"") -> Frame:
"""As `request`, but turn an exception response into a Python exception."""
response = self.request(fc, data)
if response.addr != self.unit:
raise ProtocolError(
f"response from {response.addr:#04x} != {self.unit:#04x}"
)
if response.is_exception:
if response.fc != fc | EXCEPTION_FLAG:
raise ProtocolError(
f"exception on function {response.fc & ~EXCEPTION_FLAG:#04x}, "
f"asked {fc:#04x}"
)
raise ExceptionResponse(fc, response.data[0])
if response.fc != fc:
raise ProtocolError(f"response fc {response.fc:#04x} != request {fc:#04x}")
return response
def status(self) -> Status:
return Status.decode(self._ok(CMD_READ_STATUS).payload)
def read_registers(
self, start: int, count: int, fc: int = FC_READ_INPUT
) -> list[int]:
regs = decode_registers(self.transact(fc, read_request(start, count)).data)
if len(regs) != count:
raise ProtocolError(f"asked for {count} registers, got {len(regs)}")
return regs
def ping(self, payload: bytes) -> bytes:
return self._ok(CMD_PING, payload).payload
def diagnostic(self, payload: bytes) -> bytes:
"""Return Query Data: the node echoes the data field back unchanged."""
data = struct.pack(">H", DIAG_RETURN_QUERY_DATA) + payload
return self.transact(FC_DIAGNOSTIC, data).data[2:]
def _ok(self, cmd: int, payload: bytes = b"") -> Frame:
reply = self.request(cmd, payload)
if reply.is_error:
raise ProtocolError(f"command {cmd:#04x} failed: {reply.error_name}")
if reply.cmd != cmd:
raise ProtocolError(f"reply cmd {reply.cmd:#04x} != request {cmd:#04x}")
if reply.addr != self.unit:
raise ProtocolError(f"reply from {reply.addr:#04x} != {self.unit:#04x}")
return reply
def measurement(self, fc: int = FC_READ_INPUT) -> Measurement:
return Measurement.decode(
self.read_registers(REG_TEMP_MILLI_C, REG_MEASUREMENT_END, fc)
)
def status(self, fc: int = FC_READ_INPUT) -> Status:
count = REG_FW_MAJOR_MINOR - REG_FLAGS
return Status.decode(self.read_registers(REG_FLAGS, count, fc))
def info(self, fc: int = FC_READ_INPUT) -> Info:
count = REG_COUNT - REG_FW_MAJOR_MINOR
return Info.decode(self.read_registers(REG_FW_MAJOR_MINOR, count, fc))
def all_registers(self, fc: int = FC_READ_INPUT) -> list[int]:
return self.read_registers(0, REG_COUNT, fc)
# ------------------------------------------------------------------- tests ---
@@ -300,14 +435,25 @@ class Results:
print(f" \033[31mFAIL\033[0m {name}\n {exc}")
def expect_exception(bus: Bus, fc: int, data: bytes, want: int) -> str:
"""Assert a request draws a specific Modbus exception code."""
try:
bus.transact(fc, data)
except ExceptionResponse as exc:
want_name = EXCEPTION_NAMES[want]
assert exc.code == want, f"got {exc}, expected {want_name}"
return want_name
raise AssertionError(f"function {fc:#04x} succeeded, expected {EXCEPTION_NAMES[want]}")
def run_tests(bus: Bus) -> int:
"""Exercise the parts of the protocol only a real bus can verify.
Framing and CRC arithmetic are already covered by host unit tests in
`core/`. What cannot be tested off-hardware is everything timing-dependent:
that the node delimits frames by an idle gap, that it stays off the wire for
traffic it must not answer, and that its driver-enable turnaround leaves the
reply intact.
Framing, CRC arithmetic and the register map are already covered by host
unit tests in `core/`. What cannot be tested off-hardware is everything
timing-dependent: that the node delimits frames by an idle gap, that it
stays off the wire for traffic it must not answer, and that its
driver-enable turnaround leaves the response intact.
"""
r = Results()
@@ -315,11 +461,11 @@ def run_tests(bus: Bus) -> int:
def t_info():
info = bus.info()
assert info.proto_version == 1, f"protocol version {info.proto_version} != 1"
assert info.proto_version == 2, f"protocol version {info.proto_version} != 2"
assert info.sensor_serial != 0, "sensor serial is zero — SHT31 not identified"
return str(info)
r.check("READ_INFO", t_info)
r.check("read identity registers", t_info)
def t_measure():
m = bus.measurement()
@@ -328,7 +474,7 @@ def run_tests(bus: Bus) -> int:
assert m.age_ms <= 3000, f"reading is {m.age_ms} ms old"
return str(m)
r.check("READ_MEASUREMENT", t_measure)
r.check("read measurement registers", t_measure)
def t_status():
s = bus.status()
@@ -336,35 +482,80 @@ def run_tests(bus: Bus) -> int:
assert not s.flags & 0x04, "SENSOR_FAULT is set"
return str(s)
r.check("READ_STATUS", t_status)
r.check("read diagnostic registers", t_status)
print("\nregister map consistency")
def t_both_function_codes():
# A master that only implements 0x03 must see exactly what one using
# 0x04 sees. The two are served from one table precisely so they agree.
holding = bus.all_registers(FC_READ_HOLDING)
# Skip the volatile registers: age and uptime move between the two reads.
volatile = {REG_AGE_MS, REG_UPTIME_S, REG_UPTIME_S + 1}
inputs = bus.all_registers(FC_READ_INPUT)
for i, (h, n) in enumerate(zip(holding, inputs)):
if i in volatile:
continue
assert h == n, f"register {i} ({REGISTER_NAMES.get(i, '?')}): {h} != {n}"
return f"0x03 and 0x04 agree across {len(holding)} registers"
r.check("holding and input registers agree", t_both_function_codes)
def t_subrange():
# ESPHome coalesces adjacent registers into one read, so a sub-range must
# return the same values as the same addresses read individually.
whole = bus.all_registers()
for start, count in [
(REG_FLAGS, 1),
(REG_SENSOR_STATUS, 4),
(REG_DEVICE_SERIAL, 2),
(REG_FLAGS, REG_COUNT - REG_FLAGS),
]:
part = bus.read_registers(start, count)
expect = whole[start : start + count]
assert part == expect, f"start {start} count {count}: {part} != {expect}"
return "4 sub-ranges match the whole-map read"
r.check("sub-range reads are consistent", t_subrange)
print("\nround trip integrity")
def t_ping_binary():
def t_diag_binary():
# Bytes that would need escaping in any delimiter-based protocol.
probe = bytes([0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E, 0x55, 0xAA])
echo = bus.ping(probe)
echo = bus.diagnostic(probe)
assert echo == probe, f"echo {echo.hex(' ')} != sent {probe.hex(' ')}"
return f"{len(probe)} bytes binary-transparent"
r.check("PING echoes arbitrary bytes", t_ping_binary)
r.check("diagnostic echoes arbitrary bytes", t_diag_binary)
def t_ping_long():
# 59 payload bytes -> a 64-byte frame, the largest the bridge can send as
# a single USB packet and therefore as a single transmission.
probe = bytes(range(59))
echo = bus.ping(probe)
assert echo == probe, f"echo of {len(echo)} bytes != sent {len(probe)}"
return "59-byte payload, 64-byte frame"
def t_diag_long():
# A 64-byte frame: the largest the bridge can send as a single USB packet
# and therefore as a single transmission. Well past the 16-byte RX FIFO
# watermark, which is the case the frame-gap logic gets wrong if the
# deadline is measured from the ISR's timestamp.
payload = bytes(range(MAX_BRIDGE_FRAME - MIN_FRAME - 2))
echo = bus.diagnostic(payload)
assert echo == payload, f"echo of {len(echo)} bytes != sent {len(payload)}"
return f"{len(payload)}-byte payload, {MAX_BRIDGE_FRAME}-byte frame"
r.check("PING echoes a maximum single-packet frame", t_ping_long)
r.check("diagnostic echoes a maximum single-packet frame", t_diag_long)
def t_long_response():
# The response side of the same concern: 19 registers is a 43-byte
# response, comfortably past the watermark.
regs = bus.all_registers()
assert len(regs) == REG_COUNT, f"got {len(regs)} registers"
return f"{REG_COUNT} registers in one {5 + 2 * REG_COUNT}-byte response"
r.check("whole-map read returns a long response intact", t_long_response)
def t_back_to_back():
# Catches state leaking between frames — a stale receive buffer or a
# gap-detection flag left set would show up here and nowhere else.
for i in range(20):
probe = bytes([i, 0xA5 ^ i])
echo = bus.ping(probe)
echo = bus.diagnostic(probe)
assert echo == probe, f"iteration {i}: {echo.hex(' ')} != {probe.hex(' ')}"
return "20 consecutive requests, no state leak"
@@ -374,21 +565,21 @@ def run_tests(bus: Bus) -> int:
def t_other_unit():
other = 0x02 if bus.unit != 0x02 else 0x03
raw = bus.expect_raw(CMD_READ_MEASUREMENT, addr=other)
raw = bus.expect_raw(FC_READ_INPUT, read_request(0, 5), addr=other)
assert not raw, f"answered a frame addressed to {other:#04x}: {raw.hex(' ')}"
return f"ignored a frame for unit {other:#04x}"
r.check("frame for another unit is ignored", t_other_unit)
def t_broadcast():
raw = bus.expect_raw(CMD_READ_MEASUREMENT, addr=ADDR_BROADCAST)
raw = bus.expect_raw(FC_READ_INPUT, read_request(0, 5), addr=ADDR_BROADCAST)
assert not raw, f"answered a broadcast: {raw.hex(' ')}"
return "ignored the broadcast address"
r.check("broadcast is not answered", t_broadcast)
def t_bad_crc():
frame = bytearray(build_frame(bus.unit, CMD_READ_MEASUREMENT))
frame = bytearray(build_frame(bus.unit, FC_READ_INPUT, read_request(0, 5)))
frame[-1] ^= 0xFF
bus.send_raw(bytes(frame))
raw = bus.read_raw()
@@ -398,7 +589,9 @@ def run_tests(bus: Bus) -> int:
r.check("bad CRC is ignored", t_bad_crc)
def t_truncated():
frame = build_frame(bus.unit, CMD_READ_MEASUREMENT)[:-1]
# With no length field, a frame one byte short can only be caught by the
# CRC — which is exactly what must happen here.
frame = build_frame(bus.unit, FC_READ_INPUT, read_request(0, 5))[:-1]
bus.send_raw(frame)
raw = bus.read_raw()
assert not raw, f"answered a truncated frame: {raw.hex(' ')}"
@@ -406,52 +599,58 @@ def run_tests(bus: Bus) -> int:
r.check("truncated frame is ignored", t_truncated)
def t_length_lie():
frame = bytearray(build_frame(bus.unit, CMD_PING, b"ab"))
frame[2] = 5 # claim more payload than is present
bus.send_raw(bytes(frame))
raw = bus.read_raw()
assert not raw, f"answered a frame with a bad LEN: {raw.hex(' ')}"
return "ignored a frame whose LEN lies"
print("\nexception responses")
r.check("inconsistent LEN is ignored", t_length_lie)
def t_unknown_fc():
# 0x06 Write Single Register is a perfectly ordinary Modbus function this
# node does not implement, which is the interesting case: a master may
# well try it.
return expect_exception(bus, 0x06, b"\x00\x00\x00\x01", 0x01)
print("\nerror replies")
r.check("unimplemented function is refused", t_unknown_fc)
def t_unknown_cmd():
reply = bus.request(0x7E)
assert reply.cmd == 0x7E | ERROR_FLAG, f"cmd {reply.cmd:#04x}"
assert reply.error_name == "ILLEGAL_COMMAND", reply.error_name
return "0x7E -> ILLEGAL_COMMAND"
def t_past_end():
return expect_exception(bus, FC_READ_INPUT, read_request(REG_COUNT, 1), 0x02)
r.check("unknown command is refused", t_unknown_cmd)
r.check("read past the map is ILLEGAL_DATA_ADDRESS", t_past_end)
def t_bad_length():
reply = bus.request(CMD_READ_MEASUREMENT, b"\xaa")
assert reply.error_name == "ILLEGAL_LENGTH", reply.error_name
return "payload on READ_MEASUREMENT -> ILLEGAL_LENGTH"
def t_straddles_end():
data = read_request(REG_COUNT - 1, 2)
return expect_exception(bus, FC_READ_INPUT, data, 0x02)
r.check("unexpected payload is refused", t_bad_length)
r.check("read straddling the end is refused", t_straddles_end)
def t_set_address():
reply = bus.request(CMD_SET_ADDRESS, b"\x22")
assert reply.error_name == "UNSUPPORTED", reply.error_name
return "SET_ADDRESS -> UNSUPPORTED, not silently ignored"
def t_zero_count():
return expect_exception(bus, FC_READ_INPUT, read_request(0, 0), 0x03)
r.check("reserved command reports UNSUPPORTED", t_set_address)
r.check("zero register count is ILLEGAL_DATA_VALUE", t_zero_count)
def t_huge_count():
data = read_request(0, MAX_READ_REGISTERS + 1)
return expect_exception(bus, FC_READ_INPUT, data, 0x03)
r.check("oversized register count is ILLEGAL_DATA_VALUE", t_huge_count)
def t_short_request():
return expect_exception(bus, FC_READ_INPUT, b"\x00\x00", 0x03)
r.check("misshaped request is ILLEGAL_DATA_VALUE", t_short_request)
def t_unknown_subfunction():
return expect_exception(bus, FC_DIAGNOSTIC, b"\x00\x0a", 0x01)
r.check("unknown diagnostic sub-function is refused", t_unknown_subfunction)
print("\ndiagnostic counters")
def t_crc_counter():
before = bus.status().crc_errors
bad = bytearray(build_frame(bus.unit, CMD_READ_MEASUREMENT))
bad = bytearray(build_frame(bus.unit, FC_READ_INPUT, read_request(0, 5)))
bad[-1] ^= 0xFF
bus.send_raw(bytes(bad))
bus.read_raw(timeout=0.15)
after = bus.status().crc_errors
assert after == before + 1, (
f"crc_errors went {before} -> {after}, expected +1"
)
assert after == before + 1, f"crc_errors went {before} -> {after}, expected +1"
return f"crc_errors {before} -> {after}"
r.check("a corrupt frame increments crc_errors", t_crc_counter)
@@ -466,25 +665,126 @@ def run_tests(bus: Bus) -> int:
return 0
# ----------------------------------------------------------------- esphome ---
ESPHOME_YAML = """\
# wiredsensor node at unit address {unit} — paste into your ESPHome config and
# set the tx_pin/rx_pin to whatever your RS485 transceiver is wired to.
uart:
id: rs485
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: {baud}
data_bits: 8
parity: NONE
stop_bits: 1
modbus:
id: rs485_bus
uart_id: rs485
modbus_controller:
- id: wiredsensor
address: {unit}
modbus_id: rs485_bus
update_interval: 30s
sensor:
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Temperature"
register_type: read # function code 0x04
address: 0x0000
value_type: S_DWORD
unit_of_measurement: "°C"
device_class: temperature
accuracy_decimals: 2
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Humidity"
register_type: read
address: 0x0002
value_type: S_DWORD
unit_of_measurement: "%"
device_class: humidity
accuracy_decimals: 2
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Reading age"
register_type: read
address: 0x0004
value_type: U_WORD
unit_of_measurement: "ms"
entity_category: diagnostic
# The health flags deliberately use `holding` (function code 0x03) even though
# they are the same registers. ESPHome coalesces adjacent registers of the same
# register_type into one command, and a read overlapping 0x0000..0x0004 is
# refused outright when the sensor has never produced a reading. Asking for the
# flags under the other function code puts them in their own command, so they
# still report when the measurement cannot — which is exactly when you want them.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Sensor OK"
register_type: holding
address: 0x0005
bitmask: 0x01
entity_category: diagnostic
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Data stale"
register_type: holding
address: 0x0005
bitmask: 0x02
entity_category: diagnostic
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Sensor fault"
register_type: holding
address: 0x0005
bitmask: 0x04
entity_category: diagnostic
"""
# --------------------------------------------------------------------- CLI ---
def main() -> int:
def main() -> int | str:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", default="/dev/ttyACM0", help="bridge serial port")
ap.add_argument(
"--unit", type=lambda s: int(s, 0), default=0x01, help="node address"
)
ap.add_argument("-v", "--verbose", action="store_true", help="dump bus traffic")
ap.add_argument(
"--holding",
action="store_const",
const=FC_READ_HOLDING,
default=FC_READ_INPUT,
dest="fc",
help="use 0x03 Read Holding Registers instead of 0x04 Read Input Registers",
)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("measure", help="read temperature and humidity once")
sub.add_parser("info", help="read firmware and serial numbers")
sub.add_parser("status", help="read health flags and counters")
sub.add_parser("registers", help="dump the whole register map")
sub.add_parser("test", help="run the end-to-end test suite")
sub.add_parser("esphome", help="print a ready-to-paste ESPHome config")
p_ping = sub.add_parser("ping", help="echo test")
p_ping.add_argument("--bytes", type=int, default=8, help="payload length")
p_diag = sub.add_parser("diag", help="Return Query Data echo test")
p_diag.add_argument("--bytes", type=int, default=8, help="payload length")
p_mon = sub.add_parser("monitor", help="poll continuously")
p_mon.add_argument(
@@ -493,6 +793,11 @@ def main() -> int:
args = ap.parse_args()
# Needs no bus at all, so it works before any hardware is wired up.
if args.cmd == "esphome":
print(ESPHOME_YAML.format(unit=args.unit, baud=19200))
return 0
try:
bus = Bus(args.port, args.unit, args.verbose)
except serial.SerialException as exc:
@@ -500,26 +805,32 @@ def main() -> int:
try:
if args.cmd == "measure":
print(bus.measurement())
print(bus.measurement(args.fc))
elif args.cmd == "info":
print(bus.info())
print(bus.info(args.fc))
elif args.cmd == "status":
print(bus.status())
elif args.cmd == "ping":
print(bus.status(args.fc))
elif args.cmd == "registers":
for i, v in enumerate(bus.all_registers(args.fc)):
name = REGISTER_NAMES.get(i, "")
print(f" {i:#06x} {v:#06x} {v:>6} {name}")
elif args.cmd == "diag":
probe = bytes(i & 0xFF for i in range(args.bytes))
echo = bus.ping(probe)
echo = bus.diagnostic(probe)
print("echo ok" if echo == probe else f"MISMATCH: {echo.hex(' ')}")
elif args.cmd == "monitor":
while True:
try:
print(f"{time.strftime('%H:%M:%S')} {bus.measurement()}")
except ProtocolError as exc:
print(f"{time.strftime('%H:%M:%S')} {bus.measurement(args.fc)}")
except (ProtocolError, ExceptionResponse) as exc:
print(f"{time.strftime('%H:%M:%S')} {exc}")
time.sleep(args.interval)
elif args.cmd == "test":
return run_tests(bus)
except KeyboardInterrupt:
return 130
except ExceptionResponse as exc:
return f"the node refused the request: {exc}"
except ProtocolError as exc:
return f"protocol error: {exc}"
finally: