f2d06278af
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>
501 lines
23 KiB
Markdown
501 lines
23 KiB
Markdown
# wiredsensor
|
||
|
||
RP2040 firmware for an RS485 temperature and humidity node. A Sensirion **SHT31**
|
||
on I2C, an RS485 transceiver on UART0, and a custom binary protocol in which this
|
||
node is always the slave.
|
||
|
||
Written in Rust on **RTIC 2** + `rp2040-hal`.
|
||
|
||
```
|
||
┌──────────────┐ I2C1 ┌────────┐
|
||
│ ├────────►│ SHT31 │
|
||
A/B │ RP2040 │ └────────┘
|
||
◄────►│ │ UART0 + DE
|
||
│ wiredsensor ├────────►┌──────────────┐
|
||
└──────────────┘ │ MAX485-class │
|
||
└──────────────┘
|
||
```
|
||
|
||
## Layout
|
||
|
||
| Path | Contents |
|
||
|------------|--------------------------------------------------------------------------|
|
||
| `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 `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
|
||
|
||
| GPIO | Function | Connects to |
|
||
|------|------------|---------------------------------------------------|
|
||
| 0 | UART0 TX | transceiver `DI` |
|
||
| 1 | UART0 RX | transceiver `RO` |
|
||
| 2 | SIO output | transceiver `DE` **and** `/RE`, tied together |
|
||
| 14 | I2C1 SDA | SHT31 `SDA` |
|
||
| 15 | I2C1 SCL | SHT31 `SCL` |
|
||
| 16 | PIO0 | WS2812B status indicator, data in |
|
||
| — | SWD | `SWCLK`/`SWDIO` on the dedicated pads, for the probe |
|
||
|
||
Why this arrangement:
|
||
|
||
- **GPIO 0/1/2 as one contiguous block.** All three RS485 signals sit at the
|
||
corner of the package, so the transceiver can be placed right beside it with
|
||
short traces and no crossings. It also keeps the fast switching edges of `DE`
|
||
away from the sensor.
|
||
- **`DE` and `/RE` on a single pin.** `/RE` is active low and `DE` active high,
|
||
so one line puts the transceiver in transmit when high and receive when low.
|
||
Saves a GPIO and makes it structurally impossible to drive and listen at once.
|
||
- **I2C1 on GPIO 14/15, physically far from the RS485 block.** The sensor is the
|
||
noise-sensitive part and the differential driver is the noisy part. Using I2C1
|
||
also leaves I2C0 free.
|
||
- **GPIO 16 for the WS2812B**, because that is where the addressable LED sits on
|
||
the boards this targets. Any GPIO would do, since PIO can drive the waveform
|
||
from any pin, but it is kept clear of the RS485 block so the LED's switching
|
||
current does not share a return path with the differential pair.
|
||
|
||
### Hardware notes the firmware cannot enforce
|
||
|
||
- Fit **120 Ω termination only at the two physical ends** of the segment, and
|
||
fail-safe bias resistors at exactly one point on the segment.
|
||
- The SHT31 `ADDR` pin selects the I2C address: low → `0x44`, high → `0x45`.
|
||
Strap it deliberately rather than leaving it floating, and keep
|
||
`board::SENSOR_I2C_ADDR` in agreement.
|
||
- Fit **4.7 kΩ external I2C pull-ups**. The internal pulls the firmware enables
|
||
are ~50 kΩ and are a fallback, not a substitute.
|
||
- The SHT31 `nRESET` pin can simply be pulled high; the firmware uses the soft
|
||
reset command.
|
||
- `.boot2` is built for a **W25Q080-class** QSPI flash. Change
|
||
`BOOT2_FIRMWARE` in `firmware/src/main.rs` if the board carries something else.
|
||
- The **WS2812B** wants 100 nF of local decoupling and draws tens of mA at full
|
||
brightness. The firmware keeps it deliberately dim, which is both easier to
|
||
read on a bench and easier on that current.
|
||
|
||
## Status indicator
|
||
|
||
The WS2812B on GPIO16 reports health at a glance, with no serial port attached:
|
||
|
||
| Colour | Meaning | Condition |
|
||
|---|---|---|
|
||
| Green, dim | running | default |
|
||
| Blue | measurement taken | within 120 ms of a successful reading |
|
||
| Red | sensor error | any failed poll, or `SENSOR_OK` clear |
|
||
|
||
The most urgent condition wins: red over blue, blue over green. So a healthy node
|
||
reads as dim green with a distinct blue pip once a second, and a stalled or
|
||
failing one is obvious immediately.
|
||
|
||
Driven from PIO (`firmware/src/status_led.rs`) rather than via `ws2812-pio`, whose
|
||
current release pins `rp2040-hal` 0.11 against this project's 0.12 — see that
|
||
module's header. Generating the waveform in the state machine also means the bus
|
||
tasks can preempt the LED task freely without glitching it.
|
||
|
||
Note that **many parts sold as WS2812B expect red-first rather than green-first
|
||
byte order**, including the boards this targets. That is one constant,
|
||
`CHANNEL_ORDER`; if red and green come out swapped while blue is correct, it is
|
||
the only thing to change.
|
||
|
||
## Protocol
|
||
|
||
19200 baud, 8N1, half duplex. Frames are delimited by an **idle line**, not by
|
||
any byte value, so payloads are fully binary-transparent with no escaping.
|
||
|
||
```
|
||
┌──────┬─────┬─────┬──────────────┬────────┬────────┐
|
||
│ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │
|
||
└──────┴─────┴─────┴──────────────┴────────┴────────┘
|
||
1 1 1 0..=64 1 1
|
||
```
|
||
|
||
- **ADDR** — `0x01`..`0xF7` addresses a unit; `0x00` is broadcast and is never
|
||
answered. A frame for any other address is ignored silently.
|
||
- **LEN** — payload length. Carried explicitly as well as implied by the frame
|
||
length, which lets a receiver reject a frame on length grounds before spending
|
||
time on the CRC, and makes captures readable by eye.
|
||
- **CRC** — CRC-16/MODBUS (poly `0xA001` reflected, init `0xFFFF`, no final XOR),
|
||
transmitted **low byte first**. Identical to Modbus RTU, so off-the-shelf bus
|
||
analysers validate our frames without being taught anything.
|
||
- All multi-byte payload fields are **little-endian**.
|
||
|
||
Frame timing follows the Modbus rules: a frame ends after **3.5 character times**
|
||
of idle line, pinned to a fixed 1750 µs above 19200 baud. At 19200 that is
|
||
1822 µs.
|
||
|
||
### Commands
|
||
|
||
| Code | Name | Request payload | Reply payload |
|
||
|--------|--------------------|-----------------|---------------|
|
||
| `0x01` | `READ_MEASUREMENT` | none | 10 bytes |
|
||
| `0x02` | `READ_INFO` | none | 16 bytes |
|
||
| `0x03` | `READ_STATUS` | none | 11 bytes |
|
||
| `0x04` | `PING` | 0..64 bytes | echoed back |
|
||
| `0x10` | `SET_ADDRESS` | 1 byte | *error* — see below |
|
||
|
||
**`READ_MEASUREMENT`** reply:
|
||
|
||
| Offset | Type | Field |
|
||
|--------|-------|----------------------------------------------------|
|
||
| 0 | `i32` | temperature, milli-degrees Celsius |
|
||
| 4 | `i32` | relative humidity, milli-percent (0..=100000) |
|
||
| 8 | `u16` | age of the reading in ms, saturating at 65535 |
|
||
|
||
The node reports `age_ms` rather than enforcing a freshness policy of its own, so
|
||
the master decides what staleness its application tolerates. If no reading has
|
||
ever succeeded, the reply is an error: `SENSOR_UNAVAILABLE`, or `SENSOR_FAULT`
|
||
once the failure threshold is passed.
|
||
|
||
**`READ_INFO`** reply:
|
||
|
||
| Offset | Type | Field |
|
||
|--------|-------|----------------------------------------------------------|
|
||
| 0 | `u8` | firmware major |
|
||
| 1 | `u8` | firmware minor |
|
||
| 2 | `u8` | firmware patch |
|
||
| 3 | `u8` | protocol version |
|
||
| 4 | `u32` | board serial (build-time constant) |
|
||
| 8 | `u32` | SHT31 factory serial, read over I2C at start-up, 0 if unavailable |
|
||
| 12 | `u32` | uptime in seconds |
|
||
|
||
**`READ_STATUS`** reply:
|
||
|
||
| Offset | Type | Field |
|
||
|--------|-------|----------------------------------------------|
|
||
| 0 | `u8` | flags, see below |
|
||
| 1 | `u16` | raw SHT31 status register |
|
||
| 3 | `u16` | I2C transfer errors |
|
||
| 5 | `u16` | sensor CRC-8 errors |
|
||
| 7 | `u16` | frame errors (length, framing, overrun) |
|
||
| 9 | `u16` | frame CRC-16 errors |
|
||
|
||
Flags:
|
||
|
||
| Bit | Name | Meaning |
|
||
|-----|---------------------|----------------------------------------------------|
|
||
| 0 | `SENSOR_OK` | the most recent poll succeeded |
|
||
| 1 | `DATA_STALE` | cached reading older than `STALE_AFTER_MS` |
|
||
| 2 | `SENSOR_FAULT` | consecutive failures past the threshold |
|
||
| 3 | `UART_ERROR` | a line error has been seen since power-up |
|
||
| 4 | `EVER_MEASURED` | at least one reading has succeeded since power-up |
|
||
| 5 | `SENSOR_RESET_SEEN` | the SHT31 reported an unexpected reset |
|
||
| 6 | `HEATER_ON` | the SHT31 internal heater is on |
|
||
|
||
### Errors
|
||
|
||
An error reply is the request's command code with bit 7 set, and a single
|
||
payload byte:
|
||
|
||
| Code | Meaning |
|
||
|--------|--------------------------------------------------|
|
||
| `0x01` | `ILLEGAL_COMMAND` — command not implemented |
|
||
| `0x02` | `ILLEGAL_LENGTH` — wrong payload length |
|
||
| `0x03` | `SENSOR_UNAVAILABLE` — no reading yet |
|
||
| `0x04` | `SENSOR_FAULT` — sensor persistently failing |
|
||
| `0x05` | `UNSUPPORTED` — recognised but disabled in this build |
|
||
|
||
A frame that fails to parse is **counted and ignored**, never answered: with a
|
||
bad CRC the address byte cannot be trusted, and replying risks colliding with
|
||
whichever node was actually addressed.
|
||
|
||
`SET_ADDRESS` is deliberately reserved rather than removed. This build takes its
|
||
address from a compile-time constant, so the command answers `UNSUPPORTED`
|
||
instead of silently doing nothing — a master can tell the difference between "not
|
||
implemented here" and "wrong command code".
|
||
|
||
### Example exchanges
|
||
|
||
Unit `0x01`, bytes as they appear on the wire:
|
||
|
||
```
|
||
READ_MEASUREMENT → 01 01 00 21 90
|
||
← 01 01 0A 9A 5B 00 00 F0 A0 00 00 89 00 87 66
|
||
└ 23.450 °C, 41.200 %RH, 137 ms old
|
||
|
||
READ_INFO → 01 02 00 21 60
|
||
READ_STATUS → 01 03 00 20 F0
|
||
|
||
PING "Hi" → 01 04 02 48 69 4F 1E
|
||
← 01 04 02 48 69 4F 1E
|
||
|
||
SET_ADDRESS 0x22 → 01 10 01 22 81 94
|
||
← 01 90 01 05 C0 66 (UNSUPPORTED)
|
||
|
||
unknown cmd 0x7E → 01 7E 00 01 A0
|
||
← 01 FE 01 01 A0 78 (ILLEGAL_COMMAND)
|
||
```
|
||
|
||
`PING` is the intended first bring-up step: it exercises framing, CRC and the
|
||
RS485 driver-enable turnaround without involving the sensor at all.
|
||
|
||
## Configuration
|
||
|
||
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 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.
|
||
|
||
If that becomes unwieldy, the two natural upgrades are GPIO address straps read
|
||
at boot, or a flash-stored address with `SET_ADDRESS` wired up — the command code
|
||
is already reserved for it.
|
||
|
||
## Building
|
||
|
||
```bash
|
||
cargo build --release -p wiredsensor-fw # firmware, thumbv6m-none-eabi
|
||
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
|
||
workspace to the ARM target.
|
||
|
||
Flash and watch `defmt` logs over SWD:
|
||
|
||
```bash
|
||
cargo run --release -p wiredsensor-fw # runner is probe-rs
|
||
```
|
||
|
||
Readings are logged at `debug` level while `.cargo/config.toml` pins
|
||
`DEFMT_LOG=info`, so use `DEFMT_LOG=debug cargo run …` to see them.
|
||
|
||
Resource use is ~34.5 KiB of flash and ~2.5 KiB of RAM, of 2 MiB and 256 KiB.
|
||
Roughly half the flash is the USB stack.
|
||
|
||
## USB diagnostics
|
||
|
||
The node also presents a **USB CDC serial port**, so it can be verified with
|
||
nothing but a USB cable — no SWD probe and no RS485 adapter. This is a diagnostic
|
||
aid, not part of the protocol, and it runs at the lowest priority so it can never
|
||
delay a bus reply.
|
||
|
||
With no debug probe available, flash over the bootloader instead: hold `BOOTSEL`
|
||
while plugging in, then
|
||
|
||
```bash
|
||
cp target/thumbv6m-none-eabi/release/wiredsensor-fw /tmp/fw.elf
|
||
picotool load -x /tmp/fw.elf # picotool requires a known file extension
|
||
```
|
||
|
||
The board appears as `16c0:27dd` "wiredsensor RS485 node". Read it with:
|
||
|
||
```bash
|
||
stty -F /dev/ttyACM0 raw -echo && cat /dev/ttyACM0
|
||
```
|
||
|
||
```
|
||
=== wiredsensor v0.1.0 ===
|
||
unit=0x01 baud=19200 gap=1822us
|
||
SHT31 on I2C1 SDA=GP14 SCL=GP15 addr=0x44 100000Hz
|
||
[ 82s] t=+27.349 C rh=+43.486 % age=985ms | OK fails=0
|
||
sht_serial=0x2d5ac752 sht_status=0x0000 err[i2c=1 sht_crc=0 frame=0 bus_crc=0]
|
||
```
|
||
|
||
A non-zero `sht_serial` is the useful signal: it is read from the SHT31's factory
|
||
serial register and CRC-8 validated, so a missing or miswired sensor cannot fake
|
||
it. Small jitter in the last digits of the readings is the genuine noise floor of
|
||
a live 16-bit conversion — an identical repeated value would suggest a stuck
|
||
cache instead.
|
||
|
||
The banner prints once at start-up. Output is dropped when no host is draining
|
||
the port, rather than blocking the firmware, so hold the port open across a reset
|
||
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
|
||
|
||
This node's hard requirements are two pieces of precise register-level UART
|
||
behaviour plus one scheduling guarantee, and RTIC provides all three directly:
|
||
|
||
- The PL011 **receive-timeout interrupt** (`RTIM`) fires after 32 idle bit
|
||
periods with unread data in the FIFO — very close to the 3.5-character gap the
|
||
protocol uses as a delimiter. Binding it needs raw register access.
|
||
- The PL011 has **no transmit-complete interrupt**, only FIFO-level ones, so
|
||
releasing `DE` correctly requires polling the `BUSY` flag.
|
||
- The bus must **preempt** the sensor, and the resulting sharing should be
|
||
checked rather than argued about.
|
||
|
||
`rp2040-hal`'s `Uart` exposes neither `RTIM` nor a hook for post-stop-bit `DE`
|
||
release, so the firmware lets the HAL do the fiddly baud-divisor and
|
||
line-control setup once, then calls `.free()` to reclaim the raw peripheral and
|
||
drives it from registers thereafter.
|
||
|
||
### Task structure
|
||
|
||
| Prio | Task | Kind | Job |
|
||
|------|---------------|----------|--------------------------------------------|
|
||
| 3 | `uart0_irq` | hardware | Empty the 32-byte RX FIFO, timestamp bytes |
|
||
| 2 | `frame_gap` | async | Detect the frame gap, answer the request |
|
||
| 1 | `sensor_task` | async | Poll the SHT31 once a second |
|
||
| 1 | `usb_irq` | hardware | Service the USB CDC diagnostic port |
|
||
| 1 | `usb_report` | async | Emit a diagnostic line once a second |
|
||
| 1 | `status_led` | async | Repaint the WS2812B indicator |
|
||
|
||
The central decision is that **requests are answered entirely from a cached
|
||
reading**. An SHT31 high-repeatability conversion takes up to 15 ms — far longer
|
||
than the turnaround a master expects — so the sensor is polled on its own
|
||
schedule at the lowest priority and the bus path never touches I2C. RTIC's
|
||
priority ceilings then guarantee a conversion in progress cannot delay a reply.
|
||
|
||
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.
|
||
|
||
### Details that are easy to get wrong
|
||
|
||
**`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
|
||
shows up as a CRC error at the master and is completely invisible at the slave.
|
||
`transmit` sleeps through the bulk of the transmission, then confirms with
|
||
`BUSY`. It yields rather than spins, which is safe because `DE` stays asserted
|
||
throughout: the node owns the bus for the whole call.
|
||
|
||
**The RX FIFO is flushed after transmitting.** While `DE` is asserted the
|
||
transceiver's `/RE` is disabled and `RO` is not driven, so the edge as it
|
||
re-enables can clock a spurious character in. Left in place that byte would
|
||
become the first byte of the next frame and break it.
|
||
|
||
### SHT3x, not SHT4x
|
||
|
||
Worth stating explicitly because the families are easy to confuse and the
|
||
firmware would appear to work while reading wrong:
|
||
|
||
- Commands are **16-bit** words, not a single byte.
|
||
- Humidity is `100 × raw / 65535` — **no** `-6 + 125 ×` offset term. Temperature
|
||
is `-45 + 175 × raw / 65535`, the same as SHT4x.
|
||
- High-repeatability conversion takes up to **15 ms**, not 8.3 ms.
|
||
|
||
Measurements use the clock-stretch-**disabled** command and an explicit timed
|
||
wait. The alternative has the sensor hold SCL for up to 15 ms, blocking the bus
|
||
and making the transfer duration depend on the controller's stretch timeout.
|
||
|
||
Each poll also reads the status register, so an unexpected sensor reset surfaces
|
||
in `READ_STATUS` instead of silently reverting the sensor's configuration.
|