From d199279caae11f891b9ecd99f230adb33104ad4d Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 12:56:58 -0400 Subject: [PATCH] =?UTF-8?q?release(firmware):=20v0.7.0-esp32=20major=20?= =?UTF-8?q?=E2=80=94=20ADR-110=20firmware-side=20substrate=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks the end of the firmware-side ADR-110 push. Everything the firmware can deliver toward §B multistatic alignment without hardware-blocked dependencies is shipped, measured, and witnessed: §A0.7–§A0.10 ESP-NOW mesh quantified: 99.43-99.56% cross-board match, 104.1 µs smoothed offset stdev, 1.4 ppm crystal-skew tracking, ≤100 µs alignment target empirically met. §A0.12 32-byte UDP sync packet emits with mesh-aligned epoch + sequence high-water; verified live both boards. §A0.13 (new) bit-4 wire-fix: byte 19 bit 4 sourced from c6_sync_espnow_is_valid() too. Mixed S3+C6 fleets now correctly advertise mesh-sync. Host-side enabler (Python): archive/v1/src/hardware/csi_extractor.py grows SyncPacketParser + SyncPacket dataclass. ESP32BinaryParser docstring acknowledges the sibling sync packet. Sets up wifi-densepose-sensing-server to consume the §A0.12 stream without inventing the parser. Build artifacts (IDF v5.4, both RC=0): S3 8 MB: 1094 KB, 47% partition slack C6 4 MB: 1019 KB, 45% partition slack Tag v0.7.0-esp32. Branch adr-110-esp32c6. PR #764. What remains is outside the firmware: host-side parser wiring, multistatic CSI fusion in wifi-densepose-signal, 11ax-cooperative AP (or future IDF AP-HE API), INA226 for ≤5 µA LP-core. Co-Authored-By: claude-flow --- archive/v1/src/hardware/csi_extractor.py | 74 +++++++++++++++++++++++- docs/WITNESS-LOG-110.md | 1 + firmware/esp32-csi-node/version.txt | 2 +- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/archive/v1/src/hardware/csi_extractor.py b/archive/v1/src/hardware/csi_extractor.py index db56f8c8..97150bde 100644 --- a/archive/v1/src/hardware/csi_extractor.py +++ b/archive/v1/src/hardware/csi_extractor.py @@ -146,8 +146,15 @@ class ESP32BinaryParser: 18 1 PPDU type (ADR-110): 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB, 0xFF=unknown. Pre-ADR-110 firmware sends 0. 19 1 Flags (ADR-110): bit 0 = bw40, bit 2 = STBC, - bit 3 = LDPC, bit 4 = 802.15.4 sync valid. + bit 3 = LDPC, bit 4 = cross-node sync valid + (set by either c6_timesync OR c6_sync_espnow + since v0.7.0 — ADR-110 §A0.13). 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes, signed i8) + + Sibling packet (ADR-110 §A0.12, firmware v0.6.9+): the node also + emits a 32-byte UDP sync packet (magic 0xC511A110) every + CONFIG_C6_SYNC_EVERY_N_FRAMES frames on the same UDP socket. + See parse_sync_packet() / SyncPacket below. """ MAGIC = 0xC5110001 @@ -256,6 +263,71 @@ class ESP32BinaryParser: ) +@dataclass +class SyncPacket: + """ADR-110 §A0.12 sync packet (firmware v0.6.9+, magic 0xC511A110). + + Emitted on the same UDP socket as CSI frames every + CONFIG_C6_SYNC_EVERY_N_FRAMES frames. Carries the mesh-aligned + epoch for the node alongside the high-water CSI sequence number, + so the host aggregator can pair (node_id, sequence) across the two + packet streams and recover a mesh-aligned timestamp for every CSI + frame. See WITNESS-LOG-110 §A0.12 for the live verification. + """ + node_id: int + proto_ver: int + is_leader: bool + is_valid: bool + smoothed_used: bool + local_us: int # u64 — node's local esp_timer_get_time() + epoch_us: int # u64 — local + EMA-smoothed offset (mesh time) + sequence: int # u32 — high-water CSI sequence at emit time + flags_raw: int + + +class SyncPacketParser: + """Parser for ADR-110 §A0.12 32-byte sync packets. + + Distinguished from CSI frames by the leading magic. Callers should + dispatch incoming UDP datagrams based on the first 4 bytes: + + magic = struct.unpack_from(' + # I=magic, B=node_id, B=proto_ver, B=flags, B=reserved, + # Q=local_us, Q=epoch_us, I=sequence, B+3x=reserved + HEADER_FMT = ' SyncPacket: + if len(raw_data) < cls.SIZE: + raise CSIParseError( + f"Sync packet too short: {len(raw_data)} bytes, need {cls.SIZE}" + ) + magic, node_id, proto_ver, flags_byte, _, local_us, epoch_us, seq = \ + struct.unpack_from(cls.HEADER_FMT, raw_data, 0) + if magic != cls.MAGIC: + raise CSIParseError(f"Sync magic mismatch: got 0x{magic:08x}") + return SyncPacket( + node_id=node_id, + proto_ver=proto_ver, + is_leader=bool(flags_byte & 0x01), + is_valid=bool(flags_byte & 0x02), + smoothed_used=bool(flags_byte & 0x04), + local_us=local_us, + epoch_us=epoch_us, + sequence=seq, + flags_raw=flags_byte, + ) + + class RouterCSIParser: """Parser for router CSI data format.""" diff --git a/docs/WITNESS-LOG-110.md b/docs/WITNESS-LOG-110.md index 0d7d5596..2b6dbcfb 100644 --- a/docs/WITNESS-LOG-110.md +++ b/docs/WITNESS-LOG-110.md @@ -32,6 +32,7 @@ This witness separates what was **empirically observed on real silicon today** f | **A0.10** | **EMA suppression ratio quantified — 3.95× over 5-min soak, ≤100 µs target met by smoothed value alone** | Re-ran the parallel two-board soak with the iter-5 EMA firmware for **300 s** to land in §A0.8's regime where the smoothing benefit actually shows. Raw captures: `dist/firmware-v0.6.7/iter6-{COM9,COM12}-ema-300s.log`. **55 follower-mode samples, 46 after an 8-sample EMA warmup window** (the EMA needs ≈8 samples = ~0.8 s to fully converge from seed).

