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>
This commit is contained in:
Oliver Walter
2026-07-28 20:51:57 +02:00
parent 25dba97c06
commit 46c26796cc
6 changed files with 166 additions and 32 deletions
+123 -16
View File
@@ -20,12 +20,15 @@ Written in Rust on **RTIC 2** + `rp2040-hal`.
| Path | Contents |
|------------|--------------------------------------------------------------------------|
| `core/` | `wiredsensor-core` — framing, CRCs, dispatch, SHT3x math. Pure, no I/O. |
| `core/` | `wiredsensor-core` — framing, CRCs, dispatch, SHT3x math, line rate. Pure, no I/O. |
| `firmware/`| `wiredsensor-fw` — the RTIC application, drivers and register access. |
| `bridge/` | `wiredsensor-bridge` — a spare RP2040 as a USB-to-RS485 bridge, for testing. |
| `tools/` | `wiredsensor.py` — PC-side master and end-to-end test suite. |
The split exists so the entire protocol can be exercised by ordinary host unit
tests (`cargo test`) with no hardware and no emulator. `core` performs no I/O and
touches no peripherals; everything hardware-shaped lives in `firmware`.
The `core`/`firmware` split exists so the entire protocol can be exercised by
ordinary host unit tests (`cargo test`) with no hardware and no emulator. `core`
performs no I/O and touches no peripherals; everything hardware-shaped lives in
`firmware`.
## Pinout
@@ -199,18 +202,30 @@ unknown cmd 0x7E → 01 7E 00 01 A0
`PING` is the intended first bring-up step: it exercises framing, CRC and the
RS485 driver-enable turnaround without involving the sensor at all.
## Per-unit configuration
## Configuration
Everything a unit needs is in `firmware/src/board.rs`:
Per-unit settings live in `firmware/src/board.rs`:
```rust
pub const UNIT_ADDRESS: u8 = 0x01; // this node's bus address
pub const DEVICE_SERIAL: u32 = 0x0000_0001;
pub const BAUD_RATE: u32 = 19_200;
pub const SENSOR_I2C_ADDR: u8 = 0x44; // match the ADDR strap
pub const SENSOR_PERIOD_MS: u32 = 1_000;
```
The **line rate is not** among them. It belongs to the segment rather than to any
one board, so it is defined once in `core/src/timing.rs` and read by both the
node and the bridge:
```rust
pub const BAUD_RATE: u32 = 19_200; // wiredsensor_core::timing
```
A baud mismatch between two ends of a bus is silent and presents as random CRC
failures, which is a poor thing to debug — hence one definition rather than a
copy per firmware. `INTER_FRAME_GAP_US` and `CHAR_TIME_US` derive from it, and a
unit test asserts they cannot go stale if it changes.
The address is compile-time by design: no flash wear, no commissioning protocol,
and no way for a bus glitch to renumber a live node. The cost is one image per
unit, so keep `UNIT_ADDRESS` and `DEVICE_SERIAL` the only things that differ.
@@ -223,7 +238,7 @@ is already reserved for it.
```bash
cargo build --release -p wiredsensor-fw # firmware, thumbv6m-none-eabi
cargo test -p wiredsensor-core --target x86_64-unknown-linux-gnu # 42 host tests
cargo test -p wiredsensor-core --target x86_64-unknown-linux-gnu # 43 host tests
```
The host-target flag is needed because `.cargo/config.toml` defaults the whole
@@ -283,6 +298,82 @@ if you want to see it.
Note `16c0:27dd` is the pid.codes generic CDC-ACM pair, intended for development.
Replace it before shipping.
## End-to-end bus testing
`bridge/` turns a spare RP2040 into a USB-to-RS485 bridge, and
`tools/wiredsensor.py` drives the protocol from the PC through it. Together they
test the bus itself rather than just the node's internals.
Two design choices make this a usable test instrument rather than a mirror of
the firmware's own assumptions:
- **The bridge is protocol-agnostic.** Bytes from USB go out on the pair, bytes
from the pair come back up USB. It knows nothing of frames, addresses or CRCs,
so it cannot have a bug that happens to agree with the node's.
- **The PC side reimplements the wire format** from the specification above
rather than sharing `wiredsensor-core`. Shared code would let a framing or CRC
bug cancel out and every test pass regardless.
### Wiring
You need two transceiver modules. The bridge mirrors the node's pin assignment,
so one diagram covers both boards: `GPIO0 → DI`, `GPIO1 ← RO`, `GPIO2 → DE+RE`.
```
bridge A ────────── A node
B ────────── B
GND ────────── GND
[120Ω] [120Ω] ← across A-B at each end; both are segment ends
```
Ground is common if both boards are USB-powered from the same host. If only one
is, run **VSYS → VSYS** plus **GND → GND** between them — not `3V3(OUT)`, which
back-feeds the regulator on the unpowered board.
### Running
```bash
cargo build --release -p wiredsensor-bridge
cp target/thumbv6m-none-eabi/release/wiredsensor-bridge /tmp/bridge.elf
picotool load -x /tmp/bridge.elf # hold BOOTSEL while plugging in
```
The bridge is `16c0:27de`, the node `16c0:27dd`. Find it with:
```bash
for d in /dev/ttyACM*; do udevadm info -q property -n $d | grep -q 27de && echo $d; done
```
```bash
./tools/wiredsensor.py --port /dev/ttyACM2 measure
./tools/wiredsensor.py --port /dev/ttyACM2 monitor
./tools/wiredsensor.py --port /dev/ttyACM2 -v test # -v dumps bus traffic
```
### What the suite covers
15 checks. Framing and CRC arithmetic are already covered by the host tests;
what only a real bus can verify is everything timing-dependent:
- **Silence where required** — frames for another unit, broadcasts, bad CRCs,
truncated frames and frames whose `LEN` lies must all produce *no reply*. A
node that wrongly answered would collide with whoever was actually addressed.
- **Driver-enable turnaround** — every intact reply is evidence that `DE` was
held past the final stop bit. Release it early and the last byte truncates.
- **No state leakage** between back-to-back requests.
- **Binary transparency** across the full legal frame-size range.
- **Counter integrity** — inject one corrupt frame, assert `crc_errors` rises by
exactly one.
This earned its keep immediately: it caught a frame-truncation bug that both the
host tests and the sensor verification were structurally incapable of reaching,
because it needed real bus timing *and* a frame longer than the RX FIFO
watermark. See the first item under *Details that are easy to get wrong*.
Frames must fit one 64-byte USB packet, since each host write becomes exactly one
DE-bracketed transmission. Every real command is at most 21 bytes; only an
oversized `PING` can approach the limit.
## Design notes
### Why RTIC rather than Embassy
@@ -324,15 +415,31 @@ The ISR deliberately does no parsing. At 19200 baud a character arrives every
~520 µs, roughly 65,000 core cycles, so there is ample slack; keeping the handler
to a FIFO drain is what bounds the jitter everything else sees.
### Three details that are easy to get wrong
### Details that are easy to get wrong
**`RTIM` alone is not a sufficient frame delimiter.** It only fires while the RX
FIFO is non-empty, so a frame whose length lands exactly on the FIFO watermark
can be drained by the watermark interrupt and never produce a timeout. The
authoritative delimiter is therefore a monotonic timer measured from the last
received byte; `RTIM` merely makes the common case prompt. `frame_gap` re-reads
the arrival timestamp and sleeps again rather than cancelling and respawning a
timer per byte.
**`RTIM` is not a sufficient frame delimiter, and an interrupt timestamp is not
a trustworthy end-of-frame.** Two separate traps here, and the second one bit us
for real.
`RTIM` only fires while the RX FIFO is non-empty, so a frame drained exactly
empty by the watermark interrupt never produces a timeout at all.
Worse: once the watermark interrupt has fired mid-frame — at 16 bytes, with the
FIFO 32 deep — the *remaining* bytes sit below the watermark and raise no
interrupt whatsoever until the timeout eventually arrives. A gap measured from
the timestamp the ISR left behind therefore expires while the tail of the frame
is still arriving, and the node parses a prefix whose `LEN` disagrees with its
length. Every frame of 16 bytes or more was silently rejected this way; frames
under 16 never trip the watermark, so their only interrupt is the 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.
So `frame_gap` drains the FIFO *itself* before each decision rather than trusting
the ISR's timestamp, which lets a byte that has landed 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. It re-reads and sleeps again rather than cancelling and
respawning a timer per byte.
**`DE` must be held until the last stop bit is gone.** Releasing it when the TX
FIFO empties truncates the final character for every listener — a fault that