6 Commits

Author SHA1 Message Date
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 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