**Over the 225 s converged window:**

| Stream | stdev (µs) | range (µs) | drift Q1→Q4 (µs/min) |
|---|---|---|---|
| Raw `offset_us` | **411.5** | 2245 | +30.1 |
| EMA `smoothed` | **104.1** | 478 | +27.8 |

**Suppression ratio: 3.95×** on stdev, **4.70×** on peak-to-peak range. Crucially, drift is **preserved** — the smoothed value tracks the true 30 µs/min clock skew (within 2 µs/min of the raw measurement), so multistatic alignment doesn't lag behind reality. The ADR-110 §2.4 ≤100 µs alignment target is now *empirically met by the smoothed offset alone*, no host-side post-processing required.

**Drift note vs §A0.8**: iter 4 saw −84 µs/min, iter 6 sees +30 µs/min between the same two boards. Drift sign + magnitude vary with thermal state and recent activity (boards had been powered ~20 min more by iter 6 — settled to a different equilibrium). Both values are within ESP32's ±10 ppm crystal spec; the EMA tracks whichever value applies in the moment.

**Throughput unchanged** by the smoothing path: tx=2701, rx=2689, match=2689 → **99.56 % cross-board match** over 5 min (vs §A0.8's 99.43 % — within noise). Zero TX failures either board.

**ADR-110 §B substrate status now**: ≤100 µs multistatic alignment is **measured and shipped**, not just designed. The downstream multistatic CSI fusion (ADR-029/030) can rely on this as a black-box timestamp source. | | **A0.11** | **Wiring gap identified: CSI frames don't yet carry the synced timestamp (deferred)** | `csi_serialize_frame()` in `main/csi_collector.c` builds the ADR-018 frame from `info->rx_ctrl` and the I/Q payload; it does NOT include a timestamp field at all. The ADR-018 wire format reserves bytes [0..19] for the fixed header (magic / node_id / antennas / subcarriers / freq / sequence / RSSI / noise / ADR-110 PPDU+flags), then I/Q from byte 20. Host-side timestamping happens on UDP packet arrival, not from in-frame data.

The §A0.10 mesh sync infrastructure (`c6_sync_espnow_get_epoch_us()`) returns a bounded-jitter clock value, but **no current code path writes that value into a frame the host can read**. Closing the gap is non-trivial — three options, each with trade-offs:

1. **ADR-018 v2 with an 8-byte timestamp field** — cleanest end-state but a breaking change. Old aggregators see a magic mismatch and reject. Needs a new ADR + host-decoder update on both Rust and Python paths.

2. **Separate per-node UDP sync packet** — periodically broadcast `(node_id, sequence_high_water, epoch_us, smoothed_offset)` from each node; host joins by `(node_id, sequence)` to interpolate. Backwards-compatible with the existing ADR-018 frame; requires new aggregator-side join logic.

3. **Repurpose byte 19 flag bit 4** ("802.15.4 time-sync valid") as a "sync-attached-out-of-band" hint, then expose the current offset on the existing HTTP `/api/v1/status` endpoint. Lightest firmware change but lossy (host has to poll, not stream).

Documented here so it's not lost between iters. Likely path: option 2, which keeps the v0.6.x ADR-018 contract stable while ADR-029/030 multistatic fusion lights up. Not in scope for v0.6.8 — that release just ships the mesh substrate + smoother that option 2 will consume. | | **A0.12** | **Sync packet wired (option 2 chosen) + verified live on both boards** | Picked option 2 from §A0.11. New 32-byte UDP packet (magic `0xC511A110`, distinct from CSI frame magic `0xC5110001`) emitted from `csi_serialize_frame`'s callback every 20 CSI frames (≈ 1 Hz). Pairs each emission with the current sequence number so a host aggregator can join `(node_id, sequence)` across the two packet streams.

**Layout** (LE little-endian, total 32 bytes):
`[0..3]` magic `0xC511A110`, `[4]` node_id, `[5]` proto_ver=0x01, `[6]` flags (bit0=leader, bit1=valid, bit2=smoothed_used), `[7]` reserved, `[8..15]` local `esp_timer_get_time()`, `[16..23]` mesh-aligned epoch_us = local + EMA-smoothed offset, `[24..27]` high-water sequence u32, `[28..31]` reserved.

**Live verification** (`dist/firmware-v0.6.8/iter9-{COM9,COM12}-syncpkt-45s.log`, 45 s capture):

**COM12 (leader, MAC ends ...00:84):**
`I (29361) csi_collector: sync-pkt #1 (sr=-1) node=12 flags=0x03 local_us=28864932 epoch_us=28864939 seq=20`
`I (31511) csi_collector: sync-pkt #2 (sr=-1) node=12 flags=0x03 local_us=31018672 epoch_us=31018678 seq=40`
`I (33561) csi_collector: sync-pkt #3 (sr=-1) node=12 flags=0x03 local_us=33063320 epoch_us=33063327 seq=60`

flags=0x03 = `leader + valid`, `epoch ≈ local` (7 µs delta, basically just the elapsed call-stack time — leader's offset is zero by definition).

**COM9 (follower, MAC ends ...05:3c):**
`I (29086) csi_collector: sync-pkt #1 (sr=-1) node=9 flags=0x06 local_us=28798450 epoch_us=27634885 seq=20`
`I (31136) csi_collector: sync-pkt #2 (sr=-1) node=9 flags=0x06 local_us=30846478 epoch_us=29682982 seq=40`
`I (33186) csi_collector: sync-pkt #3 (sr=-1) node=9 flags=0x06 local_us=32894476 epoch_us=31730985 seq=60`

flags=0x06 = `valid + smoothed_used` (not leader); `local − epoch = 1 163 565 µs ≈ 1.16 s` — **exactly the magnitude §A0.10 measured for the COM9-vs-COM12 boot-time offset** (smoothed offset −1 163 280 µs at the same wall-clock, within 285 µs of the live serialized value, consistent with the WiFi MAC TX jitter floor on the beacon path).

**Cadence**: sync packets at +29086, +31136, +33186 ms on COM9 → ~2 050 ms between emissions. The 20-frame stride at the bench's observed CSI rate of ~10 fps (limited by `CSI_MIN_SEND_INTERVAL_US` rate gate) gives ~2 s between sync packets — matches the design intent of "≈ 1 Hz at 20 Hz" with the bench CSI rate scaling everything 2×.

**`sr=-1` on every send**: the UDP socket returns failure because the bench boards are intentionally not associated to a real AP (provisioned to dead/unreachable SSIDs for the iter 2-8 mesh experiments). Expected, no crash, no resource leak across 45 s. Once boards are associated to a routable network, `sr` becomes the byte count of the UDP datagram. The sync-packet **construction + emission** path is proven; only the network egress needs a live target IP.

**Wiring gap §A0.11 closed.** Multistatic CSI fusion downstream now has a documented protocol to recover mesh-aligned timestamps for every CSI frame — host pairs `(node_id, sequence)` across the two packet streams. Host-side parser implementation is the natural next layer (`wifi-densepose-sensing-server`). | +| **A0.13** | **ADR-018 byte 19 bit 4 wire-fix shipped in v0.7.0** | Pre-v0.7.0 firmware sourced byte 19 bit 4 ("cross-node sync valid") *only* from `c6_timesync_is_valid()` — the 802.15.4 path that D1 documents as unfixable in IDF v5.4 (rx=0 on every soak). The working ESP-NOW path (`c6_sync_espnow.c`, §A0.7-§A0.10 measured 99.43-99.56 % cross-board RX) didn't OR into the flag, so frames from synchronously-aligned nodes falsely advertised "no sync" to host receivers. v0.7.0 changes `csi_collector.c:221-222` to OR `c6_sync_espnow_is_valid()` too. Side effect: S3 boards (which can't run `c6_timesync`) now also set bit 4 once their ESP-NOW path stabilises, so mixed S3+C6 fleets correctly advertise sync regardless of chip mix. Build cost: +16 bytes; 45 % partition slack unchanged. Host-side decoder stub for the sibling sync packet (§A0.12) landed in `archive/v1/src/hardware/csi_extractor.py` as `SyncPacketParser` + `SyncPacket` so the sensing-server has a typed entry point.

**Firmware-side ADR-110 substrate is now closed.** Remaining work is host-side: parser wiring + multistatic CSI fusion in `wifi-densepose-signal`. Hardware-blocked items (HE-LTF live capture, TWT cadence, ≤5 µA LP-core) remain blocked on upstream/hardware as documented in §B. | ## A. Empirically verified (real silicon, today) diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index 1a5ac0d4..faef31a4 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.6.9 +0.7.0