Files
wiredsensor/tools/wiredsensor.py
T
Oliver Walter 5b9c09df3b Speak Modbus RTU instead of a custom command protocol
ESPHome only has built-in support for Modbus RTU over RS485, so the
application layer moves to it. The transport already conformed: the CRC
was CRC-16/MODBUS, the line rate 19200 8N1, the delimiter a 3.5-character
idle gap pinned at 1750 us above 19200, and the address space 1..247 with
broadcast never answered. Only the bytes between the address and the CRC
change, and no external crate is needed for a read-only server.

frame.rs drops the explicit LEN byte, which RTU does not have — a
request's length is implied by its function code. The parser gets simpler
and MAX_FRAME grows to the RTU limit of 256. The CRC is now the only
integrity check there is, so a truncated frame fails it rather than a
length comparison; a test asserts every single-bit corruption is still
caught, and another pins our encoding against the published example
01 03 00 6B 00 03 74 17.

proto.rs becomes a Modbus server: function codes 0x03 and 0x04 over one
19-register table, 0x08 sub-function 0x0000 as the link test that
replaces PING, and the four standard exception codes. Registers are
big-endian with 32-bit values high word first, which is what masters and
ESPHome's S_DWORD assume. Snapshot::registers renders the whole map and
reads slice it, so a range read cannot disagree with a single-register
read of the same address — the alternative, assembling only the requested
registers per read, has no such guarantee.

Both read function codes serve the same table. That is not only for
masters that implement just 0x03: a read overlapping the measurement
block is refused with SERVER_DEVICE_FAILURE when the sensor has never
produced a reading, and ESPHome coalesces adjacent registers of one type
into a single command, so diagnostic entities merged into that command
would go unavailable along with the measurement they were meant to
explain. Requesting them under the other function code puts them in their
own command. The generated ESPHome config does this.

The firmware barely changes, because dispatch's signature does not: flags
widen to u16, buffers follow MAX_FRAME, and comments name registers
rather than commands. Now ~35 KiB flash and ~2.6 KiB RAM.

tools/wiredsensor.py is rewritten and grows from 15 checks to 21, adding
agreement between the two function codes, sub-range consistency, and a
per-code assertion for every exception. It stays a hand-written Modbus
implementation rather than moving to pymodbus: half these checks inject
deliberately malformed frames, and a conforming client library exists
precisely to make those unconstructable. It also keeps the wire format
independent of wiredsensor-core, so a shared bug cannot cancel itself
out.

Not yet exercised on hardware — the host tests and register-map
cross-checks pass, but the timing-dependent behaviour needs
`wiredsensor.py test` against a real bus.

This replaces the old protocol rather than joining it; command codes
0x01..0x04 are all valid Modbus function codes, so the two cannot share a
segment. PROTOCOL_VERSION is 2.

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

844 lines
29 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 tx_pin/rx_pin to whatever your RS485 transceiver is wired to.
uart:
id: rs485
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: {baud}
data_bits: 8
parity: NONE
stop_bits: 1
modbus:
id: rs485_bus
uart_id: rs485
modbus_controller:
- id: wiredsensor
address: {unit}
modbus_id: rs485_bus
update_interval: 30s
sensor:
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Temperature"
register_type: read # function code 0x04
address: 0x0000
value_type: S_DWORD
unit_of_measurement: "°C"
device_class: temperature
accuracy_decimals: 2
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Humidity"
register_type: read
address: 0x0002
value_type: S_DWORD
unit_of_measurement: "%"
device_class: humidity
accuracy_decimals: 2
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Reading age"
register_type: read
address: 0x0004
value_type: U_WORD
unit_of_measurement: "ms"
entity_category: diagnostic
# The health flags deliberately use `holding` (function code 0x03) even though
# they are the same registers. ESPHome coalesces adjacent registers of the same
# register_type into one command, and a read overlapping 0x0000..0x0004 is
# refused outright when the sensor has never produced a reading. Asking for the
# flags under the other function code puts them in their own command, so they
# still report when the measurement cannot — which is exactly when you want them.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Sensor OK"
register_type: holding
address: 0x0005
bitmask: 0x01
entity_category: diagnostic
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Data stale"
register_type: holding
address: 0x0005
bitmask: 0x02
entity_category: diagnostic
- platform: modbus_controller
modbus_controller_id: wiredsensor
name: "Sensor fault"
register_type: holding
address: 0x0005
bitmask: 0x04
entity_category: diagnostic
"""
# --------------------------------------------------------------------- CLI ---
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())