10 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 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
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 f2d06278af Replace the plain status LED with a WS2812B on PIO
The boards this targets carry an addressable LED on GPIO16 rather than a
plain LED, so the GPIO25 heartbeat had nothing to drive. Green means
running, blue flashes on each measurement, red means the sensor is
failing, with the most urgent condition winning. A healthy node reads as
dim green with a blue pip once a second, so a stalled or failing one is
obvious with no serial port attached.

Driven from PIO directly rather than via ws2812-pio, whose current
release pins rp2040-hal 0.11 against this project's 0.12. Those are
semver incompatible: cargo would build both HALs and the driver would not
accept our PIO, state machine or pin types. The alternative was pinning
the whole project back a HAL version, for four instructions of PIO.

Generating the waveform in the state machine matters beyond convenience.
The bus tasks preempt the LED task freely, and a CPU-timed WS2812 would
glitch the moment a frame arrived mid-update. The >50 us latch gap needs
no explicit delay either, since repaints are 25 ms apart.

Byte order is a named constant rather than two transposed shifts, because
it is a property of the part fitted and not of the protocol: many parts
sold as WS2812B, along with the WS2811 and SK6812 families, want red
first. These do. The unused ChannelOrder variant stays so the choice, and
the fix if a board differs, is legible.

The task derives everything from existing shared state — the reading's
own timestamp already says when the last measurement landed — so nothing
is published purely to drive an LED.

Colours confirmed on hardware after correcting the channel order; bus
suite still 15/15 and sensor counters clean, so PIO0 disturbs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:26:14 +02:00
Oliver Walter 46c26796cc Define the line rate once, in wiredsensor-core
BAUD_RATE was declared separately in firmware/src/board.rs and
bridge/src/main.rs with a comment asking that they be kept in agreement
by hand. A baud mismatch between two ends of an RS485 segment is silent
and presents as random CRC failures, which is a poor thing to debug and a
poor thing to leave to a comment.

It now lives in wiredsensor_core::timing alongside the arithmetic that
derives from it, because the line rate is a property of the segment
rather than of any one board. board.rs re-exports it, and the bridge
takes a dependency on core purely to read it — it still knows nothing
about the protocol itself.

INTER_FRAME_GAP_US and CHAR_TIME_US move with it and are now derived at
the definition site, with a test asserting they cannot go stale if the
rate changes.

Also fills two documentation gaps: the bridge and PC test suite were
committed without any README coverage, and the account of the RTIM
pitfall still described the incomplete understanding held before the
hardware test found the real mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:51:57 +02:00
Oliver Walter 25dba97c06 Stop miscounting SHT31 bring-up and status-read failures
Two ways the error counters lied about sensor health.

The first measurement failed on every boot, leaving a permanent
i2c_errors=1 that a master polling READ_STATUS could not tell apart from
a real fault. The sensor NACKs a command arriving while it is still busy
with the previous one, and the bring-up sequence issued a measurement
immediately after clear_status with no quiet time. Commands are now
spaced by sht3x::COMMAND_SETTLE_MS. Bring-up failures stay uncounted on
purpose: the counters describe operational health, and an unreadable
serial already surfaces as a zero in READ_INFO.

read_status() failures were discarded via `if let Ok`, which made a
persistently unreadable status register indistinguishable from a
genuinely clean one — both leave sensor_status at zero. They are now
counted, but kept out of the consecutive-failure tally so only the
measurement path drives the fault and soft-reset logic.

Confirmed on hardware: a fresh boot now holds err[i2c=0 sht_crc=0
frame=0 bus_crc=0] indefinitely, where it previously showed i2c=1 from
the first poll onwards. After a full bus test run the counters read
exactly frame=2 bus_crc=2, accounting for the four faults the suite
injects deliberately and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:41:07 +02:00
Oliver Walter aa48d8784f Fix silent truncation of frames longer than the RX FIFO watermark
Every frame of 16 bytes or more was rejected as LengthMismatch and
silently dropped. Found by the new end-to-end suite; neither the host
tests nor the sensor verification could reach it, because it needs real
bus timing and a frame past the watermark.

