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
+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