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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user