The watermark interrupt fires mid-frame once 16 bytes are in the FIFO.
The ISR drained those, stamped last_byte_at, and started frame_gap. The
remaining bytes then sat *below* the watermark and raised no interrupt at
all until the PL011's receive timeout eventually fired, so frame_gap
measured its 1822 us gap from a mid-frame timestamp, concluded the frame
had ended while its tail was still arriving, and parsed a prefix whose
LEN field disagreed with its length.

Frames under 16 bytes never trip the watermark, so their only interrupt
is the receive timeout, by which point every byte is drained and the
timestamp is honest. That is why all five real commands worked and only
an oversized PING exposed it.

frame_gap now drains the FIFO itself before each decision rather than
trusting a timestamp the ISR left behind, so a byte that has landed can
always push the deadline back. The gap is then measured from when the
byte was observed rather than when it arrived, making the reply up to one
gap period later than strictly necessary; 1.8 ms is a fair price.

Verified on hardware across every payload size from 0 to 59 — frames of
5 to 64 bytes — with the node's error counters accounting exactly for the
faults the suite injects deliberately and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:32:23 +02:00
Oliver Walter ddfda14085 Add USB-RS485 bridge and PC-side test suite
Makes end-to-end verification of the bus possible with a spare RP2040 and
no dedicated RS485 dongle.

bridge/ turns a second Pico into a transparent USB-to-RS485 bridge. It is
deliberately protocol-agnostic: bytes from USB go out on the pair, bytes
from the pair go back up USB, and it knows nothing about frames,
addresses or CRCs. A protocol-aware bridge could have a bug that happens
to agree with the node's own, which would defeat the point of using it as
a test instrument. It also needs no register-level code — it never has to
delimit a frame, and the one thing it does need, knowing that the final
stop bit has cleared the shifter before releasing DE, rp2040-hal exposes
as uart_is_busy().

tools/wiredsensor.py is an independent implementation of the wire format,
written from the specification in README.md rather than sharing code with
wiredsensor-core. Shared code would let a framing or CRC bug cancel out
and every test pass regardless. Cross-checked: it reproduces the
CRC-16/MODBUS catalogue vector and builds byte-identical frames to the
Rust side for all five documented examples.

Its `test` subcommand covers what host tests structurally cannot, all of
it timing-dependent: that malformed and mis-addressed frames leave the
node silent rather than colliding with whoever was actually addressed,
that the driver-enable turnaround leaves replies intact, and that no
state leaks between back-to-back frames.

Uses one wiring diagram for both boards, since the bridge mirrors the
node's GPIO0/1/2 assignment. Frames must fit one 64-byte USB packet, as
each host write becomes exactly one DE-bracketed transmission; every real
command is at most 21 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:31:30 +02:00
Oliver Walter bcc6869965 Add USB CDC diagnostics, verified against real hardware
Presents a USB CDC serial port alongside the RS485 protocol, so the node
can be verified with nothing but a USB cable — no SWD probe and no RS485
adapter. Motivated by having neither available: probe-rs needs SWD and
defmt logs travel over RTT, so with only the USB bootloader attached
there was no way to observe the sensor at all.

Two tasks, both at the lowest priority so they can never delay a bus
reply:

- usb_irq (binds USBCTRL_IRQ) services the stack and drains the RX
  buffer, which has to happen even though the port is output-only.
- usb_report emits one diagnostic line per second.

The report deliberately exposes raw internal state rather than the
protocol's flag encoding: "is the sensor working and what does it read"
is a different question from what a bus master asks.

Writes discard whatever does not fit rather than blocking, so an
unattended port cannot stall the firmware. The USB bus allocator lives
in an #[init(local = ...)] resource, which supplies the 'static the
device and class borrow from without a static mut.

Verified on hardware. The SHT31 reports serial 0x2d5ac752, read from its
factory serial register and CRC-8 validated, with readings of 27.3 C and
43.5 %RH varying by a few LSB between samples — the noise floor of a live
16-bit conversion rather than a stuck cache.

Costs ~17 KiB of flash and ~1.3 KiB of RAM, taking the image to ~34.5 KiB
of 2 MiB and ~2.5 KiB of 256 KiB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:32:52 +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