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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user