diff --git a/README.md b/README.md index 4240237..d7be38a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Written in Rust on **RTIC 2** + `rp2040-hal`. | `firmware/`| `wiredsensor-fw` — the RTIC application, drivers and register access. | | `bridge/` | `wiredsensor-bridge` — a spare RP2040 as a USB-to-RS485 bridge, for testing. | | `tools/` | `wiredsensor.py` — PC-side Modbus master and end-to-end test suite. | +| `examples/`| ESPHome configurations, one node and a whole segment. | The `core`/`firmware` split exists so the entire protocol can be exercised by ordinary host unit tests (`cargo test`) with no hardware and no emulator. `core` @@ -238,8 +239,17 @@ register map at all. ## ESPHome `modbus_controller` reads this node with no custom component and no external -library. `tools/wiredsensor.py esphome` prints a complete config for a given unit -address; the essentials are: +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: @@ -309,8 +319,13 @@ binary_sensor: ``` This is what serving both function codes from one table buys, beyond mere -compatibility. `tools/wiredsensor.py esphome` generates a config already set up -this way. +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 diff --git a/examples/esphome/bus-of-nodes.yaml b/examples/esphome/bus-of-nodes.yaml new file mode 100644 index 0000000..604f86d --- /dev/null +++ b/examples/esphome/bus-of-nodes.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" diff --git a/examples/esphome/wiredsensor-node.yaml b/examples/esphome/wiredsensor-node.yaml new file mode 100644 index 0000000..a0276d3 --- /dev/null +++ b/examples/esphome/wiredsensor-node.yaml @@ -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 diff --git a/examples/esphome/wiredsensor.yaml b/examples/esphome/wiredsensor.yaml new file mode 100644 index 0000000..19cd783 --- /dev/null +++ b/examples/esphome/wiredsensor.yaml @@ -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 diff --git a/tools/wiredsensor.py b/tools/wiredsensor.py index 9b77b6c..0679b8a 100755 --- a/tools/wiredsensor.py +++ b/tools/wiredsensor.py @@ -669,7 +669,11 @@ def run_tests(bus: Bus) -> int: ESPHOME_YAML = """\ # wiredsensor node at unit address {unit} — paste into your ESPHome config and -# set the tx_pin/rx_pin to whatever your RS485 transceiver is wired to. +# 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 @@ -682,6 +686,10 @@ uart: 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