3 Commits

Author SHA1 Message Date
Oliver Walter 131ae200ee Add an ESPHome gateway config for the ESP32-S3-Relay-6CH
The USB-RS485 bridge lets a PC drive a segment from tools/wiredsensor.py,
which is a bench arrangement: it needs a host awake and attached. A
Waveshare ESP32-S3-Relay-6CH does the same master role permanently and
brings six relays of its own, so one board both reads the node and
switches things in response.

Every pin is fixed by the board and was read off its schematic, so they
are written literally rather than as substitutions — a substitution would
imply a choice that does not exist. Relays sit on GPIO1/2/41/42/45/46,
the WS2812 on GPIO38, the buzzer on GPIO21 and RS485 on GPIO17/18.

Two of those deserve their comments. GPIO45 and GPIO46 are strapping
pins, and GPIO45 selects the flash supply voltage: the internal pulldown
gives 3.3V at reset and the pin is released back to it even with the
relay energised, but anything external holding it high across a reset
selects 1.8V and the module will not boot at all. The strapping warnings
from `esphome config` are left unsuppressed for that reason — channels 5
and 6 can also click during reset, so the warning is not crying wolf.

The `modbus:` component sets no flow_control_pin, unlike the other
configs here. This board derives the SP485EE's driver-enable from the TX
line with a 74HC04D, a 54K/1nF RC and a diode, so there is no DE GPIO to
name; stated in a comment because an absent option reads as an oversight.

flash_size is 16MB, read off the chip with `esptool flash-id` rather than
taken from the schematic, which names an ESP32-S3-WROOM-1U with no memory
suffix. The logger goes to USB_SERIAL_JTAG because the Type-C is wired
straight to native USB with no bridge chip, and the default hardware UART
would log to pins this board does not break out.

The node's entities are inline rather than from wiredsensor-node.yaml,
which creates its own modbus_controller and would leave two of them
polling address 0x01.

Verified on hardware: the gateway reports 23.624 C and 43.35 %RH with
zero bus CRC errors while the node's own USB diagnostics independently
show 23.72 C and 43.04 %RH over the same window, drifting in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:02:06 +02:00
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
Oliver Walter 93b4e2c4f8 Initial commit: RP2040 RS485 temperature/humidity node
RTIC 2 firmware for an RP2040 acting as an RS485 slave, reading a
Sensirion SHT31 over I2C and answering a custom binary protocol.

Split into two crates so the protocol is testable without hardware:

- wiredsensor-core: framing, CRC-16/MODBUS, command dispatch and SHT3x
  data-sheet math. Pure computation, no I/O, no peripherals. Covered by
  42 host tests including the CRC-16/MODBUS catalogue vector, the SHT3x
  data-sheet CRC-8 example, and an exhaustive single-bit-corruption
  sweep over every byte of a frame.
- wiredsensor-fw: the RTIC application, PL011 register driver and SHT31
  I2C driver.

Requests are answered entirely from a cached reading. An SHT31
high-repeatability conversion takes up to 15 ms, well past the
turnaround a master expects, so the sensor is polled at the lowest
priority and the bus path never touches I2C. RTIC's priority ceilings
make "a conversion cannot delay a reply" a checked property rather than
an argument.

Two PL011 details drive the low-level approach, since rp2040-hal exposes
neither: the receive-timeout interrupt for prompt frame delimiting, and
the BUSY flag, which is the only way to know the final stop bit has left
the shift register before releasing DE. The HAL performs the fiddly
baud-divisor setup once, then the raw peripheral is reclaimed via free().

Note that RTIM cannot be the sole frame delimiter: it only fires while
the RX FIFO is non-empty, so a frame landing exactly on the FIFO
watermark is drained by the watermark interrupt and never times out. The
authoritative delimiter is a monotonic timer measured from the last
received byte.

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