Speak Modbus RTU instead of a custom command protocol
ESPHome only has built-in support for Modbus RTU over RS485, so the application layer moves to it. The transport already conformed: the CRC was CRC-16/MODBUS, the line rate 19200 8N1, the delimiter a 3.5-character idle gap pinned at 1750 us above 19200, and the address space 1..247 with broadcast never answered. Only the bytes between the address and the CRC change, and no external crate is needed for a read-only server. frame.rs drops the explicit LEN byte, which RTU does not have — a request's length is implied by its function code. The parser gets simpler and MAX_FRAME grows to the RTU limit of 256. The CRC is now the only integrity check there is, so a truncated frame fails it rather than a length comparison; a test asserts every single-bit corruption is still caught, and another pins our encoding against the published example 01 03 00 6B 00 03 74 17. proto.rs becomes a Modbus server: function codes 0x03 and 0x04 over one 19-register table, 0x08 sub-function 0x0000 as the link test that replaces PING, and the four standard exception codes. Registers are big-endian with 32-bit values high word first, which is what masters and ESPHome's S_DWORD assume. Snapshot::registers renders the whole map and reads slice it, so a range read cannot disagree with a single-register read of the same address — the alternative, assembling only the requested registers per read, has no such guarantee. Both read function codes serve the same table. That is not only for masters that implement just 0x03: a read overlapping the measurement block is refused with SERVER_DEVICE_FAILURE when the sensor has never produced a reading, and ESPHome coalesces adjacent registers of one type into a single command, so diagnostic entities merged into that command would go unavailable along with the measurement they were meant to explain. Requesting them under the other function code puts them in their own command. The generated ESPHome config does this. The firmware barely changes, because dispatch's signature does not: flags widen to u16, buffers follow MAX_FRAME, and comments name registers rather than commands. Now ~35 KiB flash and ~2.6 KiB RAM. tools/wiredsensor.py is rewritten and grows from 15 checks to 21, adding agreement between the two function codes, sub-range consistency, and a per-code assertion for every exception. It stays a hand-written Modbus implementation rather than moving to pymodbus: half these checks inject deliberately malformed frames, and a conforming client library exists precisely to make those unconstructable. It also keeps the wire format independent of wiredsensor-core, so a shared bug cannot cancel itself out. Not yet exercised on hardware — the host tests and register-map cross-checks pass, but the timing-dependent behaviour needs `wiredsensor.py test` against a real bus. This replaces the old protocol rather than joining it; command codes 0x01..0x04 are all valid Modbus function codes, so the two cannot share a segment. PROTOCOL_VERSION is 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# wiredsensor
|
# wiredsensor
|
||||||
|
|
||||||
RP2040 firmware for an RS485 temperature and humidity node. A Sensirion **SHT31**
|
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
|
on I2C, an RS485 transceiver on UART0, and **Modbus RTU** in which this node is
|
||||||
node is always the slave.
|
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`.
|
Written in Rust on **RTIC 2** + `rp2040-hal`.
|
||||||
|
|
||||||
@@ -20,10 +21,10 @@ Written in Rust on **RTIC 2** + `rp2040-hal`.
|
|||||||
|
|
||||||
| Path | Contents |
|
| 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. |
|
| `firmware/`| `wiredsensor-fw` — the RTIC application, drivers and register access. |
|
||||||
| `bridge/` | `wiredsensor-bridge` — a spare RP2040 as a USB-to-RS485 bridge, for testing. |
|
| `bridge/` | `wiredsensor-bridge` — a spare RP2040 as a USB-to-RS485 bridge, for testing. |
|
||||||
| `tools/` | `wiredsensor.py` — PC-side master and end-to-end test suite. |
|
| `tools/` | `wiredsensor.py` — PC-side Modbus master and end-to-end test suite. |
|
||||||
|
|
||||||
The `core`/`firmware` split exists so the entire protocol can be exercised by
|
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`
|
ordinary host unit tests (`cargo test`) with no hardware and no emulator. `core`
|
||||||
@@ -102,77 +103,75 @@ the only thing to change.
|
|||||||
|
|
||||||
## Protocol
|
## Protocol
|
||||||
|
|
||||||
19200 baud, 8N1, half duplex. Frames are delimited by an **idle line**, not by
|
**Modbus RTU**, 19200 baud, 8N1, half duplex. The node is a Modbus *server*
|
||||||
any byte value, so payloads are fully binary-transparent with no escaping.
|
(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 │
|
│ ADDR │ FC │ DATA │ CRC_LO │ CRC_HI │
|
||||||
└──────┴─────┴─────┴──────────────┴────────┴────────┘
|
└──────┴────┴──────────────┴────────┴────────┘
|
||||||
1 1 1 0..=64 1 1
|
1 1 0..=252 1 1
|
||||||
```
|
```
|
||||||
|
|
||||||
- **ADDR** — `0x01`..`0xF7` addresses a unit; `0x00` is broadcast and is never
|
- **ADDR** — `0x01`..`0xF7` addresses a unit; `0x00` is broadcast and is never
|
||||||
answered. A frame for any other address is ignored silently.
|
answered. A frame for any other address is ignored silently.
|
||||||
- **LEN** — payload length. Carried explicitly as well as implied by the frame
|
- **FC** — function code. In a response, bit 7 set marks a Modbus exception.
|
||||||
length, which lets a receiver reject a frame on length grounds before spending
|
|
||||||
time on the CRC, and makes captures readable by eye.
|
|
||||||
- **CRC** — CRC-16/MODBUS (poly `0xA001` reflected, init `0xFFFF`, no final XOR),
|
- **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
|
transmitted **low byte first**.
|
||||||
analysers validate our frames without being taught anything.
|
- Register values are **big-endian**. Quantities wider than 16 bits occupy two
|
||||||
- All multi-byte payload fields are **little-endian**.
|
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**
|
Frames are delimited by an **idle line**, not by any byte value, so the data
|
||||||
of idle line, pinned to a fixed 1750 µs above 19200 baud. At 19200 that is
|
field is fully binary-transparent with no escaping. A frame ends after **3.5
|
||||||
1822 µs.
|
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 |
|
### Function codes
|
||||||
|--------|--------------------|-----------------|---------------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
**`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 |
|
Both read codes are served from one register table. Measured values are properly
|
||||||
|--------|-------|----------------------------------------------------|
|
*input* registers, but several masters only implement `0x03`, and answering both
|
||||||
| 0 | `i32` | temperature, milli-degrees Celsius |
|
costs one match arm. Anything else draws `ILLEGAL_FUNCTION`.
|
||||||
| 4 | `i32` | relative humidity, milli-percent (0..=100000) |
|
|
||||||
| 8 | `u16` | age of the reading in ms, saturating at 65535 |
|
### 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 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
|
the master decides what staleness its application tolerates.
|
||||||
ever succeeded, the reply is an error: `SENSOR_UNAVAILABLE`, or `SENSOR_FAULT`
|
|
||||||
once the failure threshold is passed.
|
|
||||||
|
|
||||||
**`READ_INFO`** reply:
|
Flags at `0x0005`:
|
||||||
|
|
||||||
| 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:
|
|
||||||
|
|
||||||
| Bit | Name | Meaning |
|
| Bit | Name | Meaning |
|
||||||
|-----|---------------------|----------------------------------------------------|
|
|-----|---------------------|----------------------------------------------------|
|
||||||
@@ -184,52 +183,134 @@ Flags:
|
|||||||
| 5 | `SENSOR_RESET_SEEN` | the SHT31 reported an unexpected reset |
|
| 5 | `SENSOR_RESET_SEEN` | the SHT31 reported an unexpected reset |
|
||||||
| 6 | `HEATER_ON` | the SHT31 internal heater is on |
|
| 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
|
An exception response is the request's function code with bit 7 set, and a single
|
||||||
payload byte:
|
data byte:
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Name | Cause |
|
||||||
|--------|--------------------------------------------------|
|
|--------|------------------------|------------------------------------------------|
|
||||||
| `0x01` | `ILLEGAL_COMMAND` — command not implemented |
|
| `0x01` | `ILLEGAL_FUNCTION` | function code, or diagnostic sub-function, not implemented |
|
||||||
| `0x02` | `ILLEGAL_LENGTH` — wrong payload length |
|
| `0x02` | `ILLEGAL_DATA_ADDRESS` | the requested range falls outside the map |
|
||||||
| `0x03` | `SENSOR_UNAVAILABLE` — no reading yet |
|
| `0x03` | `ILLEGAL_DATA_VALUE` | register count of 0 or above 125, or a misshaped request |
|
||||||
| `0x04` | `SENSOR_FAULT` — sensor persistently failing |
|
| `0x04` | `SERVER_DEVICE_FAILURE`| no reading has ever been taken, or the sensor is faulted |
|
||||||
| `0x05` | `UNSUPPORTED` — recognised but disabled in this build |
|
|
||||||
|
|
||||||
A frame that fails to parse is **counted and ignored**, never answered: with a
|
`SERVER_DEVICE_FAILURE` is raised **only for reads that touch `0x0000`..`0x0004`**.
|
||||||
bad CRC the address byte cannot be trusted, and replying risks colliding with
|
A read confined to the diagnostic and identity registers still succeeds even with
|
||||||
whichever node was actually addressed.
|
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
|
A frame that fails to parse is **counted and ignored**, never answered. With a bad
|
||||||
address from a compile-time constant, so the command answers `UNSUPPORTED`
|
CRC the address byte cannot be trusted, so replying risks colliding with whichever
|
||||||
instead of silently doing nothing — a master can tell the difference between "not
|
node was actually addressed — and the specification requires silence here anyway.
|
||||||
implemented here" and "wrong command code".
|
|
||||||
|
|
||||||
### Example exchanges
|
### Example exchanges
|
||||||
|
|
||||||
Unit `0x01`, bytes as they appear on the wire:
|
Unit `0x01`, bytes as they appear on the wire:
|
||||||
|
|
||||||
```
|
```
|
||||||
READ_MEASUREMENT → 01 01 00 21 90
|
read temp + humidity + age (5 registers from 0x0000)
|
||||||
← 01 01 0A 9A 5B 00 00 F0 A0 00 00 89 00 87 66
|
→ 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
|
└ 23.450 °C, 41.200 %RH, 137 ms old
|
||||||
|
|
||||||
READ_INFO → 01 02 00 21 60
|
read the whole map (19 registers)
|
||||||
READ_STATUS → 01 03 00 20 F0
|
→ 01 04 00 00 00 13 B1 C7
|
||||||
|
|
||||||
PING "Hi" → 01 04 02 48 69 4F 1E
|
read flags only → 01 04 00 05 00 01 21 CB
|
||||||
← 01 04 02 48 69 4F 1E
|
|
||||||
|
|
||||||
SET_ADDRESS 0x22 → 01 10 01 22 81 94
|
diagnostic echo "Hi"
|
||||||
← 01 90 01 05 C0 66 (UNSUPPORTED)
|
→ 01 08 00 00 48 69 16 25
|
||||||
|
← 01 08 00 00 48 69 16 25
|
||||||
|
|
||||||
unknown cmd 0x7E → 01 7E 00 01 A0
|
write single reg → 01 06 00 00 00 01 48 0A
|
||||||
← 01 FE 01 01 A0 78 (ILLEGAL_COMMAND)
|
(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
|
The diagnostic echo is the intended first bring-up step: it exercises framing,
|
||||||
RS485 driver-enable turnaround without involving the sensor at all.
|
CRC and the RS485 driver-enable turnaround without involving the sensor or the
|
||||||
|
register map at all.
|
||||||
|
|
||||||
|
## ESPHome
|
||||||
|
|
||||||
|
`modbus_controller` reads this node with no custom component and no external
|
||||||
|
library. `tools/wiredsensor.py esphome` prints a complete config for a given unit
|
||||||
|
address; the essentials are:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: GPIO17 # whatever your transceiver is wired to
|
||||||
|
rx_pin: GPIO16
|
||||||
|
baud_rate: 19200 # must match core/src/timing.rs
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: wiredsensor
|
||||||
|
address: 0x01 # must match board.rs UNIT_ADDRESS
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Temperature"
|
||||||
|
register_type: read # ESPHome's name for function code 0x04
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001 # the node reports milli-degrees
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things must agree between the two ends, and all three fail silently or
|
||||||
|
confusingly if they do not:
|
||||||
|
|
||||||
|
- **Baud rate.** `19200` here against `BAUD_RATE` in `core/src/timing.rs`. A
|
||||||
|
mismatch presents as random CRC failures.
|
||||||
|
- **Unit address.** `address:` here against `UNIT_ADDRESS` in
|
||||||
|
`firmware/src/board.rs`. A mismatch presents as a total silence that looks
|
||||||
|
identical to a wiring fault.
|
||||||
|
- **Value type.** `S_DWORD` with `multiply: 0.001`, not `S_WORD`. Reading half of
|
||||||
|
a 32-bit pair yields a plausible-looking but wrong number rather than an error.
|
||||||
|
|
||||||
|
ESPHome coalesces adjacent registers of the same `register_type` into a single
|
||||||
|
command, which the map is laid out for: temperature, humidity and age are
|
||||||
|
contiguous, so all three arrive in one transaction.
|
||||||
|
|
||||||
|
That coalescing has one consequence worth planning around. A read overlapping
|
||||||
|
`0x0000`..`0x0004` is refused with `SERVER_DEVICE_FAILURE` when the sensor has
|
||||||
|
never produced a reading, so any entity ESPHome merges into that command goes
|
||||||
|
unavailable with it — including, if you are not careful, the very flags that would
|
||||||
|
tell you why. The fix is to ask for the diagnostic registers under the *other*
|
||||||
|
function code: they are the same table, but a different `register_type` puts them
|
||||||
|
in their own command.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
binary_sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor OK"
|
||||||
|
register_type: holding # 0x03, so this is a separate command from the
|
||||||
|
address: 0x0005 # 0x04 read that carries the measurement
|
||||||
|
bitmask: 0x01
|
||||||
|
entity_category: diagnostic
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what serving both function codes from one table buys, beyond mere
|
||||||
|
compatibility. `tools/wiredsensor.py esphome` generates a config already set up
|
||||||
|
this way.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -260,14 +341,15 @@ and no way for a bus glitch to renumber a live node. The cost is one image per
|
|||||||
unit, so keep `UNIT_ADDRESS` and `DEVICE_SERIAL` the only things that differ.
|
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
|
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
|
at boot, or a flash-stored address written over the bus — which would mean
|
||||||
is already reserved for it.
|
implementing `0x06` Write Single Register and a holding register for it, the
|
||||||
|
first writable thing in the map.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release -p wiredsensor-fw # firmware, thumbv6m-none-eabi
|
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
|
The host-target flag is needed because `.cargo/config.toml` defaults the whole
|
||||||
@@ -282,8 +364,9 @@ cargo run --release -p wiredsensor-fw # runner is probe-rs
|
|||||||
Readings are logged at `debug` level while `.cargo/config.toml` pins
|
Readings are logged at `debug` level while `.cargo/config.toml` pins
|
||||||
`DEFMT_LOG=info`, so use `DEFMT_LOG=debug cargo run …` to see them.
|
`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.
|
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.
|
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
|
## USB diagnostics
|
||||||
|
|
||||||
@@ -339,9 +422,14 @@ the firmware's own assumptions:
|
|||||||
- **The bridge is protocol-agnostic.** Bytes from USB go out on the pair, bytes
|
- **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,
|
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.
|
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
|
- **The PC side reimplements Modbus RTU** from the specification rather than
|
||||||
rather than sharing `wiredsensor-core`. Shared code would let a framing or CRC
|
sharing `wiredsensor-core`. Shared code would let a framing or CRC bug cancel
|
||||||
bug cancel out and every test pass regardless.
|
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
|
### Wiring
|
||||||
|
|
||||||
@@ -381,16 +469,27 @@ for d in /dev/ttyACM*; do udevadm info -q property -n $d | grep -q 27de && echo
|
|||||||
|
|
||||||
### What the suite covers
|
### What the suite covers
|
||||||
|
|
||||||
15 checks. Framing and CRC arithmetic are already covered by the host tests;
|
21 checks. Framing, CRC arithmetic and the register map are already covered by the
|
||||||
what only a real bus can verify is everything timing-dependent:
|
host tests; what only a real bus can verify is everything timing-dependent:
|
||||||
|
|
||||||
- **Silence where required** — frames for another unit, broadcasts, bad CRCs,
|
- **Silence where required** — frames for another unit, broadcasts, bad CRCs and
|
||||||
truncated frames and frames whose `LEN` lies must all produce *no reply*. A
|
truncated frames must all produce *no reply*. A node that wrongly answered
|
||||||
node that wrongly answered would collide with whoever was actually addressed.
|
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
|
- **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.
|
held past the final stop bit. Release it early and the last byte truncates.
|
||||||
- **No state leakage** between back-to-back requests.
|
- **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
|
- **Counter integrity** — inject one corrupt frame, assert `crc_errors` rises by
|
||||||
exactly one.
|
exactly one.
|
||||||
|
|
||||||
@@ -399,9 +498,10 @@ host tests and the sensor verification were structurally incapable of reaching,
|
|||||||
because it needed real bus timing *and* a frame longer than the RX FIFO
|
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*.
|
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
|
Requests must fit one 64-byte USB packet, since each host write becomes exactly
|
||||||
DE-bracketed transmission. Every real command is at most 21 bytes; only an
|
one DE-bracketed transmission. Every real request is 8 bytes; only a deliberately
|
||||||
oversized `PING` can approach the limit.
|
long diagnostic echo approaches the limit, and the suite sends one at exactly 64
|
||||||
|
bytes to exercise the case.
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
@@ -457,11 +557,16 @@ Worse: once the watermark interrupt has fired mid-frame — at 16 bytes, with th
|
|||||||
FIFO 32 deep — the *remaining* bytes sit below the watermark and raise no
|
FIFO 32 deep — the *remaining* bytes sit below the watermark and raise no
|
||||||
interrupt whatsoever until the timeout eventually arrives. A gap measured from
|
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
|
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
|
is still arriving, and the node tries to parse a prefix — which fails the CRC,
|
||||||
length. Every frame of 16 bytes or more was silently rejected this way; frames
|
since the bytes it read as a CRC are really payload. Every frame of 16 bytes or
|
||||||
under 16 never trip the watermark, so their only interrupt is the timeout, by
|
more was silently rejected this way; frames under 16 never trip the watermark, so
|
||||||
which point every byte is drained and the timestamp is honest. That is why all
|
their only interrupt is the timeout, by which point every byte is drained and the
|
||||||
five real commands worked and only an oversized `PING` exposed it.
|
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
|
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.
|
the ISR's timestamp, which lets a byte that has landed push the deadline back.
|
||||||
@@ -497,4 +602,5 @@ wait. The alternative has the sensor hold SCL for up to 15 ms, blocking the bus
|
|||||||
and making the transfer duration depend on the controller's stretch timeout.
|
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
|
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
|
//! 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
|
//! 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
|
//! 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
|
//! keep frames at or under 64 bytes. Every real Modbus request is 8 bytes, so this
|
||||||
//! most 21, and only an oversized `PING` payload could reach the limit.
|
//! only constrains a deliberately long diagnostic echo — the RTU limit itself is
|
||||||
|
//! 256.
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
#![no_main]
|
#![no_main]
|
||||||
|
|||||||
+88
-84
@@ -1,34 +1,41 @@
|
|||||||
//! Frame layout and validation.
|
//! Modbus RTU ADU layout and validation.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! ┌──────┬─────┬─────┬───────────────┬────────┬────────┐
|
//! ┌──────┬────┬──────────────┬────────┬────────┐
|
||||||
//! │ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │
|
//! │ ADDR │ FC │ DATA │ CRC_LO │ CRC_HI │
|
||||||
//! └──────┴─────┴─────┴───────────────┴────────┴────────┘
|
//! └──────┴────┴──────────────┴────────┴────────┘
|
||||||
//! 1 1 1 0..=64 1 1
|
//! 1 1 0..=252 1 1
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Frames are delimited by an idle line, Modbus-RTU style, rather than by any
|
//! Frames are delimited by an idle line rather than by any byte value, so the
|
||||||
//! byte value — so the payload is fully binary-transparent with no escaping.
|
//! data field 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
|
//! Note what is *absent*: there is no explicit length field. In Modbus RTU the
|
||||||
//! read by eye.
|
//! 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;
|
use crate::crc;
|
||||||
|
|
||||||
/// Largest payload a frame may carry.
|
/// Largest data field a frame may carry: the 256-byte RTU limit less `ADDR`,
|
||||||
pub const MAX_PAYLOAD: usize = 64;
|
/// `FC` and the CRC.
|
||||||
|
pub const MAX_DATA: usize = MAX_FRAME - HEADER_LEN - CRC_LEN;
|
||||||
|
|
||||||
/// Bytes preceding the payload: `ADDR`, `CMD`, `LEN`.
|
/// Bytes preceding the data field: `ADDR`, `FC`.
|
||||||
pub const HEADER_LEN: usize = 3;
|
pub const HEADER_LEN: usize = 2;
|
||||||
|
|
||||||
/// Bytes following the payload.
|
/// Bytes following the data field.
|
||||||
pub const CRC_LEN: usize = 2;
|
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;
|
pub const MIN_FRAME: usize = HEADER_LEN + CRC_LEN;
|
||||||
|
|
||||||
/// Longest possible frame.
|
/// Longest possible frame, fixed by the Modbus RTU specification at 256 bytes
|
||||||
pub const MAX_FRAME: usize = HEADER_LEN + MAX_PAYLOAD + CRC_LEN;
|
/// 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
|
/// Reserved address meaning "every node on the segment". Nodes must never reply
|
||||||
/// to it, or the bus would collide.
|
/// to it, or the bus would collide.
|
||||||
@@ -37,7 +44,7 @@ pub const ADDR_BROADCAST: u8 = 0x00;
|
|||||||
/// Lowest assignable unit address.
|
/// Lowest assignable unit address.
|
||||||
pub const ADDR_MIN: u8 = 0x01;
|
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;
|
pub const ADDR_MAX: u8 = 0xF7;
|
||||||
|
|
||||||
/// Why a received byte sequence is not a usable frame.
|
/// Why a received byte sequence is not a usable frame.
|
||||||
@@ -48,8 +55,6 @@ pub enum ParseError {
|
|||||||
TooShort,
|
TooShort,
|
||||||
/// More bytes than the largest legal frame.
|
/// More bytes than the largest legal frame.
|
||||||
TooLong,
|
TooLong,
|
||||||
/// The `LEN` field disagrees with how many bytes actually arrived.
|
|
||||||
LengthMismatch,
|
|
||||||
/// The trailing CRC does not cover the received bytes.
|
/// The trailing CRC does not cover the received bytes.
|
||||||
BadCrc,
|
BadCrc,
|
||||||
}
|
}
|
||||||
@@ -58,8 +63,8 @@ pub enum ParseError {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||||
pub enum EncodeError {
|
pub enum EncodeError {
|
||||||
/// The payload exceeds [`MAX_PAYLOAD`].
|
/// The data field exceeds [`MAX_DATA`].
|
||||||
PayloadTooLong,
|
DataTooLong,
|
||||||
/// The destination buffer is too small for the resulting frame.
|
/// The destination buffer is too small for the resulting frame.
|
||||||
BufferTooSmall,
|
BufferTooSmall,
|
||||||
}
|
}
|
||||||
@@ -69,10 +74,11 @@ pub enum EncodeError {
|
|||||||
pub struct Frame<'a> {
|
pub struct Frame<'a> {
|
||||||
/// Destination address as it appeared on the wire.
|
/// Destination address as it appeared on the wire.
|
||||||
pub addr: u8,
|
pub addr: u8,
|
||||||
/// Command code.
|
/// Function code.
|
||||||
pub cmd: u8,
|
pub fc: u8,
|
||||||
/// Command payload, already length-checked.
|
/// Everything between the function code and the CRC. Interpreting it is the
|
||||||
pub payload: &'a [u8],
|
/// function code's business; this layer only guarantees it arrived intact.
|
||||||
|
pub data: &'a [u8],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Frame<'a> {
|
impl<'a> Frame<'a> {
|
||||||
@@ -89,11 +95,6 @@ impl<'a> Frame<'a> {
|
|||||||
return Err(ParseError::TooLong);
|
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);
|
let (body, tail) = buf.split_at(buf.len() - CRC_LEN);
|
||||||
if crc::checksum(body) != u16::from_le_bytes([tail[0], tail[1]]) {
|
if crc::checksum(body) != u16::from_le_bytes([tail[0], tail[1]]) {
|
||||||
return Err(ParseError::BadCrc);
|
return Err(ParseError::BadCrc);
|
||||||
@@ -101,28 +102,27 @@ impl<'a> Frame<'a> {
|
|||||||
|
|
||||||
Ok(Frame {
|
Ok(Frame {
|
||||||
addr: buf[0],
|
addr: buf[0],
|
||||||
cmd: buf[1],
|
fc: buf[1],
|
||||||
payload: &buf[HEADER_LEN..HEADER_LEN + len],
|
data: &body[HEADER_LEN..],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a frame into `out`, returning how many bytes were written.
|
/// 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> {
|
pub fn encode(addr: u8, fc: u8, data: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
|
||||||
if payload.len() > MAX_PAYLOAD {
|
if data.len() > MAX_DATA {
|
||||||
return Err(EncodeError::PayloadTooLong);
|
return Err(EncodeError::DataTooLong);
|
||||||
}
|
}
|
||||||
let total = HEADER_LEN + payload.len() + CRC_LEN;
|
let total = HEADER_LEN + data.len() + CRC_LEN;
|
||||||
if out.len() < total {
|
if out.len() < total {
|
||||||
return Err(EncodeError::BufferTooSmall);
|
return Err(EncodeError::BufferTooSmall);
|
||||||
}
|
}
|
||||||
|
|
||||||
out[0] = addr;
|
out[0] = addr;
|
||||||
out[1] = cmd;
|
out[1] = fc;
|
||||||
out[2] = payload.len() as u8;
|
out[HEADER_LEN..HEADER_LEN + data.len()].copy_from_slice(data);
|
||||||
out[HEADER_LEN..HEADER_LEN + payload.len()].copy_from_slice(payload);
|
|
||||||
|
|
||||||
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());
|
out[total - 2..total].copy_from_slice(&crc.to_le_bytes());
|
||||||
|
|
||||||
Ok(total)
|
Ok(total)
|
||||||
@@ -138,78 +138,81 @@ pub const fn is_valid_unit_address(addr: u8) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 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)
|
(buf, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encode_parse_roundtrip() {
|
fn encode_parse_roundtrip() {
|
||||||
let payload = [0xDE, 0xAD, 0xBE, 0xEF];
|
let data = [0x00, 0x00, 0x00, 0x02];
|
||||||
let (buf, n) = build(0x2A, 0x01, &payload);
|
let (buf, n) = build(0x2A, 0x04, &data);
|
||||||
|
|
||||||
let f = Frame::parse(&buf[..n]).unwrap();
|
let f = Frame::parse(&buf[..n]).unwrap();
|
||||||
assert_eq!(f.addr, 0x2A);
|
assert_eq!(f.addr, 0x2A);
|
||||||
assert_eq!(f.cmd, 0x01);
|
assert_eq!(f.fc, 0x04);
|
||||||
assert_eq!(f.payload, &payload);
|
assert_eq!(f.data, &data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_payload_roundtrip() {
|
fn empty_data_roundtrip() {
|
||||||
let (buf, n) = build(0x01, 0x01, &[]);
|
let (buf, n) = build(0x01, 0x03, &[]);
|
||||||
assert_eq!(n, MIN_FRAME);
|
assert_eq!(n, MIN_FRAME);
|
||||||
let f = Frame::parse(&buf[..n]).unwrap();
|
let f = Frame::parse(&buf[..n]).unwrap();
|
||||||
assert!(f.payload.is_empty());
|
assert!(f.data.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn max_payload_roundtrip() {
|
fn max_data_roundtrip() {
|
||||||
let payload = [0x5Au8; MAX_PAYLOAD];
|
let data = [0x5Au8; MAX_DATA];
|
||||||
let (buf, n) = build(0x01, 0x04, &payload);
|
let (buf, n) = build(0x01, 0x04, &data);
|
||||||
assert_eq!(n, MAX_FRAME);
|
assert_eq!(n, MAX_FRAME);
|
||||||
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
|
assert_eq!(Frame::parse(&buf[..n]).unwrap().data, &data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_is_binary_transparent() {
|
fn data_is_binary_transparent() {
|
||||||
// Bytes that would need escaping in a delimiter-based protocol.
|
// Bytes that would need escaping in a delimiter-based protocol.
|
||||||
let payload = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E];
|
let data = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E];
|
||||||
let (buf, n) = build(0x01, 0x04, &payload);
|
let (buf, n) = build(0x01, 0x08, &data);
|
||||||
assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload);
|
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]
|
#[test]
|
||||||
fn rejects_short_and_long() {
|
fn rejects_short_and_long() {
|
||||||
assert_eq!(Frame::parse(&[]), Err(ParseError::TooShort));
|
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!(
|
assert_eq!(
|
||||||
Frame::parse(&[0u8; MAX_FRAME + 1]),
|
Frame::parse(&[0u8; MAX_FRAME + 1]),
|
||||||
Err(ParseError::TooLong)
|
Err(ParseError::TooLong)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// With no length field the CRC is the only integrity check there is, so it
|
||||||
fn rejects_length_field_mismatch() {
|
/// has to catch every single-bit corruption on its own.
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_single_bit_corruption_anywhere() {
|
fn rejects_single_bit_corruption_anywhere() {
|
||||||
let payload = [0x11, 0x22, 0x33];
|
let data = [0x11, 0x22, 0x33];
|
||||||
let (good, n) = build(0x07, 0x02, &payload);
|
let (good, n) = build(0x07, 0x03, &data);
|
||||||
|
|
||||||
for byte in 0..n {
|
for byte in 0..n {
|
||||||
for bit in 0..8 {
|
for bit in 0..8 {
|
||||||
let mut bad = good;
|
let mut bad = good;
|
||||||
bad[byte] ^= 1 << bit;
|
bad[byte] ^= 1 << bit;
|
||||||
// A flip in the LEN byte is caught by the length check first;
|
assert_eq!(
|
||||||
// everything else must be caught by the CRC. Either way the
|
Frame::parse(&bad[..n]),
|
||||||
// frame must not parse as valid.
|
Err(ParseError::BadCrc),
|
||||||
assert!(
|
|
||||||
Frame::parse(&bad[..n]).is_err(),
|
|
||||||
"flip of byte {byte} bit {bit} was accepted"
|
"flip of byte {byte} bit {bit} was accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,28 +221,29 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truncation_is_rejected() {
|
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 {
|
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]
|
#[test]
|
||||||
fn oversized_payload_is_refused() {
|
fn oversized_data_is_refused() {
|
||||||
let mut out = [0u8; MAX_FRAME + 8];
|
let mut out = [0u8; MAX_FRAME + 8];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode(1, 1, &[0u8; MAX_PAYLOAD + 1], &mut out),
|
encode(1, 3, &[0u8; MAX_DATA + 1], &mut out),
|
||||||
Err(EncodeError::PayloadTooLong)
|
Err(EncodeError::DataTooLong)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn small_buffer_is_refused() {
|
fn small_buffer_is_refused() {
|
||||||
let mut out = [0u8; 4];
|
let mut out = [0u8; 3];
|
||||||
assert_eq!(
|
assert_eq!(encode(1, 3, &[], &mut out), Err(EncodeError::BufferTooSmall));
|
||||||
encode(1, 1, &[], &mut out),
|
|
||||||
Err(EncodeError::BufferTooSmall)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
//! Hardware-independent core of the wiredsensor RS485 temperature/humidity node.
|
//! Hardware-independent core of the wiredsensor RS485 temperature/humidity node.
|
||||||
//!
|
//!
|
||||||
//! Everything in this crate is pure computation: framing, CRCs, command dispatch
|
//! Everything in this crate is pure computation: Modbus RTU framing, CRCs, the
|
||||||
//! and SHT3x data-sheet conversions. It performs no I/O and touches no
|
//! 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
|
//! peripherals, which keeps the protocol testable on the host with a plain
|
||||||
//! `cargo test` while the firmware crate owns all register access.
|
//! `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
|
//! The node is a Modbus **server** (slave) speaking RTU over RS485. It answers
|
||||||
//! order on the wire and the native order of both the RP2040 and any x86/ARM
|
//! two function codes — `0x03` Read Holding Registers and `0x04` Read Input
|
||||||
//! host talking to it.
|
//! 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
|
//! 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
|
//! 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.
|
//! split is what lets the entire protocol be exercised by host unit tests.
|
||||||
|
|
||||||
use crate::frame::{self, Frame, ParseError, MAX_FRAME};
|
use crate::frame::{self, Frame, ParseError, MAX_FRAME};
|
||||||
|
|
||||||
/// Protocol revision reported by [`cmd::READ_INFO`]. Bump on any wire-visible
|
/// Register-map revision, reported by [`reg::FW_PATCH_PROTO`]. Bump on any
|
||||||
/// change.
|
/// wire-visible change.
|
||||||
pub const PROTOCOL_VERSION: u8 = 1;
|
///
|
||||||
|
/// 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.
|
/// Function codes this server implements.
|
||||||
pub mod cmd {
|
pub mod fc {
|
||||||
/// Return the latest temperature and humidity reading.
|
/// Read Holding Registers.
|
||||||
pub const READ_MEASUREMENT: u8 = 0x01;
|
pub const READ_HOLDING_REGISTERS: u8 = 0x03;
|
||||||
/// Return firmware version, serial numbers and uptime.
|
/// Read Input Registers. Served from the same table as `0x03`.
|
||||||
pub const READ_INFO: u8 = 0x02;
|
pub const READ_INPUT_REGISTERS: u8 = 0x04;
|
||||||
/// Return health flags and error counters.
|
/// Read Diagnostics — see [`super::DIAG_RETURN_QUERY_DATA`].
|
||||||
pub const READ_STATUS: u8 = 0x03;
|
pub const DIAGNOSTIC: u8 = 0x08;
|
||||||
/// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set on the command byte of a reply to mark it as an error response, so a
|
/// Set on the function code of a response to mark it an exception, so a master
|
||||||
/// master can tell success from failure without inspecting the payload.
|
/// can tell success from failure without inspecting the data field.
|
||||||
pub const ERROR_FLAG: u8 = 0x80;
|
pub const EXCEPTION_FLAG: u8 = 0x80;
|
||||||
|
|
||||||
/// Error codes carried as the single payload byte of an error reply.
|
/// Modbus exception codes, carried as the single data byte of an exception
|
||||||
pub mod err {
|
/// response.
|
||||||
/// The command code is not implemented.
|
pub mod exception {
|
||||||
pub const ILLEGAL_COMMAND: u8 = 0x01;
|
/// The function code is not implemented here.
|
||||||
/// The command does not take a payload of the supplied length.
|
pub const ILLEGAL_FUNCTION: u8 = 0x01;
|
||||||
pub const ILLEGAL_LENGTH: u8 = 0x02;
|
/// The requested register range falls outside the map.
|
||||||
/// No valid reading has been obtained since power-up.
|
pub const ILLEGAL_DATA_ADDRESS: u8 = 0x02;
|
||||||
pub const SENSOR_UNAVAILABLE: u8 = 0x03;
|
/// A field of the request is not a value this function accepts.
|
||||||
/// The sensor is present but persistently failing.
|
pub const ILLEGAL_DATA_VALUE: u8 = 0x03;
|
||||||
pub const SENSOR_FAULT: u8 = 0x04;
|
/// The device cannot answer: no valid reading has ever been taken, or the
|
||||||
/// The command is recognised but disabled in this build.
|
/// sensor is persistently failing.
|
||||||
pub const UNSUPPORTED: u8 = 0x05;
|
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 {
|
pub mod flag {
|
||||||
/// The most recent sensor poll succeeded.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Measurement {
|
pub struct Measurement {
|
||||||
/// Temperature in milli-degrees Celsius.
|
/// Temperature in milli-degrees Celsius.
|
||||||
@@ -82,27 +170,18 @@ pub struct Measurement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Measurement {
|
impl Measurement {
|
||||||
/// Encoded length in bytes.
|
/// Read a measurement back out of a register image. For host-side masters
|
||||||
pub const LEN: usize = 10;
|
/// and tests.
|
||||||
|
pub fn from_registers(regs: &[u16; 5]) -> Self {
|
||||||
/// 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 {
|
|
||||||
Self {
|
Self {
|
||||||
temp_milli_c: i32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]),
|
temp_milli_c: get_u32(regs, reg::TEMP_MILLI_C) as i32,
|
||||||
rh_milli_pct: i32::from_le_bytes([raw[4], raw[5], raw[6], raw[7]]),
|
rh_milli_pct: get_u32(regs, reg::RH_MILLI_PCT) as i32,
|
||||||
age_ms: u16::from_le_bytes([raw[8], raw[9]]),
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Info {
|
pub struct Info {
|
||||||
/// Firmware major version.
|
/// Firmware major version.
|
||||||
@@ -121,27 +200,11 @@ pub struct Info {
|
|||||||
pub uptime_s: u32,
|
pub uptime_s: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Info {
|
/// Health flags and counters, as they appear in the diagnostic registers.
|
||||||
/// 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.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub struct Status {
|
pub struct Status {
|
||||||
/// Bitwise OR of the [`flag`] masks.
|
/// Bitwise OR of the [`flag`] masks.
|
||||||
pub flags: u8,
|
pub flags: u16,
|
||||||
/// Raw SHT3x status register from the last successful read.
|
/// Raw SHT3x status register from the last successful read.
|
||||||
pub sensor_status: u16,
|
pub sensor_status: u16,
|
||||||
/// I2C transfer failures since power-up.
|
/// I2C transfer failures since power-up.
|
||||||
@@ -154,21 +217,6 @@ pub struct Status {
|
|||||||
pub crc_errors: u16,
|
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.
|
/// Everything [`dispatch`] is allowed to know about the device.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Snapshot {
|
pub struct Snapshot {
|
||||||
@@ -180,6 +228,54 @@ pub struct Snapshot {
|
|||||||
pub status: Status,
|
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.
|
/// What the firmware should do with a received byte sequence.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Outcome<'o> {
|
pub enum Outcome<'o> {
|
||||||
@@ -190,7 +286,8 @@ pub enum Outcome<'o> {
|
|||||||
Silent,
|
Silent,
|
||||||
/// The bytes were not a usable frame. The firmware should bump the matching
|
/// 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
|
/// 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),
|
Malformed(ParseError),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,79 +311,104 @@ pub fn dispatch<'o>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// This also covers the broadcast address, since a valid unit address is
|
// 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 {
|
if f.addr != unit {
|
||||||
return Outcome::Silent;
|
return Outcome::Silent;
|
||||||
}
|
}
|
||||||
|
|
||||||
match f.cmd {
|
match f.fc {
|
||||||
cmd::READ_MEASUREMENT => {
|
fc::READ_HOLDING_REGISTERS | fc::READ_INPUT_REGISTERS => {
|
||||||
if !f.payload.is_empty() {
|
read_registers(out, unit, f.fc, f.data, snap)
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fc::DIAGNOSTIC => diagnostic(out, unit, f.data),
|
||||||
cmd::READ_INFO => {
|
_ => exception(out, unit, f.fc, exception::ILLEGAL_FUNCTION),
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a success reply.
|
/// Serve `0x03` and `0x04`: `start` and `count`, both big-endian, in and a
|
||||||
fn reply<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, payload: &[u8]) -> Outcome<'o> {
|
/// byte-counted block of registers out.
|
||||||
let n = match frame::encode(addr, cmd, payload, out.as_mut_slice()) {
|
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,
|
Ok(n) => n,
|
||||||
// Unreachable: every payload built above is at most MAX_PAYLOAD, and a
|
// Unreachable: every response built above is at most MAX_DATA, and a
|
||||||
// PING echo is bounded by the request it mirrors. Staying silent is the
|
// diagnostic echo is bounded by the request it mirrors. Staying silent
|
||||||
// safe failure mode on a shared bus.
|
// is the safe failure mode on a shared bus.
|
||||||
Err(_) => return Outcome::Silent,
|
Err(_) => return Outcome::Silent,
|
||||||
};
|
};
|
||||||
Outcome::Reply(&out[..n])
|
Outcome::Reply(&out[..n])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode an error reply: the command code with [`ERROR_FLAG`] set, and a
|
/// Encode an exception response: the function code with [`EXCEPTION_FLAG`] set,
|
||||||
/// one-byte reason.
|
/// and a one-byte reason.
|
||||||
fn error<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, code: u8) -> Outcome<'o> {
|
fn exception<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, fc: u8, code: u8) -> Outcome<'o> {
|
||||||
reply(out, addr, cmd | ERROR_FLAG, &[code])
|
reply(out, addr, fc | EXCEPTION_FLAG, &[code])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::frame::MAX_PAYLOAD;
|
|
||||||
|
|
||||||
const UNIT: u8 = 0x11;
|
const UNIT: u8 = 0x11;
|
||||||
|
|
||||||
@@ -317,91 +439,235 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a request and run it through dispatch, returning the parsed reply.
|
/// Build a request and run it through dispatch, returning the raw response.
|
||||||
fn exchange(unit: u8, cmd: u8, payload: &[u8]) -> (Snapshot, [u8; MAX_FRAME], usize) {
|
fn exchange_with(snap: &Snapshot, fc: u8, data: &[u8]) -> ([u8; MAX_FRAME], usize) {
|
||||||
let mut req = [0u8; MAX_FRAME];
|
let mut req = [0u8; MAX_FRAME];
|
||||||
let n = frame::encode(unit, cmd, payload, &mut req).unwrap();
|
let n = frame::encode(UNIT, fc, data, &mut req).unwrap();
|
||||||
let snap = snapshot();
|
|
||||||
let mut out = [0u8; MAX_FRAME];
|
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) => {
|
Outcome::Reply(r) => {
|
||||||
let len = r.len();
|
let len = r.len();
|
||||||
let mut copy = [0u8; MAX_FRAME];
|
let mut copy = [0u8; MAX_FRAME];
|
||||||
copy[..len].copy_from_slice(r);
|
copy[..len].copy_from_slice(r);
|
||||||
(snap, copy, len)
|
(copy, len)
|
||||||
}
|
}
|
||||||
other => panic!("expected a reply, got {other:?}"),
|
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]
|
#[test]
|
||||||
fn read_measurement_roundtrips_through_the_wire() {
|
fn measurement_roundtrips_through_the_wire() {
|
||||||
let (snap, buf, n) = exchange(UNIT, cmd::READ_MEASUREMENT, &[]);
|
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();
|
let f = Frame::parse(&buf[..n]).unwrap();
|
||||||
|
// 23_450 = 0x00005B9A
|
||||||
assert_eq!(f.addr, UNIT);
|
assert_eq!(&f.data[1..5], &[0x00, 0x00, 0x5B, 0x9A]);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negative_temperature_survives_the_encoding() {
|
fn negative_temperature_survives_the_encoding() {
|
||||||
let m = Measurement {
|
let mut snap = snapshot();
|
||||||
|
snap.measurement = Some(Measurement {
|
||||||
temp_milli_c: -12_345,
|
temp_milli_c: -12_345,
|
||||||
rh_milli_pct: 0,
|
rh_milli_pct: 0,
|
||||||
age_ms: 0,
|
age_ms: 0,
|
||||||
};
|
});
|
||||||
let mut raw = [0u8; Measurement::LEN];
|
let s = reg::TEMP_MILLI_C.to_be_bytes();
|
||||||
m.encode(&mut raw);
|
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], 0, 5]);
|
||||||
assert_eq!(Measurement::decode(&raw), m);
|
let image: [u16; 5] = registers_of(&buf[..n]).try_into().unwrap();
|
||||||
|
assert_eq!(Measurement::from_registers(&image).temp_milli_c, -12_345);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_info_payload_layout() {
|
fn info_and_status_registers_carry_the_snapshot() {
|
||||||
let (snap, buf, n) = exchange(UNIT, cmd::READ_INFO, &[]);
|
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();
|
let f = Frame::parse(&buf[..n]).unwrap();
|
||||||
assert_eq!(f.payload.len(), Info::LEN);
|
assert_eq!(f.fc, fc::DIAGNOSTIC);
|
||||||
assert_eq!(f.payload[3], PROTOCOL_VERSION);
|
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!(
|
assert_eq!(
|
||||||
u32::from_le_bytes([f.payload[4], f.payload[5], f.payload[6], f.payload[7]]),
|
exception_of(&buf[..n], fc::DIAGNOSTIC),
|
||||||
snap.info.device_serial
|
Some(exception::ILLEGAL_FUNCTION)
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
u32::from_le_bytes([f.payload[8], f.payload[9], f.payload[10], f.payload[11]]),
|
|
||||||
snap.info.sensor_serial
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_status_payload_layout() {
|
fn a_truncated_diagnostic_is_an_illegal_value() {
|
||||||
let (snap, buf, n) = exchange(UNIT, cmd::READ_STATUS, &[]);
|
let (buf, n) = exchange(fc::DIAGNOSTIC, &[0x00]);
|
||||||
let f = Frame::parse(&buf[..n]).unwrap();
|
|
||||||
assert_eq!(f.payload.len(), Status::LEN);
|
|
||||||
assert_eq!(f.payload[0], snap.status.flags);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
u16::from_le_bytes([f.payload[1], f.payload[2]]),
|
exception_of(&buf[..n], fc::DIAGNOSTIC),
|
||||||
snap.status.sensor_status
|
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
|
/// The one behaviour that matters most on a shared bus: never transmit
|
||||||
/// unless the frame was addressed to us specifically.
|
/// unless the frame was addressed to us specifically.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -414,7 +680,8 @@ mod tests {
|
|||||||
frame::ADDR_MAX,
|
frame::ADDR_MAX,
|
||||||
] {
|
] {
|
||||||
let mut req = [0u8; MAX_FRAME];
|
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];
|
let mut out = [0u8; MAX_FRAME];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dispatch(UNIT, &req[..n], &snap, &mut out),
|
dispatch(UNIT, &req[..n], &snap, &mut out),
|
||||||
@@ -430,12 +697,12 @@ mod tests {
|
|||||||
let mut out = [0u8; MAX_FRAME];
|
let mut out = [0u8; MAX_FRAME];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dispatch(UNIT, &[0x11, 0x01], &snap, &mut out),
|
dispatch(UNIT, &[0x11, 0x03], &snap, &mut out),
|
||||||
Outcome::Malformed(ParseError::TooShort)
|
Outcome::Malformed(ParseError::TooShort)
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut req = [0u8; MAX_FRAME];
|
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
|
req[n - 1] ^= 0xFF; // corrupt the CRC
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dispatch(UNIT, &req[..n], &snap, &mut out),
|
dispatch(UNIT, &req[..n], &snap, &mut out),
|
||||||
@@ -444,80 +711,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_command_yields_illegal_command() {
|
fn missing_reading_fails_only_the_measurement_registers() {
|
||||||
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() {
|
|
||||||
let mut snap = snapshot();
|
let mut snap = snapshot();
|
||||||
snap.measurement = None;
|
snap.measurement = None;
|
||||||
snap.status.flags = 0;
|
snap.status.flags = 0;
|
||||||
|
|
||||||
let mut req = [0u8; MAX_FRAME];
|
// Anything overlapping the measurement block is refused...
|
||||||
let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap();
|
for (start, count) in [(reg::TEMP_MILLI_C, 1), (reg::AGE_MS, 2), (0, reg::COUNT)] {
|
||||||
let mut out = [0u8; MAX_FRAME];
|
let s = start.to_be_bytes();
|
||||||
|
let c = count.to_be_bytes();
|
||||||
match dispatch(UNIT, &req[..n], &snap, &mut out) {
|
let (buf, n) = exchange_with(&snap, fc::READ_INPUT_REGISTERS, &[s[0], s[1], c[0], c[1]]);
|
||||||
Outcome::Reply(r) => {
|
assert_eq!(
|
||||||
let f = Frame::parse(r).unwrap();
|
exception_of(&buf[..n], fc::READ_INPUT_REGISTERS),
|
||||||
assert_eq!(f.payload, &[err::SENSOR_UNAVAILABLE]);
|
Some(exception::SERVER_DEVICE_FAILURE),
|
||||||
}
|
"start {start}, count {count}"
|
||||||
other => panic!("expected an error reply, got {other:?}"),
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ...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]
|
#[test]
|
||||||
fn faulted_sensor_reports_fault_instead_of_unavailable() {
|
fn all_responses_are_well_formed() {
|
||||||
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() {
|
|
||||||
let snap = snapshot();
|
let snap = snapshot();
|
||||||
for cmd in 0x00u8..=0xFF {
|
for fc in 0x00u8..=0xFF {
|
||||||
let mut req = [0u8; MAX_FRAME];
|
for data in [&[][..], &[0, 0][..], &[0, 0, 0, 1][..], &[0xFF; 8][..]] {
|
||||||
let n = frame::encode(UNIT, cmd, &[], &mut req).unwrap();
|
let mut req = [0u8; MAX_FRAME];
|
||||||
let mut out = [0u8; MAX_FRAME];
|
let n = frame::encode(UNIT, fc, data, &mut req).unwrap();
|
||||||
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
|
let mut out = [0u8; MAX_FRAME];
|
||||||
let f = Frame::parse(r).expect("emitted an unparseable reply");
|
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
|
||||||
assert_eq!(f.addr, UNIT);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ use wiredsensor_core::{sht3x, timing};
|
|||||||
/// that differs between them.
|
/// that differs between them.
|
||||||
pub const UNIT_ADDRESS: u8 = 0x01;
|
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.
|
/// factory serial, which is read over I2C at start-up.
|
||||||
pub const DEVICE_SERIAL: u32 = 0x0000_0001;
|
pub const DEVICE_SERIAL: u32 = 0x0000_0001;
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ mod app {
|
|||||||
taken_at: Instant,
|
taken_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything behind `READ_STATUS` and `READ_INFO` that is discovered at
|
/// Everything behind the diagnostic and identity registers that is
|
||||||
/// runtime rather than fixed at build time.
|
/// discovered at runtime rather than fixed at build time.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct DeviceState {
|
pub struct DeviceState {
|
||||||
/// Factory serial read from the sensor at start-up, 0 if unavailable.
|
/// Factory serial read from the sensor at start-up, 0 if unavailable.
|
||||||
@@ -398,7 +398,7 @@ mod app {
|
|||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
let (info, status) = cx.shared.state.lock(|s| {
|
let (info, status) = cx.shared.state.lock(|s| {
|
||||||
let mut flags = 0u8;
|
let mut flags = 0u16;
|
||||||
if s.sensor_ok {
|
if s.sensor_ok {
|
||||||
flags |= flag::SENSOR_OK;
|
flags |= flag::SENSOR_OK;
|
||||||
}
|
}
|
||||||
@@ -499,11 +499,11 @@ mod app {
|
|||||||
// NACKs anything arriving while it is still busy with the previous one.
|
// NACKs anything arriving while it is still busy with the previous one.
|
||||||
// Without the settle after `clear_status` the first measurement below
|
// Without the settle after `clear_status` the first measurement below
|
||||||
// failed on every boot, leaving a permanent i2c_errors=1 that a master
|
// 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
|
// Failures here are logged but deliberately not counted: the counters
|
||||||
// describe operational health, and an unreadable serial already shows up
|
// 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));
|
let settle = board::Duration::millis(u64::from(sht3x::COMMAND_SETTLE_MS));
|
||||||
|
|
||||||
// Clear whatever state a warm reset left behind, so the sensor's
|
// 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 the status register so an unexpected sensor reset shows up on
|
||||||
// READ_STATUS instead of silently reverting the configuration.
|
// the bus instead of silently reverting the configuration.
|
||||||
//
|
//
|
||||||
// Failures are counted rather than discarded. Swallowing them made a
|
// Failures are counted rather than discarded. Swallowing them made a
|
||||||
// persistently unreadable status indistinguishable from a genuinely
|
// persistently unreadable status indistinguishable from a genuinely
|
||||||
|
|||||||
Binary file not shown.
+461
-150
@@ -1,11 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
This is deliberately an **independent** implementation of Modbus RTU. The
|
||||||
framing and CRC below were written from the specification in README.md rather
|
framing and CRC below were written from the specification rather than sharing
|
||||||
than sharing code with `wiredsensor-core`. If both ends shared an
|
code with `wiredsensor-core`. If both ends shared an implementation, a bug in it
|
||||||
implementation, a bug in it would cancel out and every test here would pass
|
would cancel out and every test here would pass regardless — so the duplication
|
||||||
regardless — so the duplication is the point, not an oversight.
|
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
|
Talks to the bus through the USB-to-RS485 bridge firmware (`bridge/`), which is
|
||||||
protocol-agnostic and just moves bytes.
|
protocol-agnostic and just moves bytes.
|
||||||
@@ -33,28 +40,53 @@ except ImportError:
|
|||||||
ADDR_BROADCAST = 0x00
|
ADDR_BROADCAST = 0x00
|
||||||
ADDR_MIN, ADDR_MAX = 0x01, 0xF7
|
ADDR_MIN, ADDR_MAX = 0x01, 0xF7
|
||||||
|
|
||||||
MAX_PAYLOAD = 64
|
HEADER_LEN = 2 # ADDR, FC
|
||||||
HEADER_LEN = 3
|
|
||||||
CRC_LEN = 2
|
CRC_LEN = 2
|
||||||
MIN_FRAME = HEADER_LEN + CRC_LEN
|
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
|
# Each host write becomes exactly one DE-bracketed transmission by the bridge,
|
||||||
CMD_READ_INFO = 0x02
|
# and a USB full-speed bulk packet is 64 bytes, so a request must fit in one.
|
||||||
CMD_READ_STATUS = 0x03
|
MAX_BRIDGE_FRAME = 64
|
||||||
CMD_PING = 0x04
|
|
||||||
CMD_SET_ADDRESS = 0x10
|
|
||||||
|
|
||||||
ERROR_FLAG = 0x80
|
FC_READ_HOLDING = 0x03
|
||||||
|
FC_READ_INPUT = 0x04
|
||||||
|
FC_DIAGNOSTIC = 0x08
|
||||||
|
|
||||||
ERR_NAMES = {
|
DIAG_RETURN_QUERY_DATA = 0x0000
|
||||||
0x01: "ILLEGAL_COMMAND",
|
|
||||||
0x02: "ILLEGAL_LENGTH",
|
EXCEPTION_FLAG = 0x80
|
||||||
0x03: "SENSOR_UNAVAILABLE",
|
|
||||||
0x04: "SENSOR_FAULT",
|
EXCEPTION_NAMES = {
|
||||||
0x05: "UNSUPPORTED",
|
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 = [
|
FLAG_NAMES = [
|
||||||
(1 << 0, "SENSOR_OK"),
|
(1 << 0, "SENSOR_OK"),
|
||||||
(1 << 1, "DATA_STALE"),
|
(1 << 1, "DATA_STALE"),
|
||||||
@@ -65,6 +97,28 @@ FLAG_NAMES = [
|
|||||||
(1 << 6, "HEATER_ON"),
|
(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:
|
def crc16(data: bytes) -> int:
|
||||||
"""CRC-16/MODBUS: reflected poly 0xA001, init 0xFFFF, no final XOR."""
|
"""CRC-16/MODBUS: reflected poly 0xA001, init 0xFFFF, no final XOR."""
|
||||||
@@ -76,51 +130,91 @@ def crc16(data: bytes) -> int:
|
|||||||
return crc
|
return crc
|
||||||
|
|
||||||
|
|
||||||
def build_frame(addr: int, cmd: int, payload: bytes = b"") -> bytes:
|
def build_frame(addr: int, fc: int, data: bytes = b"") -> bytes:
|
||||||
if len(payload) > MAX_PAYLOAD:
|
"""An RTU ADU: address, function code, data, CRC low byte first."""
|
||||||
raise ValueError(f"payload of {len(payload)} exceeds {MAX_PAYLOAD}")
|
if len(data) > MAX_DATA:
|
||||||
body = bytes([addr, cmd, len(payload)]) + payload
|
raise ValueError(f"data field of {len(data)} exceeds {MAX_DATA}")
|
||||||
|
body = bytes([addr, fc]) + data
|
||||||
return body + struct.pack("<H", crc16(body))
|
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
|
@dataclass
|
||||||
class Frame:
|
class Frame:
|
||||||
addr: int
|
addr: int
|
||||||
cmd: int
|
fc: int
|
||||||
payload: bytes
|
data: bytes
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_error(self) -> bool:
|
def is_exception(self) -> bool:
|
||||||
return bool(self.cmd & ERROR_FLAG)
|
return bool(self.fc & EXCEPTION_FLAG)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error_name(self) -> str | None:
|
def exception_name(self) -> str | None:
|
||||||
if not self.is_error or len(self.payload) != 1:
|
if not self.is_exception or len(self.data) != 1:
|
||||||
return None
|
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):
|
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:
|
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:
|
if len(raw) < MIN_FRAME:
|
||||||
raise ProtocolError(f"short frame: {len(raw)} bytes ({raw.hex(' ')})")
|
raise ProtocolError(f"short frame: {len(raw)} bytes ({raw.hex(' ')})")
|
||||||
if len(raw) > MAX_FRAME:
|
if len(raw) > MAX_FRAME:
|
||||||
raise ProtocolError(f"long frame: {len(raw)} bytes")
|
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:]
|
body, tail = raw[:-CRC_LEN], raw[-CRC_LEN:]
|
||||||
expected = struct.unpack("<H", tail)[0]
|
expected = struct.unpack("<H", tail)[0]
|
||||||
actual = crc16(body)
|
actual = crc16(body)
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
raise ProtocolError(f"CRC {actual:#06x} != {expected:#06x} ({raw.hex(' ')})")
|
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 ---
|
# ------------------------------------------------------------------ decode ---
|
||||||
@@ -133,11 +227,14 @@ class Measurement:
|
|||||||
age_ms: int
|
age_ms: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode(cls, p: bytes) -> Measurement:
|
def decode(cls, regs: list[int], base: int = REG_TEMP_MILLI_C) -> Measurement:
|
||||||
if len(p) != 10:
|
if len(regs) < 5:
|
||||||
raise ProtocolError(f"measurement payload is {len(p)} bytes, expected 10")
|
raise ProtocolError(f"expected 5 measurement registers, got {len(regs)}")
|
||||||
t, rh, age = struct.unpack("<iiH", p)
|
return cls(
|
||||||
return cls(t / 1000.0, rh / 1000.0, age)
|
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:
|
def __str__(self) -> str:
|
||||||
return f"{self.temp_c:+.3f} °C {self.rh_pct:.3f} %RH age {self.age_ms} ms"
|
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
|
uptime_s: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode(cls, p: bytes) -> Info:
|
def decode(cls, regs: list[int], base: int = REG_FW_MAJOR_MINOR) -> Info:
|
||||||
if len(p) != 16:
|
if len(regs) < REG_COUNT - REG_FW_MAJOR_MINOR:
|
||||||
raise ProtocolError(f"info payload is {len(p)} bytes, expected 16")
|
raise ProtocolError(f"expected 8 info registers, got {len(regs)}")
|
||||||
maj, mnr, pat, proto, dev, sens, up = struct.unpack("<BBBBIII", p)
|
major_minor = regs[REG_FW_MAJOR_MINOR - base]
|
||||||
return cls((maj, mnr, pat), proto, dev, sens, up)
|
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:
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -176,10 +280,17 @@ class Status:
|
|||||||
crc_errors: int
|
crc_errors: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode(cls, p: bytes) -> Status:
|
def decode(cls, regs: list[int], base: int = REG_FLAGS) -> Status:
|
||||||
if len(p) != 11:
|
if len(regs) < REG_FW_MAJOR_MINOR - REG_FLAGS:
|
||||||
raise ProtocolError(f"status payload is {len(p)} bytes, expected 11")
|
raise ProtocolError(f"expected 6 status registers, got {len(regs)}")
|
||||||
return cls(*struct.unpack("<BHHHHH", p))
|
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
|
@property
|
||||||
def flag_names(self) -> list[str]:
|
def flag_names(self) -> list[str]:
|
||||||
@@ -187,7 +298,7 @@ class Status:
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return (
|
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"sht_status {self.sensor_status:#06x} "
|
||||||
f"err i2c={self.i2c_errors} sht_crc={self.sensor_crc_errors} "
|
f"err i2c={self.i2c_errors} sht_crc={self.sensor_crc_errors} "
|
||||||
f"frame={self.frame_errors} bus_crc={self.crc_errors}"
|
f"frame={self.frame_errors} bus_crc={self.crc_errors}"
|
||||||
@@ -198,7 +309,7 @@ class Status:
|
|||||||
|
|
||||||
|
|
||||||
class Bus:
|
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):
|
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
|
# The bridge is a CDC device, so the baud rate here is ignored; the RS485
|
||||||
@@ -225,9 +336,9 @@ class Bus:
|
|||||||
self.ser.flush()
|
self.ser.flush()
|
||||||
|
|
||||||
def read_raw(self, timeout: float = 0.5, gap: float = 0.02) -> bytes:
|
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.
|
several USB chunks is still reassembled into one frame.
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
@@ -246,40 +357,64 @@ class Bus:
|
|||||||
print(f" RX {bytes(buf).hex(' ')}", file=sys.stderr)
|
print(f" RX {bytes(buf).hex(' ')}", file=sys.stderr)
|
||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
def request(self, cmd: int, payload: bytes = b"", addr: int | None = None) -> Frame:
|
def expect_raw(self, fc: int, data: bytes = b"", addr: int | None = None) -> bytes:
|
||||||
"""Send a command and return the parsed reply, raising if none arrives."""
|
"""Send a request and return whatever bytes come back, if any."""
|
||||||
raw = self.expect_raw(cmd, payload, addr)
|
self.send_raw(build_frame(self.unit if addr is None else addr, fc, data))
|
||||||
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))
|
|
||||||
return self.read_raw()
|
return self.read_raw()
|
||||||
|
|
||||||
def measurement(self) -> Measurement:
|
def request(self, fc: int, data: bytes = b"", addr: int | None = None) -> Frame:
|
||||||
return Measurement.decode(self._ok(CMD_READ_MEASUREMENT).payload)
|
"""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:
|
def transact(self, fc: int, data: bytes = b"") -> Frame:
|
||||||
return Info.decode(self._ok(CMD_READ_INFO).payload)
|
"""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:
|
def read_registers(
|
||||||
return Status.decode(self._ok(CMD_READ_STATUS).payload)
|
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:
|
def diagnostic(self, payload: bytes) -> bytes:
|
||||||
return self._ok(CMD_PING, payload).payload
|
"""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:
|
def measurement(self, fc: int = FC_READ_INPUT) -> Measurement:
|
||||||
reply = self.request(cmd, payload)
|
return Measurement.decode(
|
||||||
if reply.is_error:
|
self.read_registers(REG_TEMP_MILLI_C, REG_MEASUREMENT_END, fc)
|
||||||
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}")
|
def status(self, fc: int = FC_READ_INPUT) -> Status:
|
||||||
if reply.addr != self.unit:
|
count = REG_FW_MAJOR_MINOR - REG_FLAGS
|
||||||
raise ProtocolError(f"reply from {reply.addr:#04x} != {self.unit:#04x}")
|
return Status.decode(self.read_registers(REG_FLAGS, count, fc))
|
||||||
return reply
|
|
||||||
|
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 ---
|
# ------------------------------------------------------------------- tests ---
|
||||||
@@ -300,14 +435,25 @@ class Results:
|
|||||||
print(f" \033[31mFAIL\033[0m {name}\n {exc}")
|
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:
|
def run_tests(bus: Bus) -> int:
|
||||||
"""Exercise the parts of the protocol only a real bus can verify.
|
"""Exercise the parts of the protocol only a real bus can verify.
|
||||||
|
|
||||||
Framing and CRC arithmetic are already covered by host unit tests in
|
Framing, CRC arithmetic and the register map are already covered by host
|
||||||
`core/`. What cannot be tested off-hardware is everything timing-dependent:
|
unit tests in `core/`. What cannot be tested off-hardware is everything
|
||||||
that the node delimits frames by an idle gap, that it stays off the wire for
|
timing-dependent: that the node delimits frames by an idle gap, that it
|
||||||
traffic it must not answer, and that its driver-enable turnaround leaves the
|
stays off the wire for traffic it must not answer, and that its
|
||||||
reply intact.
|
driver-enable turnaround leaves the response intact.
|
||||||
"""
|
"""
|
||||||
r = Results()
|
r = Results()
|
||||||
|
|
||||||
@@ -315,11 +461,11 @@ def run_tests(bus: Bus) -> int:
|
|||||||
|
|
||||||
def t_info():
|
def t_info():
|
||||||
info = bus.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"
|
assert info.sensor_serial != 0, "sensor serial is zero — SHT31 not identified"
|
||||||
return str(info)
|
return str(info)
|
||||||
|
|
||||||
r.check("READ_INFO", t_info)
|
r.check("read identity registers", t_info)
|
||||||
|
|
||||||
def t_measure():
|
def t_measure():
|
||||||
m = bus.measurement()
|
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"
|
assert m.age_ms <= 3000, f"reading is {m.age_ms} ms old"
|
||||||
return str(m)
|
return str(m)
|
||||||
|
|
||||||
r.check("READ_MEASUREMENT", t_measure)
|
r.check("read measurement registers", t_measure)
|
||||||
|
|
||||||
def t_status():
|
def t_status():
|
||||||
s = bus.status()
|
s = bus.status()
|
||||||
@@ -336,35 +482,80 @@ def run_tests(bus: Bus) -> int:
|
|||||||
assert not s.flags & 0x04, "SENSOR_FAULT is set"
|
assert not s.flags & 0x04, "SENSOR_FAULT is set"
|
||||||
return str(s)
|
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")
|
print("\nround trip integrity")
|
||||||
|
|
||||||
def t_ping_binary():
|
def t_diag_binary():
|
||||||
# Bytes that would need escaping in any delimiter-based protocol.
|
# Bytes that would need escaping in any delimiter-based protocol.
|
||||||
probe = bytes([0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E, 0x55, 0xAA])
|
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(' ')}"
|
assert echo == probe, f"echo {echo.hex(' ')} != sent {probe.hex(' ')}"
|
||||||
return f"{len(probe)} bytes binary-transparent"
|
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():
|
def t_diag_long():
|
||||||
# 59 payload bytes -> a 64-byte frame, the largest the bridge can send as
|
# A 64-byte frame: the largest the bridge can send as a single USB packet
|
||||||
# a single USB packet and therefore as a single transmission.
|
# and therefore as a single transmission. Well past the 16-byte RX FIFO
|
||||||
probe = bytes(range(59))
|
# watermark, which is the case the frame-gap logic gets wrong if the
|
||||||
echo = bus.ping(probe)
|
# deadline is measured from the ISR's timestamp.
|
||||||
assert echo == probe, f"echo of {len(echo)} bytes != sent {len(probe)}"
|
payload = bytes(range(MAX_BRIDGE_FRAME - MIN_FRAME - 2))
|
||||||
return "59-byte payload, 64-byte frame"
|
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():
|
def t_back_to_back():
|
||||||
# Catches state leaking between frames — a stale receive buffer or a
|
# Catches state leaking between frames — a stale receive buffer or a
|
||||||
# gap-detection flag left set would show up here and nowhere else.
|
# gap-detection flag left set would show up here and nowhere else.
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
probe = bytes([i, 0xA5 ^ i])
|
probe = bytes([i, 0xA5 ^ i])
|
||||||
echo = bus.ping(probe)
|
echo = bus.diagnostic(probe)
|
||||||
assert echo == probe, f"iteration {i}: {echo.hex(' ')} != {probe.hex(' ')}"
|
assert echo == probe, f"iteration {i}: {echo.hex(' ')} != {probe.hex(' ')}"
|
||||||
return "20 consecutive requests, no state leak"
|
return "20 consecutive requests, no state leak"
|
||||||
|
|
||||||
@@ -374,21 +565,21 @@ def run_tests(bus: Bus) -> int:
|
|||||||
|
|
||||||
def t_other_unit():
|
def t_other_unit():
|
||||||
other = 0x02 if bus.unit != 0x02 else 0x03
|
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(' ')}"
|
assert not raw, f"answered a frame addressed to {other:#04x}: {raw.hex(' ')}"
|
||||||
return f"ignored a frame for unit {other:#04x}"
|
return f"ignored a frame for unit {other:#04x}"
|
||||||
|
|
||||||
r.check("frame for another unit is ignored", t_other_unit)
|
r.check("frame for another unit is ignored", t_other_unit)
|
||||||
|
|
||||||
def t_broadcast():
|
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(' ')}"
|
assert not raw, f"answered a broadcast: {raw.hex(' ')}"
|
||||||
return "ignored the broadcast address"
|
return "ignored the broadcast address"
|
||||||
|
|
||||||
r.check("broadcast is not answered", t_broadcast)
|
r.check("broadcast is not answered", t_broadcast)
|
||||||
|
|
||||||
def t_bad_crc():
|
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
|
frame[-1] ^= 0xFF
|
||||||
bus.send_raw(bytes(frame))
|
bus.send_raw(bytes(frame))
|
||||||
raw = bus.read_raw()
|
raw = bus.read_raw()
|
||||||
@@ -398,7 +589,9 @@ def run_tests(bus: Bus) -> int:
|
|||||||
r.check("bad CRC is ignored", t_bad_crc)
|
r.check("bad CRC is ignored", t_bad_crc)
|
||||||
|
|
||||||
def t_truncated():
|
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)
|
bus.send_raw(frame)
|
||||||
raw = bus.read_raw()
|
raw = bus.read_raw()
|
||||||
assert not raw, f"answered a truncated frame: {raw.hex(' ')}"
|
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)
|
r.check("truncated frame is ignored", t_truncated)
|
||||||
|
|
||||||
def t_length_lie():
|
print("\nexception responses")
|
||||||
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"
|
|
||||||
|
|
||||||
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():
|
def t_past_end():
|
||||||
reply = bus.request(0x7E)
|
return expect_exception(bus, FC_READ_INPUT, read_request(REG_COUNT, 1), 0x02)
|
||||||
assert reply.cmd == 0x7E | ERROR_FLAG, f"cmd {reply.cmd:#04x}"
|
|
||||||
assert reply.error_name == "ILLEGAL_COMMAND", reply.error_name
|
|
||||||
return "0x7E -> ILLEGAL_COMMAND"
|
|
||||||
|
|
||||||
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():
|
def t_straddles_end():
|
||||||
reply = bus.request(CMD_READ_MEASUREMENT, b"\xaa")
|
data = read_request(REG_COUNT - 1, 2)
|
||||||
assert reply.error_name == "ILLEGAL_LENGTH", reply.error_name
|
return expect_exception(bus, FC_READ_INPUT, data, 0x02)
|
||||||
return "payload on READ_MEASUREMENT -> ILLEGAL_LENGTH"
|
|
||||||
|
|
||||||
r.check("unexpected payload is refused", t_bad_length)
|
r.check("read straddling the end is refused", t_straddles_end)
|
||||||
|
|
||||||
def t_set_address():
|
def t_zero_count():
|
||||||
reply = bus.request(CMD_SET_ADDRESS, b"\x22")
|
return expect_exception(bus, FC_READ_INPUT, read_request(0, 0), 0x03)
|
||||||
assert reply.error_name == "UNSUPPORTED", reply.error_name
|
|
||||||
return "SET_ADDRESS -> UNSUPPORTED, not silently ignored"
|
|
||||||
|
|
||||||
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")
|
print("\ndiagnostic counters")
|
||||||
|
|
||||||
def t_crc_counter():
|
def t_crc_counter():
|
||||||
before = bus.status().crc_errors
|
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
|
bad[-1] ^= 0xFF
|
||||||
bus.send_raw(bytes(bad))
|
bus.send_raw(bytes(bad))
|
||||||
bus.read_raw(timeout=0.15)
|
bus.read_raw(timeout=0.15)
|
||||||
after = bus.status().crc_errors
|
after = bus.status().crc_errors
|
||||||
assert after == before + 1, (
|
assert after == before + 1, f"crc_errors went {before} -> {after}, expected +1"
|
||||||
f"crc_errors went {before} -> {after}, expected +1"
|
|
||||||
)
|
|
||||||
return f"crc_errors {before} -> {after}"
|
return f"crc_errors {before} -> {after}"
|
||||||
|
|
||||||
r.check("a corrupt frame increments crc_errors", t_crc_counter)
|
r.check("a corrupt frame increments crc_errors", t_crc_counter)
|
||||||
@@ -466,25 +665,126 @@ def run_tests(bus: Bus) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- esphome ---
|
||||||
|
|
||||||
|
ESPHOME_YAML = """\
|
||||||
|
# wiredsensor node at unit address {unit} — paste into your ESPHome config and
|
||||||
|
# set the tx_pin/rx_pin to whatever your RS485 transceiver is wired to.
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
baud_rate: {baud}
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: wiredsensor
|
||||||
|
address: {unit}
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Temperature"
|
||||||
|
register_type: read # function code 0x04
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Humidity"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0002
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
device_class: humidity
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Reading age"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0004
|
||||||
|
value_type: U_WORD
|
||||||
|
unit_of_measurement: "ms"
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
# The health flags deliberately use `holding` (function code 0x03) even though
|
||||||
|
# they are the same registers. ESPHome coalesces adjacent registers of the same
|
||||||
|
# register_type into one command, and a read overlapping 0x0000..0x0004 is
|
||||||
|
# refused outright when the sensor has never produced a reading. Asking for the
|
||||||
|
# flags under the other function code puts them in their own command, so they
|
||||||
|
# still report when the measurement cannot — which is exactly when you want them.
|
||||||
|
binary_sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor OK"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x01
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Data stale"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x02
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor fault"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x04
|
||||||
|
entity_category: diagnostic
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- CLI ---
|
# --------------------------------------------------------------------- CLI ---
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int | str:
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", default="/dev/ttyACM0", help="bridge serial port")
|
ap.add_argument("--port", default="/dev/ttyACM0", help="bridge serial port")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--unit", type=lambda s: int(s, 0), default=0x01, help="node address"
|
"--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("-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 = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
sub.add_parser("measure", help="read temperature and humidity once")
|
sub.add_parser("measure", help="read temperature and humidity once")
|
||||||
sub.add_parser("info", help="read firmware and serial numbers")
|
sub.add_parser("info", help="read firmware and serial numbers")
|
||||||
sub.add_parser("status", help="read health flags and counters")
|
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("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_diag = sub.add_parser("diag", help="Return Query Data echo test")
|
||||||
p_ping.add_argument("--bytes", type=int, default=8, help="payload length")
|
p_diag.add_argument("--bytes", type=int, default=8, help="payload length")
|
||||||
|
|
||||||
p_mon = sub.add_parser("monitor", help="poll continuously")
|
p_mon = sub.add_parser("monitor", help="poll continuously")
|
||||||
p_mon.add_argument(
|
p_mon.add_argument(
|
||||||
@@ -493,6 +793,11 @@ def main() -> int:
|
|||||||
|
|
||||||
args = ap.parse_args()
|
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:
|
try:
|
||||||
bus = Bus(args.port, args.unit, args.verbose)
|
bus = Bus(args.port, args.unit, args.verbose)
|
||||||
except serial.SerialException as exc:
|
except serial.SerialException as exc:
|
||||||
@@ -500,26 +805,32 @@ def main() -> int:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if args.cmd == "measure":
|
if args.cmd == "measure":
|
||||||
print(bus.measurement())
|
print(bus.measurement(args.fc))
|
||||||
elif args.cmd == "info":
|
elif args.cmd == "info":
|
||||||
print(bus.info())
|
print(bus.info(args.fc))
|
||||||
elif args.cmd == "status":
|
elif args.cmd == "status":
|
||||||
print(bus.status())
|
print(bus.status(args.fc))
|
||||||
elif args.cmd == "ping":
|
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))
|
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(' ')}")
|
print("echo ok" if echo == probe else f"MISMATCH: {echo.hex(' ')}")
|
||||||
elif args.cmd == "monitor":
|
elif args.cmd == "monitor":
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
print(f"{time.strftime('%H:%M:%S')} {bus.measurement()}")
|
print(f"{time.strftime('%H:%M:%S')} {bus.measurement(args.fc)}")
|
||||||
except ProtocolError as exc:
|
except (ProtocolError, ExceptionResponse) as exc:
|
||||||
print(f"{time.strftime('%H:%M:%S')} {exc}")
|
print(f"{time.strftime('%H:%M:%S')} {exc}")
|
||||||
time.sleep(args.interval)
|
time.sleep(args.interval)
|
||||||
elif args.cmd == "test":
|
elif args.cmd == "test":
|
||||||
return run_tests(bus)
|
return run_tests(bus)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
return 130
|
return 130
|
||||||
|
except ExceptionResponse as exc:
|
||||||
|
return f"the node refused the request: {exc}"
|
||||||
except ProtocolError as exc:
|
except ProtocolError as exc:
|
||||||
return f"protocol error: {exc}"
|
return f"protocol error: {exc}"
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user