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