Files
Oliver Walter c49fff9faf Add ESPHome example configurations
The README carried a fragment, which is enough to show the shape but not
enough to flash. These are complete configs:

  wiredsensor.yaml      one node, every measurement and diagnostic register
  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, and the expanded config was
checked register by register rather than only for schema validity — a
wrong address or value_type validates perfectly and then reports a
plausible but wrong temperature, which is the failure mode worth
guarding against.

Two things the fragment in the README was missing and a real config
cannot be:

flow_control_pin on the modbus component. Without it the ESP32 never
asserts DE, so nothing reaches the segment and the node looks dead. The
generated fragment now carries it too.

The read/holding split for diagnostics. ESPHome merges adjacent registers
of one register_type into a single command, and a read overlapping
0x0000..0x0004 is refused when the sensor has never produced a reading —
so diagnostics merged into the measurement command go unavailable exactly
when they are needed. Asking for them as `holding` puts them in their own
command.

Serials are text_sensors with raw_encode: HEXBYTES rather than numeric
sensors, because Home Assistant stores states as float32 and 24 bits of
mantissa cannot hold a 32-bit serial. Uptime has the same limit and is
left numeric with a note; quantising past 194 days does not matter for
spotting a reboot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:28:08 +02:00

852 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""Modbus RTU master and end-to-end test suite for the wiredsensor node.
This is deliberately an **independent** implementation of Modbus RTU. The
framing and CRC below were written from the specification rather than sharing
code with `wiredsensor-core`. If both ends shared an implementation, a bug in it
would cancel out and every test here would pass regardless — so the duplication
is the point, not an oversight.
It is hand-written rather than built on pymodbus for one concrete reason: half
these checks inject *deliberately malformed* frames — a bad CRC, a truncated
frame, a frame for another unit — and assert the node stays silent. A conforming
client library exists precisely to make those frames unconstructable. Use
pymodbus to cross-check the happy path against a third-party stack; use this to
verify the node behaves on a bus that is misbehaving.
Talks to the bus through the USB-to-RS485 bridge firmware (`bridge/`), which is
protocol-agnostic and just moves bytes.
./wiredsensor.py --port /dev/ttyACM0 measure
./wiredsensor.py --port /dev/ttyACM0 monitor
./wiredsensor.py --port /dev/ttyACM0 test
"""
from __future__ import annotations
import argparse
import struct
import sys
import time
from dataclasses import dataclass
try:
import serial
except ImportError:
sys.exit("pyserial is required: pip install pyserial")
# ---------------------------------------------------------------- protocol ---
ADDR_BROADCAST = 0x00
ADDR_MIN, ADDR_MAX = 0x01, 0xF7
HEADER_LEN = 2 # ADDR, FC
CRC_LEN = 2
MIN_FRAME = HEADER_LEN + CRC_LEN
MAX_FRAME = 256 # the RTU limit
MAX_DATA = MAX_FRAME - HEADER_LEN - CRC_LEN
# Each host write becomes exactly one DE-bracketed transmission by the bridge,
# and a USB full-speed bulk packet is 64 bytes, so a request must fit in one.
MAX_BRIDGE_FRAME = 64
FC_READ_HOLDING = 0x03
FC_READ_INPUT = 0x04
FC_DIAGNOSTIC = 0x08
DIAG_RETURN_QUERY_DATA = 0x0000
EXCEPTION_FLAG = 0x80
EXCEPTION_NAMES = {
0x01: "ILLEGAL_FUNCTION",
0x02: "ILLEGAL_DATA_ADDRESS",
0x03: "ILLEGAL_DATA_VALUE",
0x04: "SERVER_DEVICE_FAILURE",
}
MAX_READ_REGISTERS = 125
# ------------------------------------------------------------ register map ---
REG_TEMP_MILLI_C = 0x0000 # 2 registers, signed
REG_RH_MILLI_PCT = 0x0002 # 2 registers, signed
REG_AGE_MS = 0x0004
REG_FLAGS = 0x0005
REG_SENSOR_STATUS = 0x0006
REG_I2C_ERRORS = 0x0007
REG_SENSOR_CRC_ERRORS = 0x0008
REG_FRAME_ERRORS = 0x0009
REG_CRC_ERRORS = 0x000A
REG_FW_MAJOR_MINOR = 0x000B
REG_FW_PATCH_PROTO = 0x000C
REG_DEVICE_SERIAL = 0x000D # 2 registers
REG_SENSOR_SERIAL = 0x000F # 2 registers
REG_UPTIME_S = 0x0011 # 2 registers
REG_COUNT = 0x0013
REG_MEASUREMENT_END = 0x0005
FLAG_NAMES = [
(1 << 0, "SENSOR_OK"),
(1 << 1, "DATA_STALE"),
(1 << 2, "SENSOR_FAULT"),
(1 << 3, "UART_ERROR"),
(1 << 4, "EVER_MEASURED"),
(1 << 5, "SENSOR_RESET_SEEN"),
(1 << 6, "HEATER_ON"),
]
REGISTER_NAMES = {
REG_TEMP_MILLI_C: "temp_milli_c (hi)",
REG_TEMP_MILLI_C + 1: "temp_milli_c (lo)",
REG_RH_MILLI_PCT: "rh_milli_pct (hi)",
REG_RH_MILLI_PCT + 1: "rh_milli_pct (lo)",
REG_AGE_MS: "age_ms",
REG_FLAGS: "flags",
REG_SENSOR_STATUS: "sensor_status",
REG_I2C_ERRORS: "i2c_errors",
REG_SENSOR_CRC_ERRORS: "sensor_crc_errors",
REG_FRAME_ERRORS: "frame_errors",
REG_CRC_ERRORS: "crc_errors",
REG_FW_MAJOR_MINOR: "fw_major_minor",
REG_FW_PATCH_PROTO: "fw_patch_proto",
REG_DEVICE_SERIAL: "device_serial (hi)",
REG_DEVICE_SERIAL + 1: "device_serial (lo)",
REG_SENSOR_SERIAL: "sensor_serial (hi)",
REG_SENSOR_SERIAL + 1: "sensor_serial (lo)",
REG_UPTIME_S: "uptime_s (hi)",
REG_UPTIME_S + 1: "uptime_s (lo)",
}
def crc16(data: bytes) -> int:
"""CRC-16/MODBUS: reflected poly 0xA001, init 0xFFFF, no final XOR."""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
def build_frame(addr: int, fc: int, data: bytes = b"") -> bytes:
"""An RTU ADU: address, function code, data, CRC low byte first."""
if len(data) > MAX_DATA:
raise ValueError(f"data field of {len(data)} exceeds {MAX_DATA}")
body = bytes([addr, fc]) + data
return body + struct.pack("<H", crc16(body))
def read_request(start: int, count: int) -> bytes:
"""The data field of a register read: start and count, both big-endian."""
return struct.pack(">HH", start, count)
@dataclass
class Frame:
addr: int
fc: int
data: bytes
@property
def is_exception(self) -> bool:
return bool(self.fc & EXCEPTION_FLAG)
@property
def exception_name(self) -> str | None:
if not self.is_exception or len(self.data) != 1:
return None
return EXCEPTION_NAMES.get(self.data[0], f"unknown({self.data[0]:#04x})")
class ProtocolError(Exception):
"""The bytes on the wire were not a usable response."""
class ExceptionResponse(Exception):
"""The node answered, and the answer was a Modbus exception."""
def __init__(self, fc: int, code: int):
self.fc = fc
self.code = code
name = EXCEPTION_NAMES.get(code, "unknown")
super().__init__(f"function {fc:#04x} -> {name} ({code:#04x})")
def parse_frame(raw: bytes) -> Frame:
"""Validate a received ADU.
There is no length field to check: in RTU the frame is delimited by the idle
line and the CRC is the only integrity check there is.
"""
if len(raw) < MIN_FRAME:
raise ProtocolError(f"short frame: {len(raw)} bytes ({raw.hex(' ')})")
if len(raw) > MAX_FRAME:
raise ProtocolError(f"long frame: {len(raw)} bytes")
body, tail = raw[:-CRC_LEN], raw[-CRC_LEN:]
expected = struct.unpack("<H", tail)[0]
actual = crc16(body)
if actual != expected:
raise ProtocolError(f"CRC {actual:#06x} != {expected:#06x} ({raw.hex(' ')})")
return Frame(raw[0], raw[1], 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 ---
@dataclass
class Measurement:
temp_c: float
rh_pct: float
age_ms: int
@classmethod
def decode(cls, regs: list[int], base: int = REG_TEMP_MILLI_C) -> Measurement:
if len(regs) < 5:
raise ProtocolError(f"expected 5 measurement registers, got {len(regs)}")
return cls(
reg_i32(regs, REG_TEMP_MILLI_C, base) / 1000.0,
reg_i32(regs, REG_RH_MILLI_PCT, base) / 1000.0,
regs[REG_AGE_MS - base],
)
def __str__(self) -> str:
return f"{self.temp_c:+.3f} °C {self.rh_pct:.3f} %RH age {self.age_ms} ms"
@dataclass
class Info:
fw: tuple[int, int, int]
proto_version: int
device_serial: int
sensor_serial: int
uptime_s: int
@classmethod
def decode(cls, regs: list[int], base: int = REG_FW_MAJOR_MINOR) -> Info:
if len(regs) < REG_COUNT - REG_FW_MAJOR_MINOR:
raise ProtocolError(f"expected 8 info registers, got {len(regs)}")
major_minor = regs[REG_FW_MAJOR_MINOR - base]
patch_proto = regs[REG_FW_PATCH_PROTO - base]
return cls(
(major_minor >> 8, major_minor & 0xFF, patch_proto >> 8),
patch_proto & 0xFF,
reg_u32(regs, REG_DEVICE_SERIAL, base),
reg_u32(regs, REG_SENSOR_SERIAL, base),
reg_u32(regs, REG_UPTIME_S, base),
)
def __str__(self) -> str:
return (
f"fw {self.fw[0]}.{self.fw[1]}.{self.fw[2]} proto v{self.proto_version} "
f"device_serial {self.device_serial:#010x} "
f"sensor_serial {self.sensor_serial:#010x} uptime {self.uptime_s} s"
)
@dataclass
class Status:
flags: int
sensor_status: int
i2c_errors: int
sensor_crc_errors: int
frame_errors: int
crc_errors: int
@classmethod
def decode(cls, regs: list[int], base: int = REG_FLAGS) -> Status:
if len(regs) < REG_FW_MAJOR_MINOR - REG_FLAGS:
raise ProtocolError(f"expected 6 status registers, got {len(regs)}")
return cls(
regs[REG_FLAGS - base],
regs[REG_SENSOR_STATUS - base],
regs[REG_I2C_ERRORS - base],
regs[REG_SENSOR_CRC_ERRORS - base],
regs[REG_FRAME_ERRORS - base],
regs[REG_CRC_ERRORS - base],
)
@property
def flag_names(self) -> list[str]:
return [name for bit, name in FLAG_NAMES if self.flags & bit]
def __str__(self) -> str:
return (
f"flags {self.flags:#06x} [{' '.join(self.flag_names) or '-'}] "
f"sht_status {self.sensor_status:#06x} "
f"err i2c={self.i2c_errors} sht_crc={self.sensor_crc_errors} "
f"frame={self.frame_errors} bus_crc={self.crc_errors}"
)
# ------------------------------------------------------------------ master ---
class Bus:
"""A Modbus master on the segment, reached through the USB-RS485 bridge."""
def __init__(self, port: str, unit: int = 0x01, verbose: bool = False):
# The bridge is a CDC device, so the baud rate here is ignored; the RS485
# line rate is fixed in the bridge firmware.
self.ser = serial.Serial(port, 115200, timeout=0)
self.unit = unit
self.verbose = verbose
time.sleep(0.05)
self.ser.reset_input_buffer()
def close(self) -> None:
self.ser.close()
def send_raw(self, raw: bytes) -> None:
"""Put bytes on the bus verbatim, bypassing frame construction.
One write becomes exactly one transmission by the bridge, which is what
lets us inject deliberately malformed frames.
"""
if self.verbose:
print(f" TX {raw.hex(' ')}", file=sys.stderr)
self.ser.reset_input_buffer()
self.ser.write(raw)
self.ser.flush()
def read_raw(self, timeout: float = 0.5, gap: float = 0.02) -> bytes:
"""Collect a response, using an idle gap to decide it is complete.
Mirrors how the node itself delimits frames, so a response arriving in
several USB chunks is still reassembled into one frame.
"""
deadline = time.monotonic() + timeout
buf = bytearray()
last = None
while time.monotonic() < deadline:
waiting = self.ser.in_waiting
if waiting:
buf.extend(self.ser.read(waiting))
last = time.monotonic()
elif buf and last is not None and time.monotonic() - last >= gap:
break
else:
time.sleep(0.001)
if self.verbose and buf:
print(f" RX {bytes(buf).hex(' ')}", file=sys.stderr)
return bytes(buf)
def expect_raw(self, fc: int, data: bytes = b"", addr: int | None = None) -> bytes:
"""Send a request and return whatever bytes come back, if any."""
self.send_raw(build_frame(self.unit if addr is None else addr, fc, data))
return self.read_raw()
def request(self, fc: int, data: bytes = b"", addr: int | None = None) -> Frame:
"""Send a request and return the parsed response, raising if none comes."""
raw = self.expect_raw(fc, data, addr)
if not raw:
raise ProtocolError(f"no response to function {fc:#04x}")
return parse_frame(raw)
def transact(self, fc: int, data: bytes = b"") -> Frame:
"""As `request`, but turn an exception response into a Python exception."""
response = self.request(fc, data)
if response.addr != self.unit:
raise ProtocolError(
f"response from {response.addr:#04x} != {self.unit:#04x}"
)
if response.is_exception:
if response.fc != fc | EXCEPTION_FLAG:
raise ProtocolError(
f"exception on function {response.fc & ~EXCEPTION_FLAG:#04x}, "
f"asked {fc:#04x}"
)
raise ExceptionResponse(fc, response.data[0])
if response.fc != fc:
raise ProtocolError(f"response fc {response.fc:#04x} != request {fc:#04x}")
return response
def read_registers(
self, start: int, count: int, fc: int = FC_READ_INPUT
) -> list[int]:
regs = decode_registers(self.transact(fc, read_request(start, count)).data)
if len(regs) != count:
raise ProtocolError(f"asked for {count} registers, got {len(regs)}")
return regs
def diagnostic(self, payload: bytes) -> bytes:
"""Return Query Data: the node echoes the data field back unchanged."""
data = struct.pack(">H", DIAG_RETURN_QUERY_DATA) + payload
return self.transact(FC_DIAGNOSTIC, data).data[2:]
def measurement(self, fc: int = FC_READ_INPUT) -> Measurement:
return Measurement.decode(
self.read_registers(REG_TEMP_MILLI_C, REG_MEASUREMENT_END, fc)
)
def status(self, fc: int = FC_READ_INPUT) -> Status:
count = REG_FW_MAJOR_MINOR - REG_FLAGS
return Status.decode(self.read_registers(REG_FLAGS, count, fc))
def info(self, fc: int = FC_READ_INPUT) -> Info:
count = REG_COUNT - REG_FW_MAJOR_MINOR
return Info.decode(self.read_registers(REG_FW_MAJOR_MINOR, count, fc))
def all_registers(self, fc: int = FC_READ_INPUT) -> list[int]:
return self.read_registers(0, REG_COUNT, fc)
# ------------------------------------------------------------------- tests ---
class Results:
def __init__(self) -> None:
self.passed = 0
self.failed: list[tuple[str, str]] = []
def check(self, name: str, fn) -> None:
try:
detail = fn()
self.passed += 1
print(f" \033[32mPASS\033[0m {name}" + (f"{detail}" if detail else ""))
except Exception as exc: # noqa: BLE001 - a failed probe is a test result
self.failed.append((name, str(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:
"""Exercise the parts of the protocol only a real bus can verify.
Framing, CRC arithmetic and the register map are already covered by host
unit tests in `core/`. What cannot be tested off-hardware is everything
timing-dependent: that the node delimits frames by an idle gap, that it
stays off the wire for traffic it must not answer, and that its
driver-enable turnaround leaves the response intact.
"""
r = Results()
print("\nidentity and readings")
def t_info():
info = bus.info()
assert info.proto_version == 2, f"protocol version {info.proto_version} != 2"
assert info.sensor_serial != 0, "sensor serial is zero — SHT31 not identified"
return str(info)
r.check("read identity registers", t_info)
def t_measure():
m = bus.measurement()
assert -40 <= m.temp_c <= 125, f"temperature {m.temp_c} outside SHT31 range"
assert 0 <= m.rh_pct <= 100, f"humidity {m.rh_pct} outside 0..100"
assert m.age_ms <= 3000, f"reading is {m.age_ms} ms old"
return str(m)
r.check("read measurement registers", t_measure)
def t_status():
s = bus.status()
assert s.flags & 0x10, "EVER_MEASURED not set"
assert not s.flags & 0x04, "SENSOR_FAULT is set"
return str(s)
r.check("read diagnostic registers", t_status)
print("\nregister map consistency")
def t_both_function_codes():
# A master that only implements 0x03 must see exactly what one using
# 0x04 sees. The two are served from one table precisely so they agree.
holding = bus.all_registers(FC_READ_HOLDING)
# Skip the volatile registers: age and uptime move between the two reads.
volatile = {REG_AGE_MS, REG_UPTIME_S, REG_UPTIME_S + 1}
inputs = bus.all_registers(FC_READ_INPUT)
for i, (h, n) in enumerate(zip(holding, inputs)):
if i in volatile:
continue
assert h == n, f"register {i} ({REGISTER_NAMES.get(i, '?')}): {h} != {n}"
return f"0x03 and 0x04 agree across {len(holding)} registers"
r.check("holding and input registers agree", t_both_function_codes)
def t_subrange():
# ESPHome coalesces adjacent registers into one read, so a sub-range must
# return the same values as the same addresses read individually.
whole = bus.all_registers()
for start, count in [
(REG_FLAGS, 1),
(REG_SENSOR_STATUS, 4),
(REG_DEVICE_SERIAL, 2),
(REG_FLAGS, REG_COUNT - REG_FLAGS),
]:
part = bus.read_registers(start, count)
expect = whole[start : start + count]
assert part == expect, f"start {start} count {count}: {part} != {expect}"
return "4 sub-ranges match the whole-map read"
r.check("sub-range reads are consistent", t_subrange)
print("\nround trip integrity")
def t_diag_binary():
# Bytes that would need escaping in any delimiter-based protocol.
probe = bytes([0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E, 0x55, 0xAA])
echo = bus.diagnostic(probe)
assert echo == probe, f"echo {echo.hex(' ')} != sent {probe.hex(' ')}"
return f"{len(probe)} bytes binary-transparent"
r.check("diagnostic echoes arbitrary bytes", t_diag_binary)
def t_diag_long():
# A 64-byte frame: the largest the bridge can send as a single USB packet
# and therefore as a single transmission. Well past the 16-byte RX FIFO
# watermark, which is the case the frame-gap logic gets wrong if the
# deadline is measured from the ISR's timestamp.
payload = bytes(range(MAX_BRIDGE_FRAME - MIN_FRAME - 2))
echo = bus.diagnostic(payload)
assert echo == payload, f"echo of {len(echo)} bytes != sent {len(payload)}"
return f"{len(payload)}-byte payload, {MAX_BRIDGE_FRAME}-byte frame"
r.check("diagnostic echoes a maximum single-packet frame", t_diag_long)
def t_long_response():
# The response side of the same concern: 19 registers is a 43-byte
# response, comfortably past the watermark.
regs = bus.all_registers()
assert len(regs) == REG_COUNT, f"got {len(regs)} registers"
return f"{REG_COUNT} registers in one {5 + 2 * REG_COUNT}-byte response"
r.check("whole-map read returns a long response intact", t_long_response)
def t_back_to_back():
# Catches state leaking between frames — a stale receive buffer or a
# gap-detection flag left set would show up here and nowhere else.
for i in range(20):
probe = bytes([i, 0xA5 ^ i])
echo = bus.diagnostic(probe)
assert echo == probe, f"iteration {i}: {echo.hex(' ')} != {probe.hex(' ')}"
return "20 consecutive requests, no state leak"
r.check("back-to-back requests", t_back_to_back)
print("\nsilence where silence is required")
def t_other_unit():
other = 0x02 if bus.unit != 0x02 else 0x03
raw = bus.expect_raw(FC_READ_INPUT, read_request(0, 5), addr=other)
assert not raw, f"answered a frame addressed to {other:#04x}: {raw.hex(' ')}"
return f"ignored a frame for unit {other:#04x}"
r.check("frame for another unit is ignored", t_other_unit)
def t_broadcast():
raw = bus.expect_raw(FC_READ_INPUT, read_request(0, 5), addr=ADDR_BROADCAST)
assert not raw, f"answered a broadcast: {raw.hex(' ')}"
return "ignored the broadcast address"
r.check("broadcast is not answered", t_broadcast)
def t_bad_crc():
frame = bytearray(build_frame(bus.unit, FC_READ_INPUT, read_request(0, 5)))
frame[-1] ^= 0xFF
bus.send_raw(bytes(frame))
raw = bus.read_raw()
assert not raw, f"answered a frame with a bad CRC: {raw.hex(' ')}"
return "ignored a corrupted frame"
r.check("bad CRC is ignored", t_bad_crc)
def t_truncated():
# With no length field, a frame one byte short can only be caught by the
# CRC — which is exactly what must happen here.
frame = build_frame(bus.unit, FC_READ_INPUT, read_request(0, 5))[:-1]
bus.send_raw(frame)
raw = bus.read_raw()
assert not raw, f"answered a truncated frame: {raw.hex(' ')}"
return "ignored a truncated frame"
r.check("truncated frame is ignored", t_truncated)
print("\nexception responses")
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)
r.check("unimplemented function is refused", t_unknown_fc)
def t_past_end():
return expect_exception(bus, FC_READ_INPUT, read_request(REG_COUNT, 1), 0x02)
r.check("read past the map is ILLEGAL_DATA_ADDRESS", t_past_end)
def t_straddles_end():
data = read_request(REG_COUNT - 1, 2)
return expect_exception(bus, FC_READ_INPUT, data, 0x02)
r.check("read straddling the end is refused", t_straddles_end)
def t_zero_count():
return expect_exception(bus, FC_READ_INPUT, read_request(0, 0), 0x03)
r.check("zero register count is ILLEGAL_DATA_VALUE", t_zero_count)
def t_huge_count():
data = read_request(0, MAX_READ_REGISTERS + 1)
return expect_exception(bus, FC_READ_INPUT, data, 0x03)
r.check("oversized register count is ILLEGAL_DATA_VALUE", t_huge_count)
def t_short_request():
return expect_exception(bus, FC_READ_INPUT, b"\x00\x00", 0x03)
r.check("misshaped request is ILLEGAL_DATA_VALUE", t_short_request)
def t_unknown_subfunction():
return expect_exception(bus, FC_DIAGNOSTIC, b"\x00\x0a", 0x01)
r.check("unknown diagnostic sub-function is refused", t_unknown_subfunction)
print("\ndiagnostic counters")
def t_crc_counter():
before = bus.status().crc_errors
bad = bytearray(build_frame(bus.unit, FC_READ_INPUT, read_request(0, 5)))
bad[-1] ^= 0xFF
bus.send_raw(bytes(bad))
bus.read_raw(timeout=0.15)
after = bus.status().crc_errors
assert after == before + 1, f"crc_errors went {before} -> {after}, expected +1"
return f"crc_errors {before} -> {after}"
r.check("a corrupt frame increments crc_errors", t_crc_counter)
total = r.passed + len(r.failed)
print(f"\n{r.passed}/{total} passed")
if r.failed:
print("\nfailures:")
for name, why in r.failed:
print(f" {name}: {why}")
return 1
return 0
# ----------------------------------------------------------------- esphome ---
ESPHOME_YAML = """\
# wiredsensor node at unit address {unit} — paste into your ESPHome config and
# set the pins to whatever your RS485 transceiver is wired to.
#
# This is the bus and entity fragment only. For a complete flashable config,
# including the board, wifi and every diagnostic register, see
# examples/esphome/wiredsensor.yaml.
uart:
id: rs485
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: {baud}
data_bits: 8
parity: NONE
stop_bits: 1
modbus:
id: rs485_bus
uart_id: rs485
# Driver enable, wired to DE and /RE together. Delete this if your
# transceiver switches direction itself; without it on a module that does
# not, nothing you send reaches the segment and the node looks dead.
flow_control_pin: GPIO4
modbus_controller:
- id: wiredsensor
address: {unit}
modbus_id: rs485_bus
update_interval: 30s
sensor:
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Temperature"
register_type: read # function code 0x04
address: 0x0000
value_type: S_DWORD
unit_of_measurement: "°C"
device_class: temperature
accuracy_decimals: 2
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Humidity"
register_type: read
address: 0x0002
value_type: S_DWORD
unit_of_measurement: "%"
device_class: humidity
accuracy_decimals: 2
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Reading age"
register_type: read
address: 0x0004
value_type: U_WORD
unit_of_measurement: "ms"
entity_category: diagnostic
# The health flags deliberately use `holding` (function code 0x03) even though
# they are the same registers. ESPHome coalesces adjacent registers of the same
# register_type into one command, and a read overlapping 0x0000..0x0004 is
# refused outright when the sensor has never produced a reading. Asking for the
# flags under the other function code puts them in their own command, so they
# still report when the measurement cannot — which is exactly when you want them.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Sensor OK"
register_type: holding
address: 0x0005
bitmask: 0x01
entity_category: diagnostic
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Data stale"
register_type: holding
address: 0x0005
bitmask: 0x02
entity_category: diagnostic
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Sensor fault"
register_type: holding
address: 0x0005
bitmask: 0x04
entity_category: diagnostic
"""
# --------------------------------------------------------------------- CLI ---
def main() -> int | str:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", default="/dev/ttyACM0", help="bridge serial port")
ap.add_argument(
"--unit", type=lambda s: int(s, 0), default=0x01, help="node address"
)
ap.add_argument("-v", "--verbose", action="store_true", help="dump bus traffic")
ap.add_argument(
"--holding",
action="store_const",
const=FC_READ_HOLDING,
default=FC_READ_INPUT,
dest="fc",
help="use 0x03 Read Holding Registers instead of 0x04 Read Input Registers",
)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("measure", help="read temperature and humidity once")
sub.add_parser("info", help="read firmware and serial numbers")
sub.add_parser("status", help="read health flags and counters")
sub.add_parser("registers", help="dump the whole register map")
sub.add_parser("test", help="run the end-to-end test suite")
sub.add_parser("esphome", help="print a ready-to-paste ESPHome config")
p_diag = sub.add_parser("diag", help="Return Query Data echo test")
p_diag.add_argument("--bytes", type=int, default=8, help="payload length")
p_mon = sub.add_parser("monitor", help="poll continuously")
p_mon.add_argument(
"--interval", type=float, default=1.0, help="seconds between polls"
)
args = ap.parse_args()
# Needs no bus at all, so it works before any hardware is wired up.
if args.cmd == "esphome":
print(ESPHOME_YAML.format(unit=args.unit, baud=19200))
return 0
try:
bus = Bus(args.port, args.unit, args.verbose)
except serial.SerialException as exc:
return f"cannot open {args.port}: {exc}"
try:
if args.cmd == "measure":
print(bus.measurement(args.fc))
elif args.cmd == "info":
print(bus.info(args.fc))
elif args.cmd == "status":
print(bus.status(args.fc))
elif args.cmd == "registers":
for i, v in enumerate(bus.all_registers(args.fc)):
name = REGISTER_NAMES.get(i, "")
print(f" {i:#06x} {v:#06x} {v:>6} {name}")
elif args.cmd == "diag":
probe = bytes(i & 0xFF for i in range(args.bytes))
echo = bus.diagnostic(probe)
print("echo ok" if echo == probe else f"MISMATCH: {echo.hex(' ')}")
elif args.cmd == "monitor":
while True:
try:
print(f"{time.strftime('%H:%M:%S')} {bus.measurement(args.fc)}")
except (ProtocolError, ExceptionResponse) as exc:
print(f"{time.strftime('%H:%M:%S')} {exc}")
time.sleep(args.interval)
elif args.cmd == "test":
return run_tests(bus)
except KeyboardInterrupt:
return 130
except ExceptionResponse as exc:
return f"the node refused the request: {exc}"
except ProtocolError as exc:
return f"protocol error: {exc}"
finally:
bus.close()
return 0
if __name__ == "__main__":
sys.exit(main())