Compare commits
2 Commits
f2d06278af
...
c49fff9faf
| Author | SHA1 | Date | |
|---|---|---|---|
| c49fff9faf | |||
| 5b9c09df3b |
@@ -1 +1,2 @@
|
||||
/target
|
||||
__pycache__/
|
||||
|
||||
@@ -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,11 @@ 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. |
|
||||
| `examples/`| ESPHome configurations, one node and a whole segment. |
|
||||
|
||||
The `core`/`firmware` split exists so the entire protocol can be exercised by
|
||||
ordinary host unit tests (`cargo test`) with no hardware and no emulator. `core`
|
||||
@@ -102,77 +104,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 +184,148 @@ 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. Complete, flashable configs live in `examples/esphome/`:
|
||||
|
||||
| File | What it is |
|
||||
|------|------------|
|
||||
| `wiredsensor.yaml` | one node, with every measurement and diagnostic exposed. Start here. |
|
||||
| `wiredsensor-node.yaml` | one node as a reusable package, parameterised by address |
|
||||
| `bus-of-nodes.yaml` | three nodes on one segment, via that package |
|
||||
|
||||
All three validate against ESPHome 2026.7. `tools/wiredsensor.py esphome` prints
|
||||
the same thing for an arbitrary unit address if you would rather generate it. The
|
||||
essentials are:
|
||||
|
||||
```yaml
|
||||
uart:
|
||||
id: rs485
|
||||
tx_pin: GPIO17 # whatever your transceiver is wired to
|
||||
rx_pin: GPIO16
|
||||
baud_rate: 19200 # must match core/src/timing.rs
|
||||
data_bits: 8
|
||||
parity: NONE
|
||||
stop_bits: 1
|
||||
|
||||
modbus:
|
||||
id: rs485_bus
|
||||
uart_id: rs485
|
||||
|
||||
modbus_controller:
|
||||
- id: wiredsensor
|
||||
address: 0x01 # must match board.rs UNIT_ADDRESS
|
||||
modbus_id: rs485_bus
|
||||
update_interval: 30s
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Temperature"
|
||||
register_type: read # ESPHome's name for function code 0x04
|
||||
address: 0x0000
|
||||
value_type: S_DWORD
|
||||
unit_of_measurement: "°C"
|
||||
device_class: temperature
|
||||
filters:
|
||||
- multiply: 0.001 # the node reports milli-degrees
|
||||
```
|
||||
|
||||
Three things must agree between the two ends, and all three fail silently or
|
||||
confusingly if they do not:
|
||||
|
||||
- **Baud rate.** `19200` here against `BAUD_RATE` in `core/src/timing.rs`. A
|
||||
mismatch presents as random CRC failures.
|
||||
- **Unit address.** `address:` here against `UNIT_ADDRESS` in
|
||||
`firmware/src/board.rs`. A mismatch presents as a total silence that looks
|
||||
identical to a wiring fault.
|
||||
- **Value type.** `S_DWORD` with `multiply: 0.001`, not `S_WORD`. Reading half of
|
||||
a 32-bit pair yields a plausible-looking but wrong number rather than an error.
|
||||
|
||||
ESPHome coalesces adjacent registers of the same `register_type` into a single
|
||||
command, which the map is laid out for: temperature, humidity and age are
|
||||
contiguous, so all three arrive in one transaction.
|
||||
|
||||
That coalescing has one consequence worth planning around. A read overlapping
|
||||
`0x0000`..`0x0004` is refused with `SERVER_DEVICE_FAILURE` when the sensor has
|
||||
never produced a reading, so any entity ESPHome merges into that command goes
|
||||
unavailable with it — including, if you are not careful, the very flags that would
|
||||
tell you why. The fix is to ask for the diagnostic registers under the *other*
|
||||
function code: they are the same table, but a different `register_type` puts them
|
||||
in their own command.
|
||||
|
||||
```yaml
|
||||
binary_sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Sensor OK"
|
||||
register_type: holding # 0x03, so this is a separate command from the
|
||||
address: 0x0005 # 0x04 read that carries the measurement
|
||||
bitmask: 0x01
|
||||
entity_category: diagnostic
|
||||
```
|
||||
|
||||
This is what serving both function codes from one table buys, beyond mere
|
||||
compatibility. Both `examples/esphome/wiredsensor.yaml` and the generated config
|
||||
are already set up this way.
|
||||
|
||||
One more wiring detail the YAML has to get right: **`flow_control_pin` on the
|
||||
`modbus:` component**, unless your transceiver switches direction itself. Without
|
||||
it the ESP32 never asserts DE and nothing you send reaches the segment, which
|
||||
looks exactly like a dead node.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -260,14 +356,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 +379,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 +437,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 +484,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 +513,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 +572,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 +617,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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Several wiredsensor nodes on one RS485 segment, read by a single ESP32.
|
||||
#
|
||||
# The point of a bus: one gateway, one pair of wires, N sensors. Each node needs
|
||||
# a distinct UNIT_ADDRESS, which means one firmware image per node — see the
|
||||
# Configuration section of the top-level README.
|
||||
#
|
||||
# esphome run bus-of-nodes.yaml
|
||||
#
|
||||
# The per-node entities live in wiredsensor-node.yaml and are instantiated once
|
||||
# per address below, so adding a node is three lines rather than a copied block.
|
||||
|
||||
substitutions:
|
||||
name: rs485-gateway
|
||||
friendly_name: "RS485 gateway"
|
||||
baud_rate: "19200"
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
de_pin: GPIO4
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: INFO
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_encryption_key
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: !secret ota_password
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
ap: {}
|
||||
|
||||
captive_portal:
|
||||
|
||||
# ---------------------------------------------------------------- the bus ---
|
||||
|
||||
uart:
|
||||
id: rs485
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: ${baud_rate}
|
||||
data_bits: 8
|
||||
parity: NONE
|
||||
stop_bits: 1
|
||||
|
||||
modbus:
|
||||
id: rs485_bus
|
||||
uart_id: rs485
|
||||
flow_control_pin: ${de_pin}
|
||||
|
||||
# ----------------------------------------------------------------- nodes ---
|
||||
#
|
||||
# One entry per node. `node_id` must be unique; `node_addr` must match that
|
||||
# node's UNIT_ADDRESS.
|
||||
#
|
||||
# Requests are serialised across the whole bus — ESPHome waits for each response
|
||||
# before sending the next — so poll cost scales with node count. A node answers
|
||||
# roughly 2 ms after the request ends, so a dozen nodes at 30 s is nowhere near
|
||||
# saturating the segment. If you do see timeouts, `command_throttle` on the
|
||||
# controllers is the knob, not `update_interval`.
|
||||
|
||||
packages:
|
||||
hallway: !include
|
||||
file: wiredsensor-node.yaml
|
||||
vars:
|
||||
node_id: node_hallway
|
||||
node_addr: "0x01"
|
||||
node_name: "Hallway"
|
||||
|
||||
cellar: !include
|
||||
file: wiredsensor-node.yaml
|
||||
vars:
|
||||
node_id: node_cellar
|
||||
node_addr: "0x02"
|
||||
node_name: "Cellar"
|
||||
|
||||
greenhouse: !include
|
||||
file: wiredsensor-node.yaml
|
||||
vars:
|
||||
node_id: node_greenhouse
|
||||
node_addr: "0x03"
|
||||
node_name: "Greenhouse"
|
||||
@@ -0,0 +1,61 @@
|
||||
# One wiredsensor node, as a reusable ESPHome package.
|
||||
#
|
||||
# Not flashable on its own — it defines no board, no wifi and no bus. It expects
|
||||
# a parent config to supply a `modbus:` component with id `rs485_bus`, and to
|
||||
# include this file once per node with `vars`. See bus-of-nodes.yaml.
|
||||
#
|
||||
# Required vars:
|
||||
# node_id — a unique slug, used for the modbus_controller id
|
||||
# node_addr — the node's RS485 unit address, matching its UNIT_ADDRESS
|
||||
# node_name — human-readable prefix for the entity names
|
||||
#
|
||||
# Only the two measurement entities are exposed here. Per-node copies of every
|
||||
# diagnostic counter multiply fast on a segment of a dozen nodes, so
|
||||
# wiredsensor.yaml keeps the full set for the single-node case and this package
|
||||
# stays deliberately lean. Add what you need — the register map is identical.
|
||||
|
||||
modbus_controller:
|
||||
- id: ${node_id}
|
||||
address: ${node_addr}
|
||||
modbus_id: rs485_bus
|
||||
update_interval: 30s
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ${node_id}
|
||||
name: "${node_name} temperature"
|
||||
register_type: read
|
||||
address: 0x0000
|
||||
value_type: S_DWORD
|
||||
unit_of_measurement: "°C"
|
||||
device_class: temperature
|
||||
state_class: measurement
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
- multiply: 0.001
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ${node_id}
|
||||
name: "${node_name} humidity"
|
||||
register_type: read
|
||||
address: 0x0002
|
||||
value_type: S_DWORD
|
||||
unit_of_measurement: "%"
|
||||
device_class: humidity
|
||||
state_class: measurement
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
- multiply: 0.001
|
||||
|
||||
binary_sensor:
|
||||
# On `holding` (0x03) rather than `read` (0x04) so a node whose sensor has
|
||||
# never produced a reading still reports *why* — see wiredsensor.yaml for the
|
||||
# full explanation.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ${node_id}
|
||||
name: "${node_name} sensor fault"
|
||||
register_type: holding
|
||||
address: 0x0005
|
||||
bitmask: 0x04
|
||||
device_class: problem
|
||||
entity_category: diagnostic
|
||||
@@ -0,0 +1,294 @@
|
||||
# A complete ESPHome configuration for one wiredsensor node on an RS485
|
||||
# segment. Flashable as-is once the substitutions below and your secrets are
|
||||
# filled in:
|
||||
#
|
||||
# esphome run wiredsensor.yaml
|
||||
#
|
||||
# Nothing here is wiredsensor-specific beyond the register addresses — the node
|
||||
# is an ordinary Modbus RTU server, so `modbus_controller` reads it with no
|
||||
# custom component and no external library.
|
||||
#
|
||||
# For several nodes on one bus, see bus-of-nodes.yaml.
|
||||
|
||||
substitutions:
|
||||
name: wiredsensor-gateway
|
||||
friendly_name: "Wiredsensor gateway"
|
||||
|
||||
# Must match UNIT_ADDRESS in firmware/src/board.rs. A mismatch presents as
|
||||
# total silence, indistinguishable from a wiring fault.
|
||||
node_address: "0x01"
|
||||
|
||||
# Must match BAUD_RATE in core/src/timing.rs. A mismatch is silent and shows
|
||||
# up as random CRC failures rather than as an error.
|
||||
baud_rate: "19200"
|
||||
|
||||
# RS485 transceiver wiring. GPIO16/17 are UART2's defaults on the ESP32, but
|
||||
# they are wired to PSRAM on WROVER modules — pick different pins there.
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
# Driver enable, wired to DE and /RE together. Delete `flow_control_pin`
|
||||
# below if your transceiver handles direction itself (MAX13487 and most
|
||||
# "automatic" breakout modules).
|
||||
de_pin: GPIO4
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
# The RS485 port is UART2, so the log can stay on UART0. Set `baud_rate: 0`
|
||||
# if you ever move RS485 onto UART0.
|
||||
level: INFO
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_encryption_key
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: !secret ota_password
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
ap: {}
|
||||
|
||||
captive_portal:
|
||||
|
||||
# ---------------------------------------------------------------- the bus ---
|
||||
|
||||
uart:
|
||||
id: rs485
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: ${baud_rate}
|
||||
data_bits: 8
|
||||
parity: NONE
|
||||
stop_bits: 1
|
||||
|
||||
modbus:
|
||||
id: rs485_bus
|
||||
uart_id: rs485
|
||||
# Asserted while transmitting, released afterwards. The node holds its own DE
|
||||
# until the final stop bit has left the shift register; yours must too, which
|
||||
# is what this option does.
|
||||
flow_control_pin: ${de_pin}
|
||||
|
||||
modbus_controller:
|
||||
- id: wiredsensor
|
||||
address: ${node_address}
|
||||
modbus_id: rs485_bus
|
||||
# The node caches a reading every second and reports its age, so polling
|
||||
# faster than that gains nothing. 30s is plenty for room temperature.
|
||||
update_interval: 30s
|
||||
|
||||
# ------------------------------------------------------------------ sensors ---
|
||||
#
|
||||
# Two groups, on two different function codes, and the split is deliberate.
|
||||
#
|
||||
# ESPHome merges adjacent registers of the same register_type into one command.
|
||||
# A read overlapping 0x0000..0x0004 is refused outright when the sensor has
|
||||
# never produced a reading, so anything merged into the measurement command goes
|
||||
# unavailable along with it — including, without this split, the very
|
||||
# diagnostics that would tell you why. The node serves both 0x03 and 0x04 from
|
||||
# one register table precisely so there is a way out: asking for the
|
||||
# diagnostics as `holding` puts them in their own command, where a dead sensor
|
||||
# cannot take them down.
|
||||
|
||||
sensor:
|
||||
# ---- measurements, function code 0x04 (Read Input Registers) -------------
|
||||
#
|
||||
# Temperature and humidity are 32-bit signed milli-units spanning two
|
||||
# registers each, high word first — ESPHome's S_DWORD. Using S_WORD here
|
||||
# would read half the value and report a plausible but wrong number rather
|
||||
# than failing, so this is the one field worth double-checking.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Temperature"
|
||||
register_type: read
|
||||
address: 0x0000
|
||||
value_type: S_DWORD
|
||||
unit_of_measurement: "°C"
|
||||
device_class: temperature
|
||||
state_class: measurement
|
||||
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
|
||||
state_class: measurement
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
- multiply: 0.001
|
||||
|
||||
# How old the cached reading was when the node answered. The node reports this
|
||||
# rather than enforcing a freshness policy of its own, so the decision about
|
||||
# what staleness matters is yours.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Reading age"
|
||||
register_type: read
|
||||
address: 0x0004
|
||||
value_type: U_WORD
|
||||
unit_of_measurement: "ms"
|
||||
state_class: measurement
|
||||
entity_category: diagnostic
|
||||
|
||||
# ---- diagnostics, function code 0x03 (Read Holding Registers) ------------
|
||||
#
|
||||
# All counters are monotonic since power-up, so `total_increasing` lets Home
|
||||
# Assistant show a rate. A segment with a termination or biasing problem shows
|
||||
# up here as a steadily climbing CRC error count long before any reading is
|
||||
# actually lost — which is the whole reason to graph them.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "I2C errors"
|
||||
register_type: holding
|
||||
address: 0x0007
|
||||
value_type: U_WORD
|
||||
state_class: total_increasing
|
||||
entity_category: diagnostic
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Sensor CRC errors"
|
||||
register_type: holding
|
||||
address: 0x0008
|
||||
value_type: U_WORD
|
||||
state_class: total_increasing
|
||||
entity_category: diagnostic
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Bus frame errors"
|
||||
register_type: holding
|
||||
address: 0x0009
|
||||
value_type: U_WORD
|
||||
state_class: total_increasing
|
||||
entity_category: diagnostic
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Bus CRC errors"
|
||||
register_type: holding
|
||||
address: 0x000A
|
||||
value_type: U_WORD
|
||||
state_class: total_increasing
|
||||
entity_category: diagnostic
|
||||
|
||||
# The low byte of 0x000C. `bitmask` on a sensor masks and then shifts down by
|
||||
# the mask's trailing zeros, so this yields the protocol version on its own.
|
||||
# Worth surfacing: it is the one value that tells you a node is running
|
||||
# firmware older than the register map this config assumes.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Protocol version"
|
||||
register_type: holding
|
||||
address: 0x000C
|
||||
value_type: U_WORD
|
||||
bitmask: 0x00FF
|
||||
entity_category: diagnostic
|
||||
skip_updates: 60
|
||||
|
||||
# Saturating 32-bit seconds. Home Assistant stores sensor states as float32,
|
||||
# which carries 24 bits of mantissa, so this quantises past ~194 days of
|
||||
# uptime. Fine for spotting an unexpected reboot, which is what it is for.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Uptime"
|
||||
register_type: holding
|
||||
address: 0x0011
|
||||
value_type: U_DWORD
|
||||
unit_of_measurement: "s"
|
||||
device_class: duration
|
||||
state_class: total_increasing
|
||||
entity_category: diagnostic
|
||||
|
||||
binary_sensor:
|
||||
# The flag bits of register 0x0005, on `holding` for the reason above.
|
||||
- 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
|
||||
device_class: problem
|
||||
entity_category: diagnostic
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Bus line error"
|
||||
register_type: holding
|
||||
address: 0x0005
|
||||
bitmask: 0x08
|
||||
device_class: problem
|
||||
entity_category: diagnostic
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Sensor reset seen"
|
||||
register_type: holding
|
||||
address: 0x0005
|
||||
bitmask: 0x20
|
||||
entity_category: diagnostic
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Heater on"
|
||||
register_type: holding
|
||||
address: 0x0005
|
||||
bitmask: 0x40
|
||||
entity_category: diagnostic
|
||||
|
||||
text_sensor:
|
||||
# 32-bit serials as hex. A plain sensor would store these as a float and lose
|
||||
# the low bits, for the same 24-bit mantissa reason as uptime above.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "Board serial"
|
||||
register_type: holding
|
||||
address: 0x000D
|
||||
register_count: 2
|
||||
raw_encode: HEXBYTES
|
||||
entity_category: diagnostic
|
||||
disabled_by_default: true
|
||||
skip_updates: 60
|
||||
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: wiredsensor
|
||||
name: "SHT31 serial"
|
||||
register_type: holding
|
||||
address: 0x000F
|
||||
register_count: 2
|
||||
raw_encode: HEXBYTES
|
||||
entity_category: diagnostic
|
||||
disabled_by_default: true
|
||||
skip_updates: 60
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
+469
-150
@@ -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,134 @@ 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 pins to whatever your RS485 transceiver is wired to.
|
||||
#
|
||||
# This is the bus and entity fragment only. For a complete flashable config,
|
||||
# including the board, wifi and every diagnostic register, see
|
||||
# examples/esphome/wiredsensor.yaml.
|
||||
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
|
||||
# Driver enable, wired to DE and /RE together. Delete this if your
|
||||
# transceiver switches direction itself; without it on a module that does
|
||||
# not, nothing you send reaches the segment and the node looks dead.
|
||||
flow_control_pin: GPIO4
|
||||
|
||||
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 +801,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 +813,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:
|
||||
|
||||
Reference in New Issue
Block a user