Compare commits

..

2 Commits

Author SHA1 Message Date
rUv d0e27e652e fix(firmware): C6 IDF v5.5 guard + HE-LTF host ingest + WITNESS-LOG-110 B1 resolution (#1005) (#1011)
* fix(firmware): c6_sync_espnow IDF v5.5 send-callback guard + B1 HE-LTF resolution (#1005)

Espressif backported the esp_now_send_cb_t signature change to v5.5
(esp_now_send_info_t = wifi_tx_info_t there), so the #944 guard must be
ESP_IDF_VERSION >= VAL(5,5,0), not MAJOR >= 6.

Validated on this repo's hardware toolchain:
- WITHOUT fix, IDF v5.5.2 esp32c6 build fails with the reporter's exact
  incompatible-pointer error at c6_sync_espnow.c:199 (reproduced)
- WITH fix, clean build on IDF v5.5.2 (esp32c6) AND IDF v5.4 (regression)

Docs: WITNESS-LOG-110 §B1 marked RESOLVED WITH MEASUREMENT (external,
@stuinfla, issue #1005): IDF v5.4 driver downconverts HE->HT; v5.5.2
delivers true HE-LTF (532B / 256 bins / 242 tones, PPDU 0x01 HE-SU).
ADR-110 capability table updated accordingly.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: WITNESS-LOG-110 §B1 — in-house HE-LTF replication on the original COM12 C6

84% of 1,525 frames at 532B/PPDU 0x01 (HE-SU) with IDF v5.5.2 + the #1005
guard fix, AP ruv.net 11ax 2.4GHz. Two independent rigs now confirm:
v5.4 downconverts, v5.5.2 delivers 242-tone HE20.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(host): 256-bin HE-LTF ingest end-to-end + latent offset bugs (#1005)

Audit of every ADR-018 consumer against live C6 HE20 frames (532B/256-bin):
- sensing-server + CLI calibrate parsers read n_subcarriers from one byte
  (256 decoded as 0) with stale seq/rssi offsets (rssi always 0 — latent,
  pre-existing, confirmed vs firmware csi_collector.c). Fixed to the real
  ADR-018 layout; n_subcarriers u8->u16; byte 18 surfaced as typed PpduType.
- sensing-server probe buffer 256B -> 2048B (532B datagram errored on Windows)
- per-node grid gate: lock densest (n_subcarriers, ppdu_type) grid, re-warm
  on upgrade, skip sparser minority frames — HT-64 never mixes into an
  HE-256 baseline window
- hardware parser: HE-aware bandwidth classification (256-FFT HE20 = 20MHz,
  was Bw160); PpduType/Adr018Flags re-exported
- verbatim live frames (532B HE-SU, 148B HT) embedded as regression fixtures
- archive python parser: bandwidth heuristic mirror fix

Live-validated: calibrate --tier he20 consumed 600x 256-bin frames into an
ADR-135 He20 baseline (242 tones) skipping 94 HT frames; sensing-server
shows node 12 active with real RSSI (-40dBm). 765 tests green across the
three crates; workspace check clean; Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(fuzz): esp_netif/ping_sock/ip_addr stubs — un-break ADR-061 fuzz build after #954

csi_collector.c gained esp_netif.h / ping/ping_sock.h / lwip/ip_addr.h
includes for the #954 gateway self-ping; the host-fuzz stub env lacked
them, breaking the fuzz build on main since 5789351b7. Stubs return
no-gateway so the self-ping path early-outs (compiles + links, never
exercised — matches the fuzz threat model which targets frame
serialization, not the network stack).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 11:00:37 -04:00
rUv 2a307138f2 feat: per-room calibration system (ADR-151) + cognitum-v0 appliance integration spec (#989)
* docs(adr): ADR-151 — Per-Room Calibration & Specialized Model Training

Room-first calibration -> bank of small specialised ruVector models
(breathing, heartbeat, restlessness, posture, presence, anomaly) distilled
from the frozen Hugging-Face-published RF Foundation Encoder (ADR-150).

Four-stage local-first pipeline: baseline (ADR-135 environmental fingerprint)
-> guided enrollment (NEW EnrollmentProtocol, clean anchors not hours) ->
feature extraction (reuse signal_features + ruvsense) -> specialist bank
training (rapid_adapt LoRA heads, RVF storage, HNSW prototypes).

Invariants: specialisation over scale; local heads over a shared public base;
honest STALE degradation on baseline drift. Indexes ADR-149/150/151.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(cli): calibration HTTP API for UI-driven baseline capture (ADR-135/151)

Adds `wifi-densepose calibrate-serve` — an Axum HTTP API that wraps the
ADR-135 CalibrationRecorder so a UI (or any client) can drive an empty-room
baseline capture remotely. Stage 1 ("teach the room") of the ADR-151 room
calibration & training pipeline.

A single background task owns the UDP socket (ESP32 0xC511_0001 frames) and
the optional active recorder; HTTP handlers talk to it over an mpsc command
channel and read a shared status snapshot, keeping the &mut recorder
lock-free. CORS permissive so a browser UI can call it.

Endpoints (/api/v1/calibration/*):
  GET  /health      liveness + UDP ingest stats (frames_seen, streaming)
  POST /start       { tier?, duration_s?, room_id?, min_frames? }
  GET  /status      live progress (state, frames, progress, z, eta) — poll for UI
  POST /stop        finalize the current session early
  GET  /result      finalized baseline summary (amp/phase-dispersion averages)
  GET  /baselines   list persisted baseline .bin files

Reuses the existing calibrate.rs ESP32 wire parser (made pub(crate)); honest
abort when <10 frames arrive in the window (e.g. ESP32 not streaming).

Verified end-to-end over loopback: start -> 300 replayed HT20 frames ->
state=complete, 52-subcarrier baseline, phase_dispersion_avg=0.00096
(concentrated/valid), persisted to disk; all 6 endpoints exercised.
CLI: 19 tests pass; crate builds clean.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(cli): firewall-free CSI UDP relay for local Windows ESP32 testing

Windows Defender blocks inbound LAN UDP to a freshly-built binary without an
admin allow-rule; python.exe is already allowed. This relay binds the public
CSI port and forwards each datagram verbatim to a loopback port where
`calibrate-serve --udp-bind 127.0.0.1 --udp-port 5006` listens (loopback is
firewall-exempt). No admin required.

Validated: ESP32-format 0xC5110001 frames -> :5005 -> relay -> :5006 ->
calibrate-serve -> state=complete, 52-subcarrier baseline,
phase_dispersion_avg=0.00098 (clean). Completes the no-admin live-test path.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): record ADR-151 calibration API (calibrate-serve)

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibration): ADR-151 Stages 2–5 — enrollment, extraction, specialist bank, runtime

New crate wifi-densepose-calibration implementing the per-room pipeline beyond
Stage-1 baseline:

- anchor.rs: guided-anchor sequence + event-sourced EnrollmentSession (Stage 2)
- enrollment.rs: AnchorQualityGate + AnchorRecorder — gates anchors against the
  ADR-135 baseline deviation (presence/motion), re-prompts bad captures
- extract.rs: Features + AnchorFeature — autocorrelation periodicity (breathing/
  HR bands), variance/motion (Stage 3)
- specialist.rs: 6 small room-calibrated models — presence (learned threshold),
  posture (nearest-prototype), breathing/heartbeat (band periodicity),
  restlessness (calm/active normalization), anomaly (novelty vs anchors) (Stage 4)
- bank.rs: SpecialistBank — train/persist + baseline-drift STALE invalidation
- runtime.rs: MixtureOfSpecialists — presence short-circuit + anomaly veto +
  stale flagging (Stage 5)

Statistical heads make the pipeline runnable/validatable today; the ADR-150 HF
RF Foundation Encoder backbone is the documented upgrade path. 29 unit tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(cli): wire ADR-151 enroll / train-room / room-status / room-watch

Integrates the wifi-densepose-calibration crate into the CLI as four
subcommands driving the full Stage 2–5 pipeline against a live ESP32 raw-CSI
stream (edge_tier=0):

- enroll: walks the guided anchor sequence, gates each capture against the
  ADR-135 baseline deviation (re-prompts bad anchors), writes labelled features
- train-room: fits the SpecialistBank from the enrollment, persists JSON
- room-status: prints a trained bank's summary
- room-watch: live mixture-of-specialists readout (presence/posture/breathing/
  heart/restless) over a rolling window, with anomaly veto + STALE flagging

Per-frame scalar is the mean CSI amplitude (carries presence/motion + breathing
modulation). Validated end-to-end on the live ESP32 (COM8, edge_tier=0): the
real parser → feature extraction → runtime detected breathing (~16–31 BPM) on
hardware. Full multi-anchor enrollment accuracy requires the operator to perform
the poses; phase-based breathing extraction is a noted refinement.

48 tests pass (29 calibration + 19 CLI).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-151): mark Stages 1–5 implemented; expand CHANGELOG

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(cli): keep proven mean-amplitude carrier for room features

The max-variance-subcarrier carrier locked onto motion artifacts (not
breathing) and also had an out-of-bounds bug on variable CSI subcarrier
counts. Reverted to the mean-amplitude carrier, which is validated live to
detect breathing. Phase-based extraction on a stable subcarrier remains the
proper higher-SNR refinement (ADR-151 §4).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibration): multistatic fusion of co-located nodes (ADR-029/151)

MultiNodeMixture fuses several co-located nodes (each with its own
room-calibrated SpecialistBank) into one RoomState:
- presence: OR across nodes (any node seeing a person wins)
- posture/breathing/heartbeat: highest-confidence node (best viewpoint)
- restlessness/anomaly: max across nodes
- veto: any node's physically-implausible signal vetoes the room's vitals
  (anti-hallucination, same as single-node runtime) + presence short-circuit
- stale: any node's STALE flag propagates

Same-room multistatic only; cross-room is federation (ADR-105), not fusion.
6 unit tests (presence OR, best-confidence breathing, single-node veto,
staleness). 35 calibration tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(cli): multistatic room-watch — fuse co-located nodes (ADR-029/151)

`room-watch --node-bank N:path` (repeatable) groups live CSI frames by node_id
and fuses per-node banks via MultiNodeMixture. Validated live on COM8 (node 9,
edge_tier=0): frames grouped + fused end-to-end. True 2-node fusion is covered
by unit tests; a second raw-CSI node is the hardware blocker. 54 tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(integration): calibration → cognitum-v0 appliance integration overview

Detailed cross-repo integration spec for cognitum-one/v0-appliance: data
contracts (CSI wire format, ADR-135 baseline binary, enrollment/bank/RoomState
JSON schemas), calibrate-serve HTTP API, public crate API, Pi5+Hailo tiering,
and a 5-step appliance integration plan. Grounded in the verified cognitum-v0
inventory (aarch64, cargo 1.96, HAILO10H, ruview-vitals-worker:50054).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(calibration): address PR review — aarch64 decouple, API auth, path traversal, throttle

Resolves the review on #989:

- **Cross-compile (the appliance blocker):** make wifi-densepose-mat optional
  and feature-gate it (`mat`), so `cargo build -p wifi-densepose-cli
  --no-default-features` excludes the mat→nn→ort(ONNX)→openssl-sys chain.
  Verified: `cargo tree --no-default-features` shows 0 ort/openssl deps →
  calibration cross-compiles clean for the Pi.
- **Security (must-fix before LAN):**
  - `--token` / CALIBRATE_TOKEN bearer-auth middleware on every route; warns if
    bound non-loopback without a token.
  - sanitize client-supplied `room_id` to [A-Za-z0-9_-] (≤64) before it reaches
    the baseline write path — kills the `../` file-write primitive. + test.
- **Perf:** stop locking shared status + cloning SessionStatus on every UDP
  frame — counters/snapshot flush on the 200 ms tick instead (no CPU
  starvation under flood). finalize write moved to async `tokio::fs::write`.
- **Docs:** ADR-151 STALE wording matches the impl (baseline-id change;
  drift-threshold = P6 refinement); integration doc gets the
  `--no-default-features` build + auth/sanitize notes.

35 calibration + 15 CLI tests (no-default) / 20 CLI (default) pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(worldgraph,worldmodel): add crates.io READMEs

Plain-language overviews + feature lists, comparison tables (symbolic graph vs
predictive occupancy; graph vs grid vs event-log), usage, and technical
details. Adds readme = "README.md" to both manifests so they render on
crates.io on the next release.

Co-Authored-By: claude-flow <ruv@ruv.net>

* release: worldgraph & worldmodel 0.3.1 (READMEs on crates.io)

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: precise calibration validation scope (capture+API+auth proven; clean enroll→train→infer not yet on-target)

Aligns ADR-151 §7 + the appliance integration doc with the PR #989 scope
clarification: nothing has run a clean baseline → enroll → train → infer on
live CSI; the live breathing read used the stateless head, not a trained bank.
Adds --source-format adr018v6 to the backlog.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibrate-serve): live GET /room/state endpoint (mixture over CSI window)

Adds a live RoomState readout over HTTP — the appliance UI's main need. The
ingest task maintains a rolling per-frame scalar window (flushed on the 200 ms
tick, no per-frame lock); the handler loads a bank (resolved as a sanitized
name under output_dir — same path-traversal defense as room_id), runs the
MixtureOfSpecialists over the window, returns RoomState JSON.

Validated live (ESP32-S3 via relay): breathing 14-19 BPM over HTTP; a
bank=../../etc/passwd query is neutralized to 'etcpasswd' (no traversal).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibrate-serve): POST /room/train + fix AnchorLabel JSON to snake_case

- POST /api/v1/room/train: { room_id, baseline_id, anchors[] } → trains a
  SpecialistBank and persists it as <output_dir>/<room_id>.json (path-sanitized),
  readable via /room/state?bank=<room_id>. Completes the HTTP train→infer loop.
- Fix data-contract bug: AnchorLabel serialized as PascalCase variant names
  (serde default) while as_str() + the integration doc used snake_case. Added
  #[serde(rename_all = "snake_case")] so the JSON wire format matches the
  documented contract (empty/stand_still/…). Locked with a roundtrip test.

Validated live (ESP32-S3): POST train (4 anchors → 6 specialists, persisted) →
GET /room/state returns RoomState with the trained presence/restlessness; the
synthetic-vs-real scale mismatch correctly triggers the anomaly veto. 36
calibration tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(calibrate-serve): live enroll-over-HTTP (POST /enroll/anchor + /enroll/status)

Closes the last HTTP gap — the appliance can now drive the ENTIRE calibration
pipeline over HTTP without the CLI:
  baseline (start/stop) -> enroll/anchor x8 -> room/train -> room/state

- POST /enroll/anchor { room_id, baseline, label, duration_s? }: the ingest task
  loads the baseline (sanitized name under output_dir), captures the anchor for
  the duration against it (AnchorRecorder + per-frame series), runs the quality
  gate, and on completion replies with the verdict + accumulates the AnchorFeature
  in an in-server enrollment map keyed by room_id. Re-prompts on rejection.
- GET /enroll/status?room=<id>: accepted anchors, next, complete.
- POST /room/train now falls back to the in-server enrollment when anchors[] is
  omitted.

Validated live (ESP32-S3): capture baseline -> enroll stand_still (271 frames,
6s) -> gate correctly rejects "no person detected (presence_z 0.90 < 1.50)"
relative to a same-occupancy baseline (a clean empty-room baseline is the
documented on-target prerequisite). Builds clean; CLI tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(calibrate-serve): HTTP integration tests for the room/enroll endpoints

Factor the router into build_router() (shared by execute + tests) and add
tower-oneshot integration tests (no network/ingest needed):
- health + descriptor → 200
- POST /room/train persists the bank; GET /room/state → 200; train with no
  anchors/enrollment → 400
- path-traversal: /room/state?bank=../../etc/passwd → 404 (sanitized, never
  reads outside output_dir)
- enroll/status empty; /enroll/anchor with an unknown label → 400

CI regression coverage for the endpoints added this session. 18 CLI tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(mat): make serde non-optional — unblocks `cargo test --workspace --no-default-features`

Making wifi-densepose-mat optional in the CLI (for the aarch64/ort decouple)
exposed a latent feature bug: mat's `api` module compiles unconditionally and
uses serde, but `serde` was an optional dep enabled only via the `api`/`serde`
features. Previously the CLI's *unconditional* mat dependency enabled those
features transitively, so `--workspace --no-default-features` still got serde;
once mat became optional+gated, the workspace build lost it →
`error[E0432]: unresolved import serde` across mat's api/* (CI red).

mat already pulls serde_json + axum unconditionally, so making `serde`
non-optional has no real cost and restores the workspace build. Does NOT affect
the aarch64 CLI build (mat isn't built there at all): verified
`cargo tree -p wifi-densepose-cli --no-default-features` still shows 0
ort/openssl deps, and `cargo test --workspace --no-default-features` compiles
clean.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(claude.md): add wifi-densepose-calibration to crate table (pre-merge)

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-152 — WiFi-pose SOTA 2026 intake (geometry-conditioned calibration, external benchmarks, encoder recipe)

Records the 2026-06-10 deep-research run (22 sources, 110 claims, 25
adversarially verified: 24 confirmed / 1 refuted) and the decisions it
implies:

- §2.1 ACCEPTED: geometry-condition the ADR-151 calibration system —
  NodeGeometry at enrollment, geometry embeddings for future LoRA heads,
  PerceptAlign-style two-checkerboard camera↔WiFi alignment for the
  ADR-079 supervised path. PerceptAlign (MobiCom'26) names the failure
  mode ("coordinate overfitting") that matches our own ADR-150 cross-
  subject collapse.
- §2.2 ACCEPTED: benchmark protocol vs external "WiFlow-STD (DY2434)"
  (claimed 97.25% PCK@20, Apache-2.0 weights+dataset) with a no-citation
  rule until measured on our 17-keypoint ESP32 eval set. Name collision
  with our internal WiFlow is disambiguated.
- §2.3 ACCEPTED: amend ADR-150 training recipe per UNSW MAE study —
  80% masking, (30,3) patches, data-over-capacity priority (log-linear,
  unsaturated at 1.3M samples).
- §2.4 watch items: IEEE 802.11bf-2025 published 2025-09-26;
  esp_wifi_sensing as external presence baseline (drop-in claim REFUTED
  0-3); ZTECSITool 160MHz/512-subcarrier anchor node (procurement-gated).
- §2.5 NOT adopted: non-WiFi "foundation model" papers; DensePose-UV
  (no 2025-2026 work does UV regression from commodity WiFi).

Every number is evidence-graded CLAIMED vs MEASURED in the source
register. Re-check horizon 2026-12.

Co-Authored-By: RuFlo <ruv@ruv.net>

* test(calibration): full-loop integration test — baseline→enroll→train→infer proven in-process (ADR-151 §7 gap, software half)

Closes the software half of PR #989's headline validation gap: the
complete calibration loop had never run end-to-end anywhere, even
in-process. tests/full_loop.rs (412 lines, deterministic xorshift32
room simulator, HT20/52-subcarrier/20Hz, same fingerprint family as
the ADR-135 roundtrip test) now drives the CLI's exact stage order
through the public API:

  1. baseline  — 600 static frames, zero motion flags post-warmup,
                 calibration_uuid() exactly as the CLI derives it
  2. enroll    — all 8 AnchorLabel::SEQUENCE anchors through
                 AnchorQualityGate::default(), session is_complete()
  3. extract   — AnchorFeature::from_series recovers injected 0.25Hz
                 and 0.125Hz breathing within ±0.04Hz
  4. train     — SpecialistBank::train fits all 6 specialists; JSON
                 round-trip and the runtime consumes the RELOADED bank
  5. infer     — positive: never-enrolled 0.30Hz subject reads present,
                 18±2 BPM; negative: empty window reads absent;
                 degradation: foreign baseline_id flags STALE

Seed-robust (5 seeds), passes with and without default features:
36 unit + 1 integration green.

Validation docs updated (ADR-151 §7 + integration doc §7 matrix): what
remains is strictly the on-target hardware session (real CSI, physically
empty room, operator performing the guided anchors). Three behavioral
findings from building the test are recorded for pre-session triage:
z-band squeeze between baseline motion flagging (z>2.0) and the still-
anchor gate (presence_z≥1.5) — likeliest on-hardware enroll failure;
variance-only PresenceSpecialist missing motionless-person mean shift;
ungated breathing_hz/heart_hz in noise-window embeddings.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(calibration): close all four ADR-152 behavioral findings pre-hardware-session

The full-loop integration test surfaced three findings; fixing the third
exposed a fourth. All four are fixed and regression-guarded:

1. z-band squeeze (enrollment.rs) — anchor motion is now measured from
   frame-to-frame deltas of the deviation series (|Δz| > Z_DELTA_MOTION
   0.5 ∨ |Δφ| > π/6), not from the absolute motion_flagged, which fires
   at amplitude_z_median > 2.0 vs the EMPTY baseline and so conflated
   presence strength with motion. A strongly-reflecting still person
   (z = 3.0 — every frame flagged by the old heuristic) now enrolls.
   The old unit tests mocked (z=3.0, motion=false), a combination the
   real deviation() can never emit — which is exactly how the squeeze
   hid; tests now derive the flag from z the way the producer does.

2. variance-only presence (specialist.rs) — PresenceSpecialist gains a
   mean-shift channel: present when variance > threshold OR
   |mean − empty_mean| > mean_dist_threshold (trained at half the
   empty→occupied mean distance, None when the means don't separate).
   Detects the motionless person whose body raises the scalar mean but
   not its variance. Old persisted banks deserialize with the channel
   inert (serde default None) — variance-only behavior preserved,
   proven by a fixture test against pre-change JSON.

3. ungated hz embedding (extract.rs) — Features::embedding() zeroes
   breathing_hz/heart_hz below EMBED_MIN_SCORE (0.25), keeping the
   random in-band peaks of noise windows out of the posture/anomaly
   prototype space. Raw fields stay ungated (specialists have their
   own stricter gates).

4. heart-band lag-floor leakage (extract.rs, found while fixing 3) —
   a pure 0.30 Hz breathing signal scored 0.67 in the heart band at
   3.33 Hz: out-of-band rhythm leaks as a monotonic slope whose max
   sits at the band's lag floor, so score gating alone cannot stop it.
   autocorr_dominant now requires the winning lag to be an interior
   local maximum; band-edge "peaks" are rejected, true in-band peaks
   (interior by definition) are preserved.

full_loop.rs strengthened to drive the fixes end-to-end: the StandStill
anchor is now a z=3.0 strong reflector (unenrollable pre-fix), and a new
motionless-person runtime case proves mean-channel detection at empty-
level variance.

Validation: 41 calibration unit + 1 full-loop integration + 23 CLI tests
green; cargo test --workspace --no-default-features exit 0.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-06-10 15:21:09 -04:00
15 changed files with 618 additions and 77 deletions
+7 -3
View File
@@ -221,11 +221,15 @@ class ESP32BinaryParser:
snr = float(rssi - noise_floor)
frequency = float(freq_mhz) * 1e6
bandwidth = 20e6 # default; could infer from n_subcarriers
if n_subcarriers <= 56:
# Bandwidth inference (issue #1005): HE-LTF uses a 4x denser tone
# grid than HT-LTF on the same channel width — an HE-SU frame with
# 256 bins (242 active HE20 tones) is a *20 MHz* capture, not 160.
if ppdu_byte in (1, 2, 3): # HE-SU / HE-MU / HE-TB
bandwidth = 40e6 if (flags_byte & 0x01) or n_subcarriers > 256 else 20e6
elif n_subcarriers <= 64: # ESP32 HT20 delivers the full 64-bin FFT
bandwidth = 20e6
elif n_subcarriers <= 114:
elif n_subcarriers <= 128:
bandwidth = 40e6
elif n_subcarriers <= 242:
bandwidth = 80e6
+1 -1
View File
@@ -57,7 +57,7 @@ This witness separates what was **empirically observed on real silicon today** f
| # | Claim | Why it's not verified |
|---|---|---|
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.** |
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.**<br><br>**RESOLVED WITH MEASUREMENT (2026-06-11, external — issue #1005, production deployment by @stuinfla):** the open question is answered in both directions. **IDF v5.4's driver blob downconverts** (148 B / 64-subcarrier HT frames, PPDU byte 0x00, on a confirmed-HE link); **IDF v5.5.2 delivers true HE-LTF** — 532 B frames = 256 bins (242 active HE20 tones), PPDU byte 0x01 (HE-SU), ~90% of frames, same board/AP/link. Setup: XIAO ESP32-C6 → hostapd on Intel AX210, 2.4 GHz ch 6, `ieee80211ax=1`. No firmware change required (`acquire_csi_su=1` was already set); the gate was purely the IDF driver version. Three C6 nodes ran this mode simultaneously with ADR-110 ESP-NOW sync. Requires the issue-#1005 version-guard fix in `c6_sync_espnow.c` to build on v5.5.x. |<br><br>**REPLICATED IN-HOUSE (2026-06-11):** same source + fix, fresh IDF v5.5.2 toolchain, original COM12 board (`20:6e:f1:17:00:84`), AP `ruv.net` (11ax 2.4 GHz): **84% of 1,525 captured frames at 532 B / PPDU 0x01 (HE-SU)**, HT minority 148 B / 0x00. Evidence grade: MEASURED (two independent rigs). |
| **B2** | "TWT-bounded deterministic CSI cadence (10 ms wake)" | No 11ax AP in range. The TWT setup *call* was exercised live and the graceful fallback path is now correct (A9), but the agreement itself was never accepted. **Validate by associating with an 11ax AP that has TWT Responder=1, then capturing the timestamped CSI cadence vs the wall clock.** |
| **B3** | "±100 µs cross-node alignment over 802.15.4" | 3 boards initialized their radios with correct EUIs (A4/A5), but **none stepped down from candidate-leader to follower** during repeated 35-second multi-board captures. <br><br>**Coex hypothesis REJECTED**: rebuilt + reflashed all 3 boards with `CONFIG_C6_TIMESYNC_CHANNEL=26` (2480 MHz, non-overlapping with WiFi ch 5 at 2432 MHz). Result identical: 3× candidate, 0× "stepping down". So 2.4 GHz radio coex was NOT the cause. <br><br>**Current leading hypothesis**: OpenThread (CONFIG_OPENTHREAD_ENABLED=y) owns the 802.15.4 radio when its stack is initialized — our weak-symbol overrides of `esp_ieee802154_receive_done` / `_transmit_done` may never be called because OpenThread registers strong handlers. Validation in progress: rebuilding with `CONFIG_OPENTHREAD_ENABLED=n` (raw 802.15.4 only, our beacon protocol is private — no need for the Thread stack). If leader election fires under raw-15.4-only, hypothesis confirmed. <br><br>If raw-only also fails, next move is to dump the actual PHY frame bytes via the IEEE 802.15.4 sniffer mode on a 4th board and diagnose at the frame level. |
| **B4** | "~5 µA hibernation for battery seed nodes" | No INA / Joulescope current measurement available on this bench. The shipped code uses `esp_deep_sleep_enable_gpio_wakeup` (ext1 path, ESP-IDF default ~10 µA), not a true LP-core polling program. The 5 µA number is the C6 datasheet figure for ULP-level hibernation, not a measured value. **Validate by hooking an INA219/INA226 between the dev board's 3V3 rail and the regulator output, then averaging current over a 60-second cycle with the LP-core armed.** |
@@ -19,7 +19,7 @@ The production CSI node firmware (`firmware/esp32-csi-node`) was built around th
| C6 capability | What it enables for sensing | Why we can't get it on S3 |
|---|---|---|
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding | S3 radio is HT-only (n) |
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding. **Hardware-confirmed 2026-06-11** (issue #1005, external production deployment): requires **ESP-IDF ≥ 5.5** — the v5.4 driver blob silently downconverts to 64-subcarrier HT even on a confirmed-HE link; v5.5.2 delivers 532 B frames = 256 bins (242 active tones), PPDU 0x01 (HE-SU). See WITNESS-LOG-110 §B1 (resolved). | S3 radio is HT-only (n) |
| **802.15.4 (Thread / Zigbee)** | Cross-node time-sync over a separate radio — frees Wi-Fi airtime for CSI, ±100 µs alignment possible without coordination traffic on the sensing channel | S3 has no 802.15.4 |
| **TWT (Target Wake Time)** | Sensor negotiates a deterministic wake slot with the AP; CSI cadence becomes scheduler-bounded instead of opportunistic | Requires 802.11ax — S3 can't speak it |
| **LP-core + hibernation (~5 µA)** | Always-on motion gate runs on a separate RISC-V LP core in deep sleep; HP core stays off until a real event | S3 ULP is FSM-only, ~10 µA floor |
@@ -151,9 +151,13 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
* Both signatures ignore the address-side argument here — we only inspect
* `status` to bump the TX-fail counter — so the body is identical; only the
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
* function-pointer type differs.
*
* Issue #1005: Espressif backported the new signature to v5.5
* (`esp_now_send_info_t` = typedef of `wifi_tx_info_t` there), so the guard
* must be the full version triple, not ESP_IDF_VERSION_MAJOR.
*/
#if ESP_IDF_VERSION_MAJOR >= 6
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
{
(void)tx_info;
@@ -0,0 +1,48 @@
/* Host-fuzzing stub for esp_netif.h (ADR-061).
*
* csi_collector.c's #954 self-ping needs the STA netif handle + gateway IP.
* In the fuzz environment there is no network stack: the handle lookup
* returns NULL, so csi_start_self_ping() takes its no-gateway early-out and
* the esp_ping path is never exercised (but must compile and link).
*/
#pragma once
#include <stdint.h>
#include <stdio.h>
#include "esp_err.h"
typedef struct esp_netif_obj esp_netif_t;
typedef struct {
uint32_t addr;
} esp_ip4_addr_t;
typedef struct {
esp_ip4_addr_t ip;
esp_ip4_addr_t netmask;
esp_ip4_addr_t gw;
} esp_netif_ip_info_t;
static inline esp_netif_t *esp_netif_get_handle_from_ifkey(const char *if_key)
{
(void)if_key;
return NULL; /* no netif in fuzz env -> self-ping early-out */
}
static inline esp_err_t esp_netif_get_ip_info(esp_netif_t *netif, esp_netif_ip_info_t *ip_info)
{
(void)netif;
(void)ip_info;
return ESP_FAIL;
}
static inline char *esp_ip4addr_ntoa(const esp_ip4_addr_t *addr, char *buf, int buflen)
{
if (buf != NULL && buflen > 0) {
snprintf(buf, (size_t)buflen, "%u.%u.%u.%u",
(unsigned)(addr->addr & 0xff), (unsigned)((addr->addr >> 8) & 0xff),
(unsigned)((addr->addr >> 16) & 0xff), (unsigned)((addr->addr >> 24) & 0xff));
}
return buf;
}
@@ -0,0 +1,20 @@
/* Host-fuzzing stub for lwip/ip_addr.h (ADR-061). Minimal surface for the
* #954 self-ping block; never functionally exercised in the fuzz env. */
#pragma once
#include <stdint.h>
typedef struct {
uint32_t addr;
uint8_t type;
} ip_addr_t;
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr)
{
(void)cp;
if (addr != NULL) {
addr->addr = 0;
addr->type = 0;
}
return 1;
}
@@ -0,0 +1,79 @@
/* Host-fuzzing stub for ping/ping_sock.h (ADR-061). The #954 self-ping is
* unreachable in the fuzz env (esp_netif stub returns no gateway), but the
* symbols must compile and link. */
#pragma once
#include <stdint.h>
#include "esp_err.h"
#include "lwip/ip_addr.h"
typedef void *esp_ping_handle_t;
typedef void (*esp_ping_cb_t)(esp_ping_handle_t hdl, void *args);
typedef struct {
uint32_t count;
uint32_t interval_ms;
uint32_t timeout_ms;
uint32_t data_size;
uint8_t tos;
int ttl;
ip_addr_t target_addr;
uint32_t task_stack_size;
uint32_t task_prio;
uint32_t interface;
} esp_ping_config_t;
#define ESP_PING_COUNT_INFINITE (0)
#define ESP_PING_DEFAULT_CONFIG() \
{ \
.count = 5, \
.interval_ms = 1000, \
.timeout_ms = 1000, \
.data_size = 64, \
.tos = 0, \
.ttl = 64, \
.target_addr = {0, 0}, \
.task_stack_size = 2048, \
.task_prio = 2, \
.interface = 0, \
}
typedef struct {
void *cb_args;
esp_ping_cb_t on_ping_success;
esp_ping_cb_t on_ping_timeout;
esp_ping_cb_t on_ping_end;
} esp_ping_callbacks_t;
static inline esp_err_t esp_ping_new_session(const esp_ping_config_t *config,
const esp_ping_callbacks_t *cbs,
esp_ping_handle_t *hdl_out)
{
(void)config;
(void)cbs;
if (hdl_out != NULL) {
*hdl_out = (void *)0;
}
return ESP_FAIL; /* never starts a ping task in the fuzz env */
}
static inline esp_err_t esp_ping_start(esp_ping_handle_t hdl)
{
(void)hdl;
return ESP_OK;
}
static inline esp_err_t esp_ping_stop(esp_ping_handle_t hdl)
{
(void)hdl;
return ESP_OK;
}
static inline esp_err_t esp_ping_delete_session(esp_ping_handle_t hdl)
{
(void)hdl;
return ESP_OK;
}
+59 -24
View File
@@ -8,22 +8,24 @@
//!
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
//!
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
//!
//! Offset Size Field
//! ────── ──── ─────────────────────────────────────────────────────────────
//! 0 4 Magic: 0xC511_0001 (LE u32)
//! 4 1 node_id (u8)
//! 5 1 n_antennas (u8)
//! 6 1 n_subcarriers (u8)
//! 7 1 (reserved)
//! 8 2 freq_mhz (LE u16)
//! 10 4 sequence (LE u32)
//! 14 1 rssi (i8)
//! 15 1 noise_floor (i8)
//! 16 4 (reserved / padding)
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
//! 8 4 freq_mhz (LE u32)
//! 12 4 sequence (LE u32)
//! 16 1 rssi (i8)
//! 17 1 noise_floor (i8)
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
//!
//! This parser mirrors `parse_esp32_frame` in
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
use anyhow::{bail, Result};
use clap::Args;
@@ -261,11 +263,15 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
let node_id = buf[4];
let n_antennas = buf[5] as usize;
let n_subcarriers = buf[6] as usize;
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi = buf[14] as i8;
let noise_floor = buf[15] as i8;
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi = buf[16] as i8;
let noise_floor = buf[17] as i8;
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
let n_pairs = n_antennas * n_subcarriers;
let iq_start = 20usize;
@@ -414,24 +420,53 @@ mod tests {
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
buf[4] = 12; // node_id
buf[5] = 1; // n_antennas
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
buf[16] = (-40i8) as u8; // rssi
buf[17] = (-87i8) as u8; // noise floor
buf[18] = ppdu;
buf[19] = 0x10; // time-sync valid
for k in 0..n_subcarriers as usize {
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
buf[20 + k * 2 + 1] = (k % 50) as u8;
}
buf
}
#[test]
fn test_parse_csi_packet_valid() {
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
// Magic 0xC511_0001 LE
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
buf[5] = 1; // n_antennas
buf[6] = 2; // n_subcarriers
// freq_mhz = 2437 (channel 6)
buf[8] = 0x85; buf[9] = 0x09;
// IQ pairs at offset 20: (10, 20), (5, 15)
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
let buf = build_frame(2, 0);
let frame = parse_csi_packet(&buf, "ht20");
assert!(frame.is_some());
let f = frame.unwrap();
assert_eq!(f.num_spatial_streams(), 1);
assert_eq!(f.num_subcarriers(), 2);
assert_eq!(f.metadata.rssi_dbm, -40);
assert_eq!(f.metadata.noise_floor_dbm, -87);
}
#[test]
fn test_parse_csi_packet_he_su_256_bins() {
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
let buf = build_frame(256, 1);
assert_eq!(buf.len(), 532); // matches the live wire size
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
assert_eq!(f.num_subcarriers(), 256);
assert_eq!(f.metadata.rssi_dbm, -40);
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
assert!(he.record(&f).is_ok());
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
assert!(ht.record(&f).is_err());
}
#[test]
@@ -16,7 +16,8 @@
//! 12 4 Sequence number (LE u32)
//! 16 1 RSSI (i8)
//! 17 1 Noise floor (i8)
//! 18 2 Reserved
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
//! ```
//!
@@ -240,12 +241,31 @@ impl Esp32CsiParser {
}
}
// Determine bandwidth from subcarrier count
let bandwidth = match n_subcarriers {
0..=56 => Bandwidth::Bw20,
57..=114 => Bandwidth::Bw40,
115..=242 => Bandwidth::Bw80,
_ => Bandwidth::Bw160,
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
//
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
// For HE frames the firmware also writes the bandwidth into byte 19
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
//
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
// channel, issue #1005).
let bandwidth = if ppdu_type.is_he() {
if adr018_flags.bw40 || n_subcarriers > 256 {
Bandwidth::Bw40
} else {
Bandwidth::Bw20
}
} else {
match n_subcarriers {
0..=64 => Bandwidth::Bw20,
65..=128 => Bandwidth::Bw40,
129..=242 => Bandwidth::Bw80,
_ => Bandwidth::Bw160,
}
};
let frame = CsiFrame {
+3 -1
View File
@@ -49,7 +49,9 @@ pub mod sync_packet;
pub mod radio_ops;
pub use bridge::CsiData;
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
pub use csi_frame::{
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
};
pub use error::ParseError;
pub use esp32_parser::{
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
@@ -0,0 +1,90 @@
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
//!
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
//! traffic. They are the canonical regression fixtures for the non-fixed
//! subcarrier count introduced by HE-LTF.
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn live_he_su_frame_532_bytes_parses_with_256_subcarriers() {
let data = unhex(HE_FRAME_HEX);
assert_eq!(data.len(), 532);
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
assert_eq!(consumed, 532);
assert_eq!(frame.metadata.node_id, 12);
assert_eq!(frame.metadata.n_antennas, 1);
assert_eq!(frame.metadata.n_subcarriers, 256);
assert_eq!(frame.subcarrier_count(), 256);
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
assert_eq!(frame.metadata.sequence, 11610);
assert_eq!(frame.metadata.rssi_dbm, -40);
assert_eq!(frame.metadata.noise_floor_dbm, -87);
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
assert!(frame.metadata.ppdu_type.is_he());
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
assert!(!frame.metadata.adr018_flags.bw40);
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
assert!(frame.is_valid());
}
#[test]
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
let data = unhex(HT_FRAME_HEX);
assert_eq!(data.len(), 148);
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
assert_eq!(consumed, 148);
assert_eq!(frame.metadata.node_id, 12);
assert_eq!(frame.metadata.n_subcarriers, 64);
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
assert_eq!(frame.metadata.sequence, 11622);
assert_eq!(frame.metadata.rssi_dbm, -79);
assert_eq!(frame.metadata.noise_floor_dbm, -87);
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
assert!(!frame.metadata.ppdu_type.is_he());
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
assert!(frame.is_valid());
}
#[test]
fn live_interleaved_stream_parses_both_grids() {
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
let mut stream = unhex(HE_FRAME_HEX);
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
assert_eq!(frames.len(), 3);
assert_eq!(consumed, 532 + 148 + 532);
assert_eq!(frames[0].metadata.n_subcarriers, 256);
assert_eq!(frames[1].metadata.n_subcarriers, 64);
assert_eq!(frames[2].metadata.n_subcarriers, 256);
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
}
@@ -3,6 +3,7 @@
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
use std::collections::{HashMap, VecDeque};
use wifi_densepose_hardware::PpduType;
use crate::adaptive_classifier;
use crate::types::*;
@@ -84,6 +85,18 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
})
}
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
///
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
/// I/Q pairs from @20.
///
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
if buf.len() < 20 {
return None;
@@ -95,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
let node_id = buf[4];
let n_antennas = buf[5];
let n_subcarriers = buf[6];
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi_raw = buf[14] as i8;
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi_raw = buf[16] as i8;
let rssi = if rssi_raw > 0 {
rssi_raw.saturating_neg()
} else {
rssi_raw
};
let noise_floor = buf[15] as i8;
let noise_floor = buf[17] as i8;
let ppdu_type = PpduType::from_byte(buf[18]);
let iq_start = 20;
let n_pairs = n_antennas as usize * n_subcarriers as usize;
@@ -131,6 +146,7 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
sequence,
rssi,
noise_floor,
ppdu_type,
amplitudes,
phases,
})
@@ -964,11 +980,12 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
magic: 0xC511_0001,
node_id: 1,
n_antennas: 1,
n_subcarriers: n_sub as u8,
n_subcarriers: n_sub as u16,
freq_mhz: 2437,
sequence: tick as u32,
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
noise_floor: -90,
ppdu_type: PpduType::HtLegacy,
amplitudes,
phases,
}
@@ -981,3 +998,76 @@ pub fn chrono_timestamp() -> u64 {
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
#[cfg(test)]
mod adr110_tests {
use super::*;
use crate::types::NodeState;
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
/// Verbatim 148-byte HT payload from the same node seconds later:
/// 64 bins, byte18=0x00.
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn live_he_su_frame_parses_with_256_subcarriers() {
let buf = unhex(HE_FRAME_HEX);
assert_eq!(buf.len(), 532);
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
assert_eq!(f.node_id, 12);
assert_eq!(f.n_subcarriers, 256);
assert_eq!(f.amplitudes.len(), 256);
assert_eq!(f.freq_mhz, 2432);
assert_eq!(f.sequence, 11610);
assert_eq!(f.rssi, -40);
assert_eq!(f.noise_floor, -87);
assert_eq!(f.ppdu_type, PpduType::HeSu);
}
#[test]
fn live_ht_frame_parses_with_64_subcarriers() {
let buf = unhex(HT_FRAME_HEX);
assert_eq!(buf.len(), 148);
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
assert_eq!(f.node_id, 12);
assert_eq!(f.n_subcarriers, 64);
assert_eq!(f.amplitudes.len(), 64);
assert_eq!(f.rssi, -79);
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
}
#[test]
fn grid_gate_never_mixes_ht_and_he_windows() {
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
let mut ns = NodeState::new();
// First frame locks the grid.
assert!(ns.accept_grid(ht.grid()));
ns.frame_history.push_back(ht.amplitudes.clone());
// HE upgrade: accepted, denser grid wins, history re-keyed.
assert!(ns.accept_grid(he.grid()));
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
ns.frame_history.push_back(he.amplitudes.clone());
// Interleaved HT minority frames are rejected from the feature path.
assert!(!ns.accept_grid(ht.grid()));
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
// Steady-state HE frames keep flowing.
assert!(ns.accept_grid(he.grid()));
}
}
@@ -226,15 +226,28 @@ struct Esp32Frame {
magic: u32,
node_id: u8,
n_antennas: u8,
n_subcarriers: u8,
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
n_subcarriers: u16,
freq_mhz: u16,
sequence: u32,
rssi: i8,
noise_floor: i8,
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
ppdu_type: wifi_densepose_hardware::PpduType,
amplitudes: Vec<f64>,
phases: Vec<f64>,
}
impl Esp32Frame {
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
(self.n_subcarriers, self.ppdu_type)
}
}
/// Sensing update broadcast to WebSocket clients
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SensingUpdate {
@@ -442,6 +455,12 @@ struct NodeState {
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
/// 1 = no overlap). Consumed by the model-wake gate downstream.
pub(crate) last_novelty_score: Option<f32>,
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
/// node's rolling windows were built on. ESP32-C6 nodes interleave
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
/// the two symbol grids in `frame_history` corrupts variance/baseline
/// statistics. See [`NodeState::accept_grid`].
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
}
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
@@ -647,6 +666,35 @@ impl NodeState {
),
),
last_novelty_score: None,
active_grid: None,
}
}
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
/// may enter this node's feature path, and update `active_grid`.
///
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
/// frame after HT 64-bin history) the rolling amplitude history and
/// motion baseline are cleared so HT and HE symbol grids are never
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
/// path; the caller still records the arrival for fps/liveness.
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
match self.active_grid {
None => {
self.active_grid = Some(grid);
true
}
Some(active) if active == grid => true,
Some((active_n, _)) if grid.0 > active_n => {
self.active_grid = Some(grid);
self.frame_history.clear();
self.baseline_motion = 0.0;
self.baseline_frames = 0;
true
}
Some(_) => false,
}
}
@@ -1374,19 +1422,25 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
// [17] noise_floor (i8)
// [18..19] reserved
// [20..] I/Q data
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
let node_id = buf[4];
let n_antennas = buf[5];
let n_subcarriers = buf[6];
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi_raw = buf[14] as i8;
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
let freq_mhz =
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi_raw = buf[16] as i8;
// Fix RSSI sign: ensure it's always negative (dBm convention).
let rssi = if rssi_raw > 0 {
rssi_raw.saturating_neg()
} else {
rssi_raw
};
let noise_floor = buf[15] as i8;
let noise_floor = buf[17] as i8;
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
let iq_start = 20;
let n_pairs = n_antennas as usize * n_subcarriers as usize;
@@ -1415,6 +1469,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
sequence,
rssi,
noise_floor,
ppdu_type,
amplitudes,
phases,
})
@@ -2296,11 +2351,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
magic: 0xC511_0001,
node_id: 0,
n_antennas: 1,
n_subcarriers: obs_count.min(255) as u8,
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
freq_mhz: 2437,
sequence: seq,
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
amplitudes: multi_ap_frame.amplitudes.clone(),
phases: multi_ap_frame.phases.clone(),
};
@@ -2482,6 +2538,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
sequence: seq,
rssi: rssi_dbm as i8,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
amplitudes: vec![signal_pct],
phases: vec![0.0],
};
@@ -2615,7 +2672,11 @@ async fn probe_esp32(port: u16) -> bool {
let addr = format!("0.0.0.0:{port}");
match UdpSocket::bind(&addr).await {
Ok(sock) => {
let mut buf = [0u8; 256];
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
// capture is 532 bytes (issue #1005); on Windows a too-small
// recv buffer makes recv_from error on the oversized datagram,
// which made this probe fail against HE-only streams.
let mut buf = [0u8; 2048];
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
_ => false,
@@ -2644,11 +2705,12 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
magic: 0xC511_0001,
node_id: 1,
n_antennas: 1,
n_subcarriers: n_sub as u8,
n_subcarriers: n_sub as u16,
freq_mhz: 2437,
sequence: tick as u32,
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
amplitudes,
phases,
}
@@ -5231,6 +5293,34 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
s.source = "esp32".to_string();
s.last_esp32_frame = Some(std::time::Instant::now());
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
// with HT 64-bin frames on the same socket. HT-LTF and
// HE-LTF symbol grids are not bin-comparable, so a frame
// on a different grid than the node's rolling window must
// not enter the feature path. Policy (NodeState::accept_grid):
// lock onto the densest grid seen, clear+re-warm on
// upgrade, skip sparser-grid frames (arrival still
// recorded for fps/liveness).
let grid_accepted = s
.node_states
.entry(frame.node_id)
.or_insert_with(NodeState::new)
.accept_grid(frame.grid());
if !grid_accepted {
debug!(
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
frame.node_id,
frame.n_subcarriers,
frame.ppdu_type,
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
);
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
ns.observe_csi_frame_arrival(std::time::Instant::now());
}
continue;
}
// Also maintain global frame_history for backward compat
// (simulation path, REST endpoints, etc.).
s.frame_history.push_back(frame.amplitudes.clone());
@@ -12,6 +12,7 @@ use crate::rvf_container::RvfContainerInfo;
use crate::rvf_pipeline::ProgressiveLoader;
use crate::vital_signs::{VitalSignDetector, VitalSigns};
use wifi_densepose_hardware::PpduType;
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
@@ -84,15 +85,33 @@ pub struct Esp32Frame {
pub magic: u32,
pub node_id: u8,
pub n_antennas: u8,
pub n_subcarriers: u8,
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
pub n_subcarriers: u16,
pub freq_mhz: u16,
pub sequence: u32,
pub rssi: i8,
pub noise_floor: i8,
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
pub ppdu_type: PpduType,
pub amplitudes: Vec<f64>,
pub phases: Vec<f64>,
}
impl Esp32Frame {
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
/// this frame was sampled on. Frames from different grids must never be
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
pub fn grid(&self) -> CsiGrid {
(self.n_subcarriers, self.ppdu_type)
}
}
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
pub type CsiGrid = (u16, PpduType);
// ── Sensing Update ──────────────────────────────────────────────────────────
/// Sensing update broadcast to WebSocket clients
@@ -281,6 +300,14 @@ pub struct NodeState {
/// `None` until the first `update_novelty` call. Consumed by the
/// model-wake gate downstream (low novelty → skip CNN, save energy).
pub last_novelty_score: Option<f32>,
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
/// node's rolling windows were built on. ESP32-C6 nodes interleave
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
/// the two symbol grids in `frame_history` corrupts variance/baseline
/// statistics. Policy: lock onto the densest grid seen; frames on a
/// sparser grid are counted as arrivals but skipped by the feature
/// path; a grid upgrade clears the history and re-warms the baseline.
pub active_grid: Option<CsiGrid>,
}
impl Default for NodeState {
@@ -322,6 +349,35 @@ impl NodeState {
NOVELTY_SKETCH_VERSION,
)),
last_novelty_score: None,
active_grid: None,
}
}
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
/// may enter this node's feature path, and update `active_grid`.
///
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
/// history) the rolling amplitude history and motion baseline are
/// cleared so HT and HE symbol grids are never mixed in one window.
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
/// rejected from the feature path.
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
match self.active_grid {
None => {
self.active_grid = Some(grid);
true
}
Some(active) if active == grid => true,
Some((active_n, _)) if grid.0 > active_n => {
// Denser grid wins: re-key the window and re-warm baselines.
self.active_grid = Some(grid);
self.frame_history.clear();
self.baseline_motion = 0.0;
self.baseline_frames = 0;
true
}
Some(_) => false,
}
}
@@ -13,19 +13,19 @@ use std::time::Duration;
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
///
/// Format (ADR-018):
/// [0..3] magic: 0xC511_0001 (LE)
/// [4] node_id
/// [5] n_antennas (1)
/// [6] n_subcarriers (e.g., 32)
/// [7] reserved
/// [8..9] freq_mhz (2437 = channel 6)
/// [10..13] sequence (LE u32)
/// [14] rssi (signed)
/// [15] noise_floor
/// [16..19] reserved
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
/// [0..3] magic: 0xC511_0001 (LE)
/// [4] node_id
/// [5] n_antennas (1)
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
/// [12..15] sequence (LE u32)
/// [16] rssi (signed)
/// [17] noise_floor
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
/// [19] flags (ADR-110)
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
let n_pairs = n_sub as usize;
let mut buf = vec![0u8; 20 + n_pairs * 2];
@@ -35,18 +35,19 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
buf[4] = node_id;
buf[5] = 1; // n_antennas
buf[6] = n_sub;
buf[7] = 0;
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
// freq = 2437 MHz (channel 6)
let freq: u16 = 2437;
buf[8..10].copy_from_slice(&freq.to_le_bytes());
let freq: u32 = 2437;
buf[8..12].copy_from_slice(&freq.to_le_bytes());
// sequence
buf[10..14].copy_from_slice(&seq.to_le_bytes());
buf[12..16].copy_from_slice(&seq.to_le_bytes());
buf[14] = rssi as u8;
buf[15] = (-90i8) as u8; // noise floor
buf[16] = rssi as u8;
buf[17] = (-90i8) as u8; // noise floor
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
buf[19] = 0; // ADR-110 flags
// Generate I/Q pairs with node-specific patterns.
// Different nodes produce different amplitude patterns so the server
@@ -136,7 +137,7 @@ fn test_multi_node_udp_send() {
sock.set_write_timeout(Some(Duration::from_millis(100)))
.ok();
let n_sub = 32u8;
let n_sub = 32u16;
let node_ids = [1u8, 2, 3, 5, 7];
for &nid in &node_ids {
@@ -161,11 +162,13 @@ fn test_multi_node_udp_send() {
/// size for various subcarrier counts (boundary testing).
#[test]
fn test_frame_sizes() {
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
let frame = build_csi_frame(1, 0, -50, n_sub);
let expected = 20 + (n_sub as usize) * 2;
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
}
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
}
/// Simulate a mesh of N nodes sending frames at different rates.