Compare commits
3 Commits
f2d06278af
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 131ae200ee | |||
| c49fff9faf | |||
| 5b9c09df3b |
@@ -1 +1,10 @@
|
|||||||
/target
|
/target
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# ESPHome. Unanchored so they hold anywhere in the tree, not just next to the
|
||||||
|
# configs: secrets.yaml carries wifi credentials and the API/OTA keys, and
|
||||||
|
# .esphome is build state (including .pioenvs). examples/esphome/.gitignore says
|
||||||
|
# the same thing directory-locally because ESPHome regenerates it on every run;
|
||||||
|
# these entries are the ones that matter if a config is ever built elsewhere.
|
||||||
|
secrets.yaml
|
||||||
|
.esphome/
|
||||||
|
|||||||
@@ -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,11 @@ 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. |
|
||||||
|
| `examples/`| ESPHome configurations, one node and a whole segment. |
|
||||||
|
|
||||||
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 +104,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 +184,148 @@ 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. Complete, flashable configs live in `examples/esphome/`:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|------|------------|
|
||||||
|
| `wiredsensor.yaml` | one node, with every measurement and diagnostic exposed. Start here. |
|
||||||
|
| `wiredsensor-node.yaml` | one node as a reusable package, parameterised by address |
|
||||||
|
| `bus-of-nodes.yaml` | three nodes on one segment, via that package |
|
||||||
|
|
||||||
|
All three validate against ESPHome 2026.7. `tools/wiredsensor.py esphome` prints
|
||||||
|
the same thing for an arbitrary unit address if you would rather generate it. The
|
||||||
|
essentials are:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: GPIO17 # whatever your transceiver is wired to
|
||||||
|
rx_pin: GPIO16
|
||||||
|
baud_rate: 19200 # must match core/src/timing.rs
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: wiredsensor
|
||||||
|
address: 0x01 # must match board.rs UNIT_ADDRESS
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Temperature"
|
||||||
|
register_type: read # ESPHome's name for function code 0x04
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001 # the node reports milli-degrees
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things must agree between the two ends, and all three fail silently or
|
||||||
|
confusingly if they do not:
|
||||||
|
|
||||||
|
- **Baud rate.** `19200` here against `BAUD_RATE` in `core/src/timing.rs`. A
|
||||||
|
mismatch presents as random CRC failures.
|
||||||
|
- **Unit address.** `address:` here against `UNIT_ADDRESS` in
|
||||||
|
`firmware/src/board.rs`. A mismatch presents as a total silence that looks
|
||||||
|
identical to a wiring fault.
|
||||||
|
- **Value type.** `S_DWORD` with `multiply: 0.001`, not `S_WORD`. Reading half of
|
||||||
|
a 32-bit pair yields a plausible-looking but wrong number rather than an error.
|
||||||
|
|
||||||
|
ESPHome coalesces adjacent registers of the same `register_type` into a single
|
||||||
|
command, which the map is laid out for: temperature, humidity and age are
|
||||||
|
contiguous, so all three arrive in one transaction.
|
||||||
|
|
||||||
|
That coalescing has one consequence worth planning around. A read overlapping
|
||||||
|
`0x0000`..`0x0004` is refused with `SERVER_DEVICE_FAILURE` when the sensor has
|
||||||
|
never produced a reading, so any entity ESPHome merges into that command goes
|
||||||
|
unavailable with it — including, if you are not careful, the very flags that would
|
||||||
|
tell you why. The fix is to ask for the diagnostic registers under the *other*
|
||||||
|
function code: they are the same table, but a different `register_type` puts them
|
||||||
|
in their own command.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
binary_sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor OK"
|
||||||
|
register_type: holding # 0x03, so this is a separate command from the
|
||||||
|
address: 0x0005 # 0x04 read that carries the measurement
|
||||||
|
bitmask: 0x01
|
||||||
|
entity_category: diagnostic
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what serving both function codes from one table buys, beyond mere
|
||||||
|
compatibility. Both `examples/esphome/wiredsensor.yaml` and the generated config
|
||||||
|
are already set up this way.
|
||||||
|
|
||||||
|
One more wiring detail the YAML has to get right: **`flow_control_pin` on the
|
||||||
|
`modbus:` component**, unless your transceiver switches direction itself. Without
|
||||||
|
it the ESP32 never asserts DE and nothing you send reaches the segment, which
|
||||||
|
looks exactly like a dead node.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -260,14 +356,15 @@ and no way for a bus glitch to renumber a live node. The cost is one image per
|
|||||||
unit, so keep `UNIT_ADDRESS` and `DEVICE_SERIAL` the only things that differ.
|
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 +379,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 +437,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 +484,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 +513,10 @@ host tests and the sensor verification were structurally incapable of reaching,
|
|||||||
because it needed real bus timing *and* a frame longer than the RX FIFO
|
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 +572,16 @@ Worse: once the watermark interrupt has fired mid-frame — at 16 bytes, with th
|
|||||||
FIFO 32 deep — the *remaining* bytes sit below the watermark and raise no
|
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 +617,5 @@ wait. The alternative has the sensor hold SCL for up to 15 ms, blocking the bus
|
|||||||
and making the transfer duration depend on the controller's stretch timeout.
|
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.
|
||||||
|
|
||||||
|
|||||||
+509
-267
@@ -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 {
|
fc::DIAGNOSTIC => diagnostic(out, unit, f.data),
|
||||||
Some(m) => {
|
_ => exception(out, unit, f.fc, exception::ILLEGAL_FUNCTION),
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd::READ_INFO => {
|
|
||||||
if !f.payload.is_empty() {
|
|
||||||
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
|
|
||||||
}
|
|
||||||
let mut p = [0u8; Info::LEN];
|
|
||||||
snap.info.encode(&mut p);
|
|
||||||
reply(out, unit, f.cmd, &p)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd::READ_STATUS => {
|
|
||||||
if !f.payload.is_empty() {
|
|
||||||
return error(out, unit, f.cmd, err::ILLEGAL_LENGTH);
|
|
||||||
}
|
|
||||||
let mut p = [0u8; Status::LEN];
|
|
||||||
snap.status.encode(&mut p);
|
|
||||||
reply(out, unit, f.cmd, &p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Echo verbatim. Proves the full round trip — framing, CRC and RS485
|
|
||||||
// driver-enable turnaround — without involving the sensor at all.
|
|
||||||
cmd::PING => reply(out, unit, f.cmd, f.payload),
|
|
||||||
|
|
||||||
cmd::SET_ADDRESS => error(out, unit, f.cmd, err::UNSUPPORTED),
|
|
||||||
|
|
||||||
_ => error(out, unit, f.cmd, err::ILLEGAL_COMMAND),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
for data in [&[][..], &[0, 0][..], &[0, 0, 0, 1][..], &[0xFF; 8][..]] {
|
||||||
let mut req = [0u8; MAX_FRAME];
|
let mut req = [0u8; MAX_FRAME];
|
||||||
let n = frame::encode(UNIT, cmd, &[], &mut req).unwrap();
|
let n = frame::encode(UNIT, fc, data, &mut req).unwrap();
|
||||||
let mut out = [0u8; MAX_FRAME];
|
let mut out = [0u8; MAX_FRAME];
|
||||||
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
|
if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) {
|
||||||
let f = Frame::parse(r).expect("emitted an unparseable reply");
|
let f = Frame::parse(r).expect("emitted an unparseable response");
|
||||||
assert_eq!(f.addr, UNIT);
|
assert_eq!(f.addr, UNIT);
|
||||||
|
// Either the request's own function code, or that code with
|
||||||
|
// the exception flag set. Nothing else is a legal response.
|
||||||
|
assert!(
|
||||||
|
f.fc == fc || f.fc == fc | EXCEPTION_FLAG,
|
||||||
|
"function code {fc:#04x} answered as {:#04x}",
|
||||||
|
f.fc
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Gitignore settings for ESPHome
|
||||||
|
# This is an example and may include too much for your use-case.
|
||||||
|
# You can modify this file to suit your needs.
|
||||||
|
/.esphome/
|
||||||
|
/secrets.yaml
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Several wiredsensor nodes on one RS485 segment, read by a single ESP32.
|
||||||
|
#
|
||||||
|
# The point of a bus: one gateway, one pair of wires, N sensors. Each node needs
|
||||||
|
# a distinct UNIT_ADDRESS, which means one firmware image per node — see the
|
||||||
|
# Configuration section of the top-level README.
|
||||||
|
#
|
||||||
|
# esphome run bus-of-nodes.yaml
|
||||||
|
#
|
||||||
|
# The per-node entities live in wiredsensor-node.yaml and are instantiated once
|
||||||
|
# per address below, so adding a node is three lines rather than a copied block.
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
name: rs485-gateway
|
||||||
|
friendly_name: "RS485 gateway"
|
||||||
|
baud_rate: "19200"
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
de_pin: GPIO4
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
friendly_name: ${friendly_name}
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_encryption_key
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
password: !secret ota_password
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
ap: {}
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the bus ---
|
||||||
|
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: ${tx_pin}
|
||||||
|
rx_pin: ${rx_pin}
|
||||||
|
baud_rate: ${baud_rate}
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
flow_control_pin: ${de_pin}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- nodes ---
|
||||||
|
#
|
||||||
|
# One entry per node. `node_id` must be unique; `node_addr` must match that
|
||||||
|
# node's UNIT_ADDRESS.
|
||||||
|
#
|
||||||
|
# Requests are serialised across the whole bus — ESPHome waits for each response
|
||||||
|
# before sending the next — so poll cost scales with node count. A node answers
|
||||||
|
# roughly 2 ms after the request ends, so a dozen nodes at 30 s is nowhere near
|
||||||
|
# saturating the segment. If you do see timeouts, `command_throttle` on the
|
||||||
|
# controllers is the knob, not `update_interval`.
|
||||||
|
|
||||||
|
packages:
|
||||||
|
hallway: !include
|
||||||
|
file: wiredsensor-node.yaml
|
||||||
|
vars:
|
||||||
|
node_id: node_hallway
|
||||||
|
node_addr: "0x01"
|
||||||
|
node_name: "Hallway"
|
||||||
|
|
||||||
|
cellar: !include
|
||||||
|
file: wiredsensor-node.yaml
|
||||||
|
vars:
|
||||||
|
node_id: node_cellar
|
||||||
|
node_addr: "0x02"
|
||||||
|
node_name: "Cellar"
|
||||||
|
|
||||||
|
greenhouse: !include
|
||||||
|
file: wiredsensor-node.yaml
|
||||||
|
vars:
|
||||||
|
node_id: node_greenhouse
|
||||||
|
node_addr: "0x03"
|
||||||
|
node_name: "Greenhouse"
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# The Waveshare ESP32-S3-Relay-6CH as a permanent RS485 gateway: it polls the
|
||||||
|
# wiredsensor node over Modbus RTU and exposes its own six relays, buzzer and
|
||||||
|
# WS2812 status LED.
|
||||||
|
#
|
||||||
|
# esphome run relay-6ch-gateway.yaml
|
||||||
|
#
|
||||||
|
# This board takes the USB-RS485 bridge's place on the segment. The bridge exists
|
||||||
|
# so a PC can drive the bus from tools/wiredsensor.py; a gateway is the same
|
||||||
|
# master role made permanent, so the two are alternatives rather than a stack —
|
||||||
|
# only one device may drive the segment at a time.
|
||||||
|
#
|
||||||
|
# Every pin below is fixed by the board and was read off its schematic
|
||||||
|
# (files.waveshare.com/wiki/ESP32-S3-Relay-6CH/ESP32-S3-Relay-6CH.pdf). They are
|
||||||
|
# not configurable, so they are written literally rather than as substitutions —
|
||||||
|
# a substitution would imply a choice that does not exist.
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
name: relay-6ch-gateway
|
||||||
|
friendly_name: "Relay gateway"
|
||||||
|
|
||||||
|
# Must match UNIT_ADDRESS in firmware/src/board.rs. A mismatch presents as
|
||||||
|
# total silence, indistinguishable from a wiring fault.
|
||||||
|
node_address: "0x01"
|
||||||
|
|
||||||
|
# Must match BAUD_RATE in core/src/timing.rs. A mismatch is silent and shows up
|
||||||
|
# as random CRC failures rather than as an error.
|
||||||
|
baud_rate: "19200"
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
friendly_name: ${friendly_name}
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32-s3-devkitc-1
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
# Read off the chip with `esptool flash-id`, not taken from the schematic —
|
||||||
|
# which names an ESP32-S3-WROOM-1U with no memory suffix. The part fitted here
|
||||||
|
# is 16MB (winbond c8/4018), well above ESPHome's 4MB default; leaving the
|
||||||
|
# default would work but would strand three quarters of the flash and cap the
|
||||||
|
# OTA partition.
|
||||||
|
flash_size: 16MB
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: INFO
|
||||||
|
# The Type-C connector is wired straight to the ESP32-S3's native USB (GPIO19
|
||||||
|
# and GPIO20, the fixed D-/D+ pins), and there is no UART bridge chip. Logs
|
||||||
|
# therefore have to go over USB-Serial-JTAG; the default hardware UART would
|
||||||
|
# send them to pins this board does not break out, and the log would appear
|
||||||
|
# to be silently broken.
|
||||||
|
hardware_uart: USB_SERIAL_JTAG
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_encryption_key
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
password: !secret ota_password
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
ap: {}
|
||||||
|
# The WROOM-1U is the external-antenna variant: it has a U.FL connector and no
|
||||||
|
# PCB antenna. Without an antenna fitted it will associate from a metre away
|
||||||
|
# and nowhere else, which reads as a flaky access point rather than as missing
|
||||||
|
# hardware.
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the bus ---
|
||||||
|
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO18
|
||||||
|
baud_rate: ${baud_rate}
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
# Deliberately no `flow_control_pin`. This board derives the SP485EE's
|
||||||
|
# driver-enable from the TX line itself — a 74HC04D inverter with a 54K/1nF RC
|
||||||
|
# and a diode — so DE asserts on the first start bit and releases an RC delay
|
||||||
|
# after the last stop bit. There is no DE GPIO to name. Stated explicitly
|
||||||
|
# because the other configs here do set one, and its absence is the single
|
||||||
|
# thing that differs about this board.
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: wiredsensor
|
||||||
|
address: ${node_address}
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
# The node caches a reading every second and reports its age, so polling
|
||||||
|
# faster than that gains nothing.
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- the node ---
|
||||||
|
#
|
||||||
|
# The node's entities are defined inline rather than pulled from
|
||||||
|
# wiredsensor-node.yaml. That package creates its own `modbus_controller`, so
|
||||||
|
# including it here would leave two controllers polling address 0x01 — hence the
|
||||||
|
# duplication, which is the single-node tradeoff wiredsensor.yaml makes too. Use
|
||||||
|
# the package instead of this section only if you move to several nodes, and drop
|
||||||
|
# the `modbus_controller` above when you do.
|
||||||
|
#
|
||||||
|
# This is a curated subset. For the full diagnostic set — every error counter,
|
||||||
|
# all seven flag bits, both serials — copy the `sensor:`, `binary_sensor:` and
|
||||||
|
# `text_sensor:` blocks out of wiredsensor.yaml; the register map is identical
|
||||||
|
# and they attach to the `wiredsensor` controller above unchanged.
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Temperature"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
state_class: measurement
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Humidity"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0002
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
device_class: humidity
|
||||||
|
state_class: measurement
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
# Bus health. A segment with a termination or biasing problem shows up as a
|
||||||
|
# steadily climbing CRC count long before a reading is actually lost, which is
|
||||||
|
# the whole reason to graph it from the gateway.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Bus CRC errors"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x000A
|
||||||
|
value_type: U_WORD
|
||||||
|
state_class: total_increasing
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
# ---- the gateway's own health ----
|
||||||
|
- platform: uptime
|
||||||
|
name: "Gateway uptime"
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: wifi_signal
|
||||||
|
name: "Gateway WiFi signal"
|
||||||
|
update_interval: 60s
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
# On `holding` (0x03) rather than `read` (0x04) deliberately. A read
|
||||||
|
# overlapping 0x0000..0x0004 is refused outright when the sensor has never
|
||||||
|
# produced a reading, and ESPHome merges adjacent same-type registers into one
|
||||||
|
# command — so asking for the flags as `read` would let a dead sensor take down
|
||||||
|
# the very diagnostic that explains it. The node serves both function codes
|
||||||
|
# from one table precisely so there is a way out.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor fault"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x04
|
||||||
|
device_class: problem
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Data stale"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x02
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- the relays ---
|
||||||
|
#
|
||||||
|
# Six independent SPDT relays, each driven from the module pin through a 0R link
|
||||||
|
# resistor. Active high: the GPIO drives the coil transistor, so ON energises the
|
||||||
|
# coil and closes NO.
|
||||||
|
#
|
||||||
|
# `restore_mode: ALWAYS_OFF` throughout rather than a restoring mode. These are
|
||||||
|
# mains-side contacts; coming back from a power cut into whatever state preceded
|
||||||
|
# it is a decision worth making per relay, not inheriting by default.
|
||||||
|
|
||||||
|
switch:
|
||||||
|
- platform: gpio
|
||||||
|
name: "Relay 1"
|
||||||
|
id: relay_1
|
||||||
|
pin: GPIO1
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
- platform: gpio
|
||||||
|
name: "Relay 2"
|
||||||
|
id: relay_2
|
||||||
|
pin: GPIO2
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
- platform: gpio
|
||||||
|
name: "Relay 3"
|
||||||
|
id: relay_3
|
||||||
|
pin: GPIO41
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
- platform: gpio
|
||||||
|
name: "Relay 4"
|
||||||
|
id: relay_4
|
||||||
|
pin: GPIO42
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
# GPIO45 and GPIO46 are ESP32-S3 strapping pins — VDD_SPI voltage select and
|
||||||
|
# boot mode respectively, both sampled at reset with internal pulldowns. They
|
||||||
|
# are ordinary outputs once booted, so driving relays from them is fine, but
|
||||||
|
# channels 5 and 6 may click during reset before the firmware takes over. Do
|
||||||
|
# not put anything on them where a momentary close at power-up matters.
|
||||||
|
#
|
||||||
|
# GPIO45 is the sharper of the two: it selects the flash supply voltage, and
|
||||||
|
# this module's flash is 3.3V. The internal pulldown gives the right answer at
|
||||||
|
# reset (esptool confirms "flash voltage set by a strapping pin: 3.3V"), and the
|
||||||
|
# pin is released back to it while the CPU is in reset even if the relay was
|
||||||
|
# energised. But anything external that holds GPIO45 high across a reset — a
|
||||||
|
# bench pullup, a scope probe, a wiring error on CH5 — selects 1.8V and the
|
||||||
|
# module will not boot at all.
|
||||||
|
- platform: gpio
|
||||||
|
name: "Relay 5"
|
||||||
|
id: relay_5
|
||||||
|
pin: GPIO45
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
- platform: gpio
|
||||||
|
name: "Relay 6"
|
||||||
|
id: relay_6
|
||||||
|
pin: GPIO46
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
# ------------------------------------------------------- indicator and buzzer ---
|
||||||
|
|
||||||
|
light:
|
||||||
|
- platform: esp32_rmt_led_strip
|
||||||
|
id: status_led
|
||||||
|
name: "Status LED"
|
||||||
|
pin: GPIO38
|
||||||
|
num_leds: 1
|
||||||
|
chipset: WS2812
|
||||||
|
rgb_order: GRB
|
||||||
|
default_transition_length: 0s
|
||||||
|
restore_mode: ALWAYS_OFF
|
||||||
|
|
||||||
|
output:
|
||||||
|
# Driven through LEDC because the buzzer is passive and needs a tone rather
|
||||||
|
# than a level. If yours is an active buzzer, this still works — any non-zero
|
||||||
|
# level sounds it — but `rtttl` melodies will all sound the same pitch.
|
||||||
|
- platform: ledc
|
||||||
|
id: buzzer_out
|
||||||
|
pin: GPIO21
|
||||||
|
|
||||||
|
rtttl:
|
||||||
|
id: buzzer
|
||||||
|
output: buzzer_out
|
||||||
|
|
||||||
|
button:
|
||||||
|
# Bench check for the buzzer and the LED, so a freshly flashed board can be
|
||||||
|
# confirmed without touching the mains side or the bus.
|
||||||
|
- platform: template
|
||||||
|
name: "Self test"
|
||||||
|
entity_category: diagnostic
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: status_led
|
||||||
|
red: 0%
|
||||||
|
green: 100%
|
||||||
|
blue: 0%
|
||||||
|
- rtttl.play: "test:d=16,o=6,b=140:c,e,g"
|
||||||
|
- delay: 1s
|
||||||
|
- light.turn_off: status_led
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# One wiredsensor node, as a reusable ESPHome package.
|
||||||
|
#
|
||||||
|
# Not flashable on its own — it defines no board, no wifi and no bus. It expects
|
||||||
|
# a parent config to supply a `modbus:` component with id `rs485_bus`, and to
|
||||||
|
# include this file once per node with `vars`. See bus-of-nodes.yaml.
|
||||||
|
#
|
||||||
|
# Required vars:
|
||||||
|
# node_id — a unique slug, used for the modbus_controller id
|
||||||
|
# node_addr — the node's RS485 unit address, matching its UNIT_ADDRESS
|
||||||
|
# node_name — human-readable prefix for the entity names
|
||||||
|
#
|
||||||
|
# Only the two measurement entities are exposed here. Per-node copies of every
|
||||||
|
# diagnostic counter multiply fast on a segment of a dozen nodes, so
|
||||||
|
# wiredsensor.yaml keeps the full set for the single-node case and this package
|
||||||
|
# stays deliberately lean. Add what you need — the register map is identical.
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: ${node_id}
|
||||||
|
address: ${node_addr}
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: ${node_id}
|
||||||
|
name: "${node_name} temperature"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
state_class: measurement
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: ${node_id}
|
||||||
|
name: "${node_name} humidity"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0002
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
device_class: humidity
|
||||||
|
state_class: measurement
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
# On `holding` (0x03) rather than `read` (0x04) so a node whose sensor has
|
||||||
|
# never produced a reading still reports *why* — see wiredsensor.yaml for the
|
||||||
|
# full explanation.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: ${node_id}
|
||||||
|
name: "${node_name} sensor fault"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x04
|
||||||
|
device_class: problem
|
||||||
|
entity_category: diagnostic
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# A complete ESPHome configuration for one wiredsensor node on an RS485
|
||||||
|
# segment. Flashable as-is once the substitutions below and your secrets are
|
||||||
|
# filled in:
|
||||||
|
#
|
||||||
|
# esphome run wiredsensor.yaml
|
||||||
|
#
|
||||||
|
# Nothing here is wiredsensor-specific beyond the register addresses — the node
|
||||||
|
# is an ordinary Modbus RTU server, so `modbus_controller` reads it with no
|
||||||
|
# custom component and no external library.
|
||||||
|
#
|
||||||
|
# For several nodes on one bus, see bus-of-nodes.yaml.
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
name: wiredsensor-gateway
|
||||||
|
friendly_name: "Wiredsensor gateway"
|
||||||
|
|
||||||
|
# Must match UNIT_ADDRESS in firmware/src/board.rs. A mismatch presents as
|
||||||
|
# total silence, indistinguishable from a wiring fault.
|
||||||
|
node_address: "0x01"
|
||||||
|
|
||||||
|
# Must match BAUD_RATE in core/src/timing.rs. A mismatch is silent and shows
|
||||||
|
# up as random CRC failures rather than as an error.
|
||||||
|
baud_rate: "19200"
|
||||||
|
|
||||||
|
# RS485 transceiver wiring. GPIO16/17 are UART2's defaults on the ESP32, but
|
||||||
|
# they are wired to PSRAM on WROVER modules — pick different pins there.
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
# Driver enable, wired to DE and /RE together. Delete `flow_control_pin`
|
||||||
|
# below if your transceiver handles direction itself (MAX13487 and most
|
||||||
|
# "automatic" breakout modules).
|
||||||
|
de_pin: GPIO4
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
friendly_name: ${friendly_name}
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
# The RS485 port is UART2, so the log can stay on UART0. Set `baud_rate: 0`
|
||||||
|
# if you ever move RS485 onto UART0.
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_encryption_key
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
password: !secret ota_password
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
ap: {}
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the bus ---
|
||||||
|
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: ${tx_pin}
|
||||||
|
rx_pin: ${rx_pin}
|
||||||
|
baud_rate: ${baud_rate}
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
# Asserted while transmitting, released afterwards. The node holds its own DE
|
||||||
|
# until the final stop bit has left the shift register; yours must too, which
|
||||||
|
# is what this option does.
|
||||||
|
flow_control_pin: ${de_pin}
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: wiredsensor
|
||||||
|
address: ${node_address}
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
# The node caches a reading every second and reports its age, so polling
|
||||||
|
# faster than that gains nothing. 30s is plenty for room temperature.
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ sensors ---
|
||||||
|
#
|
||||||
|
# Two groups, on two different function codes, and the split is deliberate.
|
||||||
|
#
|
||||||
|
# ESPHome merges adjacent registers of the same register_type into one command.
|
||||||
|
# A read overlapping 0x0000..0x0004 is refused outright when the sensor has
|
||||||
|
# never produced a reading, so anything merged into the measurement command goes
|
||||||
|
# unavailable along with it — including, without this split, the very
|
||||||
|
# diagnostics that would tell you why. The node serves both 0x03 and 0x04 from
|
||||||
|
# one register table precisely so there is a way out: asking for the
|
||||||
|
# diagnostics as `holding` puts them in their own command, where a dead sensor
|
||||||
|
# cannot take them down.
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
# ---- measurements, function code 0x04 (Read Input Registers) -------------
|
||||||
|
#
|
||||||
|
# Temperature and humidity are 32-bit signed milli-units spanning two
|
||||||
|
# registers each, high word first — ESPHome's S_DWORD. Using S_WORD here
|
||||||
|
# would read half the value and report a plausible but wrong number rather
|
||||||
|
# than failing, so this is the one field worth double-checking.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Temperature"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
state_class: measurement
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Humidity"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0002
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
device_class: humidity
|
||||||
|
state_class: measurement
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
# How old the cached reading was when the node answered. The node reports this
|
||||||
|
# rather than enforcing a freshness policy of its own, so the decision about
|
||||||
|
# what staleness matters is yours.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Reading age"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0004
|
||||||
|
value_type: U_WORD
|
||||||
|
unit_of_measurement: "ms"
|
||||||
|
state_class: measurement
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
# ---- diagnostics, function code 0x03 (Read Holding Registers) ------------
|
||||||
|
#
|
||||||
|
# All counters are monotonic since power-up, so `total_increasing` lets Home
|
||||||
|
# Assistant show a rate. A segment with a termination or biasing problem shows
|
||||||
|
# up here as a steadily climbing CRC error count long before any reading is
|
||||||
|
# actually lost — which is the whole reason to graph them.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "I2C errors"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0007
|
||||||
|
value_type: U_WORD
|
||||||
|
state_class: total_increasing
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor CRC errors"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0008
|
||||||
|
value_type: U_WORD
|
||||||
|
state_class: total_increasing
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Bus frame errors"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0009
|
||||||
|
value_type: U_WORD
|
||||||
|
state_class: total_increasing
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Bus CRC errors"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x000A
|
||||||
|
value_type: U_WORD
|
||||||
|
state_class: total_increasing
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
# The low byte of 0x000C. `bitmask` on a sensor masks and then shifts down by
|
||||||
|
# the mask's trailing zeros, so this yields the protocol version on its own.
|
||||||
|
# Worth surfacing: it is the one value that tells you a node is running
|
||||||
|
# firmware older than the register map this config assumes.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Protocol version"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x000C
|
||||||
|
value_type: U_WORD
|
||||||
|
bitmask: 0x00FF
|
||||||
|
entity_category: diagnostic
|
||||||
|
skip_updates: 60
|
||||||
|
|
||||||
|
# Saturating 32-bit seconds. Home Assistant stores sensor states as float32,
|
||||||
|
# which carries 24 bits of mantissa, so this quantises past ~194 days of
|
||||||
|
# uptime. Fine for spotting an unexpected reboot, which is what it is for.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Uptime"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0011
|
||||||
|
value_type: U_DWORD
|
||||||
|
unit_of_measurement: "s"
|
||||||
|
device_class: duration
|
||||||
|
state_class: total_increasing
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
# The flag bits of register 0x0005, on `holding` for the reason above.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor OK"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x01
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Data stale"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x02
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor fault"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x04
|
||||||
|
device_class: problem
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Bus line error"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x08
|
||||||
|
device_class: problem
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor reset seen"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x20
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Heater on"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x40
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
text_sensor:
|
||||||
|
# 32-bit serials as hex. A plain sensor would store these as a float and lose
|
||||||
|
# the low bits, for the same 24-bit mantissa reason as uptime above.
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Board serial"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x000D
|
||||||
|
register_count: 2
|
||||||
|
raw_encode: HEXBYTES
|
||||||
|
entity_category: diagnostic
|
||||||
|
disabled_by_default: true
|
||||||
|
skip_updates: 60
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "SHT31 serial"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x000F
|
||||||
|
register_count: 2
|
||||||
|
raw_encode: HEXBYTES
|
||||||
|
entity_category: diagnostic
|
||||||
|
disabled_by_default: true
|
||||||
|
skip_updates: 60
|
||||||
@@ -75,7 +75,7 @@ use wiredsensor_core::{sht3x, timing};
|
|||||||
/// that differs between them.
|
/// 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.
+469
-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,134 @@ 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 pins to whatever your RS485 transceiver is wired to.
|
||||||
|
#
|
||||||
|
# This is the bus and entity fragment only. For a complete flashable config,
|
||||||
|
# including the board, wifi and every diagnostic register, see
|
||||||
|
# examples/esphome/wiredsensor.yaml.
|
||||||
|
uart:
|
||||||
|
id: rs485
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
baud_rate: {baud}
|
||||||
|
data_bits: 8
|
||||||
|
parity: NONE
|
||||||
|
stop_bits: 1
|
||||||
|
|
||||||
|
modbus:
|
||||||
|
id: rs485_bus
|
||||||
|
uart_id: rs485
|
||||||
|
# Driver enable, wired to DE and /RE together. Delete this if your
|
||||||
|
# transceiver switches direction itself; without it on a module that does
|
||||||
|
# not, nothing you send reaches the segment and the node looks dead.
|
||||||
|
flow_control_pin: GPIO4
|
||||||
|
|
||||||
|
modbus_controller:
|
||||||
|
- id: wiredsensor
|
||||||
|
address: {unit}
|
||||||
|
modbus_id: rs485_bus
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Temperature"
|
||||||
|
register_type: read # function code 0x04
|
||||||
|
address: 0x0000
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
device_class: temperature
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Humidity"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0002
|
||||||
|
value_type: S_DWORD
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
device_class: humidity
|
||||||
|
accuracy_decimals: 2
|
||||||
|
filters:
|
||||||
|
- multiply: 0.001
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Reading age"
|
||||||
|
register_type: read
|
||||||
|
address: 0x0004
|
||||||
|
value_type: U_WORD
|
||||||
|
unit_of_measurement: "ms"
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
# The health flags deliberately use `holding` (function code 0x03) even though
|
||||||
|
# they are the same registers. ESPHome coalesces adjacent registers of the same
|
||||||
|
# register_type into one command, and a read overlapping 0x0000..0x0004 is
|
||||||
|
# refused outright when the sensor has never produced a reading. Asking for the
|
||||||
|
# flags under the other function code puts them in their own command, so they
|
||||||
|
# still report when the measurement cannot — which is exactly when you want them.
|
||||||
|
binary_sensor:
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor OK"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x01
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Data stale"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x02
|
||||||
|
entity_category: diagnostic
|
||||||
|
|
||||||
|
- platform: modbus_controller
|
||||||
|
modbus_controller_id: wiredsensor
|
||||||
|
name: "Sensor fault"
|
||||||
|
register_type: holding
|
||||||
|
address: 0x0005
|
||||||
|
bitmask: 0x04
|
||||||
|
entity_category: diagnostic
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- CLI ---
|
# --------------------------------------------------------------------- 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 +801,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 +813,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