diff --git a/CHANGELOG.md b/CHANGELOG.md index b0dac6a8..12ad795c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK. - **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1–FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`. - - **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture; the pcap timestamp stamps each frame). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — `nexmonDecodeRecords`/`nexmonDecodePcap`/`inspectNexmonPcap`/`decodeChanspec`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap` → `.rvcsi`), `inspect`, `inspect-nexmon`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load. - - **Tests:** 161 across the rvcsi crates (core 29, dsp 28, events 18, adapter-file 20 + 1 doctest, adapter-nexmon 22 — round-tripping through the C shim and synthetic libpcap files, ruvector 20 + 1 doctest, runtime 13, cli 9), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Not yet wired in: live radio capture, the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates. + - **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types) and a **Nexmon-chip / Raspberry-Pi-model registry** (`NexmonChip` / `RaspberryPiModel` — including the **Raspberry Pi 5** (CYW43455/BCM43455c0, same wireless as the Pi 4 — 20/40/80 MHz, 2.4+5 GHz, 64/128/256 subcarriers), the Pi 3B+/4/400, and the Pi Zero 2 W (BCM43436b0); `nexmon_adapter_profile` / `raspberry_pi_profile` build the per-chip `AdapterProfile`; `chip_ver` words auto-resolve to a chip). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture — the pcap timestamp stamps each frame; the chip is auto-detected from `chip_ver`, overridable via `.with_pi_model(Pi5)` / `.with_chip(...)`). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — `nexmonDecodeRecords`/`nexmonDecodePcap` (with optional `chip`)/`inspectNexmonPcap`/`decodeChanspec`/`nexmonChipName`/`nexmonProfile`/`nexmonChips`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap [--chip pi5]` → `.rvcsi`), `inspect`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load. + - **Tests:** 168 across the rvcsi crates (core 29, dsp 28, events 18, adapter-file 20 + 1 doctest, adapter-nexmon 28 — round-tripping through the C shim and synthetic libpcap files, incl. Pi 5 / chip-detection, ruvector 20 + 1 doctest, runtime 13, cli 10), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Not yet wired in: live radio capture, the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates. - **`wifi-densepose-train`: `signal_features` module — wires `wifi-densepose-signal` into the training pipeline.** `wifi-densepose-signal` was previously a phantom dependency of `wifi-densepose-train` (listed in `Cargo.toml`, never imported). New `wifi_densepose_train::signal_features::extract_signal_features` (and `CsiSample::signal_features()`) run a windowed CSI observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (`FEATURE_LEN = 12`) amplitude/phase/PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and not yet fed back into the loss. Surfaced by the 2026-05-11 training-pipeline audit (findings #1 "vitals features absent from training" and #2 "`wifi-densepose-signal` ghost dep"). - **`wifi-densepose-train`: `TrainingConfig` subcarrier-layout presets + a real-loader integration test.** New `TrainingConfig::for_subcarriers(native, target)` plus named presets `ht40_192()` (≈192-sc ESP32 HT40 → 56) and `multiband_168()` (168-sc ADR-078 multi-band mesh → 56), so non-MM-Fi CSI shapes are first-class instead of requiring manual `native_subcarriers`/`num_subcarriers` overrides; field docs now list the supported source counts and the multi-NIC mapping. New `tests/test_real_loader.rs` round-trips synthetic CSI through `.npy` files → `MmFiDataset::discover`/`get` (including the subcarrier-interpolation branch and the empty-root case) — exercising the on-disk loader path the deterministic `verify-training` proof intentionally bypasses. Addresses training-pipeline audit findings #6 (56-sc/1-NIC config default) and #7 (multi-band mesh not in config); the #4 concern ("proof uses synthetic data") is reframed — the proof *should* use a reproducible source, and this test covers the real loader it skips. diff --git a/CLAUDE.md b/CLAUDE.md index f13d8c93..9ca13130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`). | `rvcsi-dsp` | rvCSI: reusable DSP stages (DC removal, phase unwrap, Hampel, smoothing, variance, baseline subtraction, motion/presence/breathing features, `SignalPipeline`) | | `rvcsi-events` | rvCSI: `WindowBuffer` + `EventDetector` state machines (presence/motion/quality/baseline-drift) + `EventPipeline` | | `rvcsi-adapter-file` | rvCSI: `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` (deterministic replay) | -| `rvcsi-adapter-nexmon` | rvCSI: the **napi-c** seam — `native/rvcsi_nexmon_shim.{c,h}` (the only C; ABI 1.1; rvCSI-record + real nexmon_csi UDP + chanspec; `build.rs`+`cc`) + pure-Rust pcap reader + `NexmonAdapter` / `NexmonPcapAdapter` | +| `rvcsi-adapter-nexmon` | rvCSI: the **napi-c** seam — `native/rvcsi_nexmon_shim.{c,h}` (the only C; ABI 1.1; rvCSI-record + real nexmon_csi UDP + chanspec; `build.rs`+`cc`) + pure-Rust pcap reader + Nexmon-chip / Raspberry-Pi-model registry (incl. **Pi 5** = BCM43455c0) + `NexmonAdapter` / `NexmonPcapAdapter` (chip auto-detect) | | `rvcsi-ruvector` | rvCSI: deterministic RF-memory embeddings, `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` (RuVector standin) | | `rvcsi-runtime` | rvCSI: composition layer — `CaptureRuntime` (source + validate + DSP + events) + one-shot capture/nexmon-pcap helpers | | `rvcsi-node` | rvCSI: the **napi-rs** seam — `["cdylib","rlib"]` Node addon; ships the `@ruv/rvcsi` npm package | diff --git a/README.md b/README.md index 77b79517..7e7ad04c 100644 --- a/README.md +++ b/README.md @@ -522,7 +522,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail | [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror | | [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) | | [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language | -| [rvCSI — edge RF sensing runtime](docs/prd/rvcsi-platform-prd.md) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md); 9 `rvcsi-*` crates + the `@ruv/rvcsi` napi-rs SDK) | +| [rvCSI — edge RF sensing runtime](docs/prd/rvcsi-platform-prd.md) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md); 9 `rvcsi-*` crates + the `@ruv/rvcsi` napi-rs SDK) | | [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization | | [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable | | [Extended Documentation](docs/readme-details.md) | Latest additions, key features, installation, quick start, signal processing, training, CLI, testing, deployment, and changelog | diff --git a/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md b/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md index 0f28320e..cc4bc31b 100644 --- a/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md +++ b/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md @@ -38,7 +38,7 @@ Eight new workspace members under `v2/crates/`: | `rvcsi-dsp` | no (`forbid`) | `rvcsi-core` | Reusable DSP stages (DC removal, phase unwrap, smoothing, Hampel/MAD outlier filter, sliding variance, baseline subtraction) and scalar features (motion energy, presence score, confidence, heuristic breathing-band estimate), plus a non-destructive `SignalPipeline::process_frame`. | | `rvcsi-events` | no (`forbid`) | `rvcsi-core` | `WindowBuffer` (frames → `CsiWindow`), the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, and `EventPipeline` (windows → `CsiEvent`s). | | `rvcsi-adapter-file` | no (`forbid`) | `rvcsi-core` | The `.rvcsi` capture format (JSONL: a header line + one `CsiFrame` per line), `FileRecorder`, and `FileReplayAdapter` (a `CsiSource`) — deterministic replay (D9). | -| `rvcsi-adapter-nexmon` | **yes** (FFI only) | `rvcsi-core` + the C shim | The **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` compiled via `build.rs`+`cc`, a documented `ffi` module wrapping it, a pure-Rust libpcap reader (`pcap.rs`), and two `CsiSource`s — `NexmonAdapter` (rvCSI-record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP payloads inside a `.pcap`). | +| `rvcsi-adapter-nexmon` | **yes** (FFI only) | `rvcsi-core` + the C shim | The **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` compiled via `build.rs`+`cc`, a documented `ffi` module wrapping it, a pure-Rust libpcap reader (`pcap.rs`), the Nexmon-chip / Raspberry-Pi-model registry (`chips.rs` — `NexmonChip`, `RaspberryPiModel` incl. **Pi 5**, profile builders), and two `CsiSource`s — `NexmonAdapter` (rvCSI-record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP payloads inside a `.pcap`, with chip auto-detection). | | `rvcsi-ruvector` | no (`forbid`) | `rvcsi-core` | The RuVector RF-memory bridge: deterministic `window_embedding`/`event_embedding`, `cosine_similarity`, the `RfMemoryStore` trait, and `InMemoryRfMemory` + `JsonlRfMemory` (a standin until the production RuVector binding lands). | | `rvcsi-runtime` | no (`forbid`) | core, dsp, events, adapter-file, adapter-nexmon, ruvector | The composition layer (no FFI): `CaptureRuntime` (a `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`) plus one-shot helpers (`summarize_capture`, `decode_nexmon_records`, `decode_nexmon_pcap`, `summarize_nexmon_pcap`, `events_from_capture`, `export_capture_to_rf_memory`). The shared layer under `rvcsi-node` and `rvcsi-cli`. | | `rvcsi-node` | no (`deny(clippy::all)`) | `rvcsi-core`, `rvcsi-runtime`, `rvcsi-adapter-nexmon` | The **napi-rs** seam: the `.node` addon (cdylib + rlib) exposing a safe TS-facing surface (thin `#[napi]` wrappers over `rvcsi-runtime`); `build.rs` runs `napi_build::setup()`. | @@ -65,7 +65,9 @@ Contract (both formats): - **ABI versioned.** `rvcsi_nx_abi_version()` returns `major << 16 | minor` (`0x0001_0001`); the Rust side `debug_assert`s the major matches the header it was compiled against. The minor was bumped from `1.0` → `1.1` when the format-(2) entry points landed (additive — format (1) is unchanged). - The Rust `ffi` module wraps these in safe functions (`record_len`, `decode_record`, `encode_record`, `decode_chanspec`, `parse_nexmon_udp_header`, `decode_nexmon_udp`, `encode_nexmon_udp`, `shim_abi_version`); every `unsafe` block is limited to the FFI call (and reading back C-initialised structs) and carries a `// SAFETY:` comment, per the project rule. -A real deployment captures with `tcpdump -i wlan0 dst port 5500 -w csi.pcap` on the Pi and feeds the `.pcap` to `NexmonPcapAdapter::open` (or `rvcsi record --source nexmon-pcap --in csi.pcap --out cap.rvcsi`, then the rest of the toolchain works on the `.rvcsi`). Production *live* capture (binding the UDP socket, monitor mode, firmware patch hooks) is a later increment that reuses the same shim parse path — the shim's job is the *parse*, not the *socket*. +**Chip registry (`rvcsi-adapter-nexmon::chips`).** nexmon_csi runs on a handful of patched Broadcom/Cypress chips; `NexmonChip` names them, `RaspberryPiModel` maps Pi boards to their chip, and `nexmon_adapter_profile` / `raspberry_pi_profile` build the [`AdapterProfile`] (supported channels / bandwidths / expected subcarrier counts — 20→64, 40→128, 80→256, 160→512) `validate_frame` bounds CSI frames against. The **Raspberry Pi 5** carries the same **CYW43455 / BCM43455c0** 802.11ac wireless as the Pi 3B+ / Pi 4 / Pi 400 (20/40/80 MHz, 2.4 + 5 GHz) — the chip with the most mature nexmon_csi support — so `RaspberryPiModel::Pi5 → NexmonChip::Bcm43455c0`; the Pi Zero 2 W is `Bcm43436b0` (2.4 GHz, ≤40 MHz). `NexmonPcapAdapter` **auto-detects** the chip from each packet's `chip_ver` word (`0x4345` → `Bcm43455c0`, etc.) and uses the matching profile; `.with_chip(...)` / `.with_pi_model(...)` override it. `NexmonChip::from_chip_ver` and the `chip_ver` field are best-effort/preserved respectively — the c0/b0 revision suffix isn't carried by that word, and the int16-vs-packed-float export distinction is handled by the `csi_format` selector, not by chip-ver parsing. + +A real deployment captures with `tcpdump -i wlan0 dst port 5500 -w csi.pcap` on the Pi and feeds the `.pcap` to `NexmonPcapAdapter::open` (or `rvcsi record --source nexmon-pcap --in csi.pcap --out cap.rvcsi --chip pi5`, then the rest of the toolchain works on the `.rvcsi`; `rvcsi inspect-nexmon` reports the resolved chip, `rvcsi nexmon-chips` lists the matrix). Production *live* capture (binding the UDP socket, monitor mode, firmware patch hooks) is a later increment that reuses the same shim parse path — the shim's job is the *parse*, not the *socket*. ### 2.3 The napi-rs surface — what crosses the seam @@ -123,11 +125,11 @@ A real deployment captures with `tcpdump -i wlan0 dst port 5500 -w csi.pcap` on ## 5. Status of the implementation - `rvcsi-core` — implemented, `forbid(unsafe_code)`, 29 unit tests. -- `rvcsi-adapter-nexmon` + the napi-c shim — implemented; C (ABI `1.1`) compiled via `build.rs`+`cc`; the `ffi` module wraps both record formats (rvCSI record **and** the real nexmon_csi UDP payload + chanspec decode); a pure-Rust `pcap` reader; `NexmonAdapter` + `NexmonPcapAdapter` `CsiSource`s; 22 tests, several round-tripping through the C shim and through synthetic libpcap files. +- `rvcsi-adapter-nexmon` + the napi-c shim — implemented; C (ABI `1.1`) compiled via `build.rs`+`cc`; the `ffi` module wraps both record formats (rvCSI record **and** the real nexmon_csi UDP payload + chanspec decode); a pure-Rust `pcap` reader; the Nexmon-chip / Raspberry-Pi-model registry (`chips.rs` — incl. **Pi 5 → BCM43455c0** + chip auto-detection from `chip_ver`); `NexmonAdapter` + `NexmonPcapAdapter` `CsiSource`s; 28 tests, several round-tripping through the C shim and through synthetic libpcap files. - `rvcsi-dsp` (28 tests), `rvcsi-events` (18 tests), `rvcsi-adapter-file` (20 + 1 doctest), `rvcsi-ruvector` (20 + 1 doctest) — implemented. -- `rvcsi-runtime` (13 tests) — composition layer + the one-shot helpers, including `decode_nexmon_pcap` / `summarize_nexmon_pcap`. -- `rvcsi-node` (napi-rs surface — incl. `nexmonDecodePcap` / `inspectNexmonPcap` / `decodeChanspec` / `RvcsiRuntime.openNexmonPcap`) and `rvcsi-cli` (9 tests — incl. `record --source nexmon-pcap`, `inspect-nexmon`, `decode-chanspec`) — implemented; the `@ruv/rvcsi` npm package + a Node smoke test ship alongside. -- Totals: 161 rvcsi unit/integration tests + 2 doctests, 0 failures; all rvcsi crates build together and are clippy-clean. +- `rvcsi-runtime` (13 tests) — composition layer + the one-shot helpers, including `decode_nexmon_pcap` / `decode_nexmon_pcap_for` (per-chip) / `summarize_nexmon_pcap` / `nexmon_profile_for`. +- `rvcsi-node` (napi-rs surface — incl. `nexmonDecodePcap` (with `chip`) / `inspectNexmonPcap` / `decodeChanspec` / `nexmonChipName` / `nexmonProfile` / `nexmonChips` / `RvcsiRuntime.openNexmonPcap`) and `rvcsi-cli` (10 tests — incl. `record --source nexmon-pcap [--chip pi5]`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`) — implemented; the `@ruv/rvcsi` npm package + a Node smoke test ship alongside. +- Totals: 168 rvcsi unit/integration tests + 2 doctests, 0 failures; all rvcsi crates build together and are clippy-clean. - `rvcsi-mcp` (MCP tool server), `rvcsi-daemon` (live capture + WebSocket), and the legacy nexmon *packed-float* CSI export — not in this PR; tracked as follow-ups. --- diff --git a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h index b54ce6f4..1f58c395 100644 --- a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h +++ b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h @@ -42,7 +42,7 @@ * 10 2 seq_cnt uint16 (802.11 sequence-control) * 12 2 core_stream uint16 (bits[2:0]=rx core, bits[5:3]=spatial stream) * 14 2 chanspec uint16 (Broadcom d11ac chanspec) - * 16 2 chip_ver uint16 (e.g. 0x0142 = BCM43455c0) + * 16 2 chip_ver uint16 (e.g. 0x4345 = BCM43455c0) * 18 ... CSI: nsub complex samples; for RVCSI_NX_CSI_FMT_INT16_IQ that is * 4*nsub bytes = nsub pairs of int16 LE (real, imag), raw counts. * nsub is derived from the payload length: nsub = (len - 18) / 4. diff --git a/v2/crates/rvcsi-adapter-nexmon/src/chips.rs b/v2/crates/rvcsi-adapter-nexmon/src/chips.rs new file mode 100644 index 00000000..192c9a27 --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/src/chips.rs @@ -0,0 +1,340 @@ +//! The Nexmon-supported Broadcom chip registry and Raspberry Pi model map +//! (ADR-095 D15, ADR-096) — including the **Raspberry Pi 5**. +//! +//! nexmon_csi runs on a handful of patched Broadcom/Cypress chips. This module +//! names them ([`NexmonChip`]), maps Raspberry Pi models to their chip +//! ([`RaspberryPiModel`]), resolves the on-the-wire `chip_ver` word back to a +//! chip (best-effort — the raw value is always preserved), and builds a +//! [`rvcsi_core::AdapterProfile`] (supported channels / bandwidths / expected +//! subcarrier counts) for each — so `validate_frame` can bound CSI frames +//! against the device that produced them. +//! +//! The Raspberry Pi 5 carries the same **CYW43455 (BCM43455c0)** 802.11ac +//! wireless as the Pi 3B+ / Pi 4 / Pi 400 — the chip with the most mature +//! nexmon_csi support — so Pi 5 CSI captures use the [`NexmonChip::Bcm43455c0`] +//! profile (20/40/80 MHz, 64/128/256 subcarriers, 2.4 + 5 GHz). The chip is also +//! auto-detected at runtime from each frame's `chip_ver` (see +//! [`crate::NexmonPcapAdapter`]). + +use rvcsi_core::{AdapterKind, AdapterProfile}; + +/// A Broadcom/Cypress WiFi chip nexmon_csi is known to run on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum NexmonChip { + /// BCM43455c0 / CYW43455 — 802.11ac, 2.4 + 5 GHz, 20/40/80 MHz. The + /// flagship nexmon_csi target: **Raspberry Pi 3B+, Pi 4, Pi 400 and Pi 5**, + /// plus the Pi Zero W. Modern int16 I/Q CSI export. + Bcm43455c0, + /// BCM43436b0 — 802.11n, 2.4 GHz only, 20/40 MHz. Raspberry Pi Zero 2 W. + Bcm43436b0, + /// BCM4366c0 — 802.11ac, 2.4 + 5 GHz, up to 80 MHz. ASUS RT-AC86U. Modern int16 export. + Bcm4366c0, + /// BCM4375b1 — 802.11ax-class, 2.4 + 5 GHz. Some Samsung Galaxy S10/S20. + Bcm4375b1, + /// BCM4358 — 802.11ac. Nexus 6P (and similar). Some firmwares use the legacy + /// packed-float CSI export (see [`NexmonChip::uses_int16_iq`]). + Bcm4358, + /// BCM4339 — 802.11ac. Nexus 5. Legacy packed-float CSI export. + Bcm4339, + /// A chip we don't recognise — the raw `chip_ver` word from the packet. + Unknown { + /// The `chip_ver` word as it appeared on the wire. + chip_ver: u16, + }, +} + +impl NexmonChip { + /// Stable lower-case slug (`"bcm43455c0"`, `"bcm4366c0"`, ...; `"unknown:0xNNNN"` for [`NexmonChip::Unknown`]). + pub fn slug(self) -> String { + match self { + NexmonChip::Bcm43455c0 => "bcm43455c0".to_string(), + NexmonChip::Bcm43436b0 => "bcm43436b0".to_string(), + NexmonChip::Bcm4366c0 => "bcm4366c0".to_string(), + NexmonChip::Bcm4375b1 => "bcm4375b1".to_string(), + NexmonChip::Bcm4358 => "bcm4358".to_string(), + NexmonChip::Bcm4339 => "bcm4339".to_string(), + NexmonChip::Unknown { chip_ver } => format!("unknown:0x{chip_ver:04x}"), + } + } + + /// A friendlier display name including a typical host device. + pub fn description(self) -> &'static str { + match self { + NexmonChip::Bcm43455c0 => "BCM43455c0 / CYW43455 (Raspberry Pi 3B+/4/400/5, Pi Zero W) — 802.11ac, 2.4+5 GHz", + NexmonChip::Bcm43436b0 => "BCM43436b0 (Raspberry Pi Zero 2 W) — 802.11n, 2.4 GHz", + NexmonChip::Bcm4366c0 => "BCM4366c0 (ASUS RT-AC86U) — 802.11ac, 2.4+5 GHz", + NexmonChip::Bcm4375b1 => "BCM4375b1 (Samsung Galaxy S10/S20) — 802.11ax-class, 2.4+5 GHz", + NexmonChip::Bcm4358 => "BCM4358 (Nexus 6P) — 802.11ac", + NexmonChip::Bcm4339 => "BCM4339 (Nexus 5) — 802.11ac", + NexmonChip::Unknown { .. } => "unknown Broadcom/Cypress chip", + } + } + + /// Whether this chip's nexmon_csi firmware exports CSI in the modern int16 + /// LE I/Q format ([`crate::NEXMON_CSI_FMT_INT16_IQ`]). The BCM4339 and some + /// BCM4358 firmwares use the legacy *packed-float* export instead (not yet + /// implemented by the shim — see `ffi::NEXMON_CSI_FMT_INT16_IQ`). + pub fn uses_int16_iq(self) -> bool { + !matches!(self, NexmonChip::Bcm4339 | NexmonChip::Bcm4358) + } + + /// Whether the chip supports the 5 GHz band (and therefore 802.11ac wide channels). + pub fn dual_band(self) -> bool { + matches!( + self, + NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4375b1 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339 + ) + } + + /// Resolve a `chip_ver` word from a nexmon_csi UDP header to a chip + /// (best-effort — matches the Broadcom chip-ID convention `0x4345` = BCM4345 + /// family, `0x4339`, `0x4358`, `0x4366`, `0x4375`; anything else is + /// [`NexmonChip::Unknown`]). The c0/b0 revision suffix isn't carried by this + /// word; the int16-vs-packed-float export distinction is handled separately. + pub fn from_chip_ver(chip_ver: u16) -> NexmonChip { + match chip_ver { + 0x4345 => NexmonChip::Bcm43455c0, + 0x4339 => NexmonChip::Bcm4339, + 0x4358 => NexmonChip::Bcm4358, + 0x4366 => NexmonChip::Bcm4366c0, + 0x4375 => NexmonChip::Bcm4375b1, + // 43436's chip id varies by source; treat it as unknown unless we see it. + other => NexmonChip::Unknown { chip_ver: other }, + } + } + + /// Parse a chip name/slug (`"bcm43455c0"`, `"43455c0"`, `"cyw43455"`, ...). + pub fn from_slug(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + match s.as_str() { + "bcm43455c0" | "43455c0" | "43455" | "bcm43455" | "cyw43455" => Some(NexmonChip::Bcm43455c0), + "bcm43436b0" | "43436b0" | "43436" | "bcm43436" => Some(NexmonChip::Bcm43436b0), + "bcm4366c0" | "4366c0" | "4366" | "bcm4366" => Some(NexmonChip::Bcm4366c0), + "bcm4375b1" | "4375b1" | "4375" | "bcm4375" => Some(NexmonChip::Bcm4375b1), + "bcm4358" | "4358" => Some(NexmonChip::Bcm4358), + "bcm4339" | "4339" => Some(NexmonChip::Bcm4339), + _ => None, + } + } +} + +/// 5 GHz UNII channels (a representative set; nexmon picks a control channel via `makecsiparams`). +const FIVE_GHZ_CHANNELS: &[u16] = &[ + 36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, + 153, 157, 161, 165, +]; + +fn channels_for(chip: NexmonChip) -> Vec { + let mut v: Vec = (1..=13).collect(); + if chip.dual_band() { + v.extend_from_slice(FIVE_GHZ_CHANNELS); + } + v +} + +fn bandwidths_for(chip: NexmonChip) -> Vec { + match chip { + NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339 => vec![20, 40, 80], + NexmonChip::Bcm4375b1 => vec![20, 40, 80, 160], + NexmonChip::Bcm43436b0 => vec![20, 40], + NexmonChip::Unknown { .. } => vec![20, 40, 80], + } +} + +/// Subcarrier (FFT) count per supported bandwidth: 20→64, 40→128, 80→256, 160→512. +fn subcarrier_counts_for(chip: NexmonChip) -> Vec { + bandwidths_for(chip) + .iter() + .map(|bw| (bw / 20) * 64) + .collect() +} + +/// Build the [`rvcsi_core::AdapterProfile`] for a Nexmon chip — the channels / +/// bandwidths / expected subcarrier counts `validate_frame` will bound CSI +/// frames against, plus the live-capability flags (Nexmon supports monitor mode +/// and injection on these chips). +pub fn nexmon_adapter_profile(chip: NexmonChip) -> AdapterProfile { + AdapterProfile { + adapter_kind: AdapterKind::Nexmon, + chip: Some(chip.slug()), + firmware_version: None, + driver_version: None, + supported_channels: channels_for(chip), + supported_bandwidths_mhz: bandwidths_for(chip), + expected_subcarrier_counts: subcarrier_counts_for(chip), + supports_live_capture: true, + supports_injection: true, + supports_monitor_mode: true, + } +} + +/// Raspberry Pi models with on-board WiFi that nexmon_csi can extract CSI from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RaspberryPiModel { + /// Raspberry Pi 3 Model B+ — CYW43455 / BCM43455c0. + Pi3BPlus, + /// Raspberry Pi 4 Model B — CYW43455 / BCM43455c0. + Pi4, + /// Raspberry Pi 400 — CYW43455 / BCM43455c0. + Pi400, + /// **Raspberry Pi 5** — CYW43455 / BCM43455c0 (same wireless as the Pi 4). + Pi5, + /// Raspberry Pi Zero W — CYW43438? No — the Zero W uses the BCM43438 (2.4 GHz + /// only), which nexmon_csi does **not** support; included here only so callers + /// can detect and reject it. Use a Zero 2 W instead. + PiZeroW, + /// Raspberry Pi Zero 2 W — BCM43436b0 (2.4 GHz only). + PiZero2W, +} + +impl RaspberryPiModel { + /// The Broadcom/Cypress WiFi chip on this board. + pub fn nexmon_chip(self) -> NexmonChip { + match self { + RaspberryPiModel::Pi3BPlus + | RaspberryPiModel::Pi4 + | RaspberryPiModel::Pi400 + | RaspberryPiModel::Pi5 => NexmonChip::Bcm43455c0, + RaspberryPiModel::PiZero2W => NexmonChip::Bcm43436b0, + RaspberryPiModel::PiZeroW => NexmonChip::Unknown { chip_ver: 0x4343 }, // BCM43438 — not CSI-capable + } + } + + /// Whether nexmon_csi can extract CSI from this board's WiFi. + pub fn csi_supported(self) -> bool { + !matches!(self, RaspberryPiModel::PiZeroW) + } + + /// Stable slug (`"pi5"`, `"pi4"`, `"pi3b+"`, `"pi400"`, `"pizero2w"`, `"pizerow"`). + pub fn slug(self) -> &'static str { + match self { + RaspberryPiModel::Pi3BPlus => "pi3b+", + RaspberryPiModel::Pi4 => "pi4", + RaspberryPiModel::Pi400 => "pi400", + RaspberryPiModel::Pi5 => "pi5", + RaspberryPiModel::PiZeroW => "pizerow", + RaspberryPiModel::PiZero2W => "pizero2w", + } + } + + /// Parse a model slug (accepts `pi5`, `pi 5`, `rpi5`, `raspberrypi5`, `pi3b+`/`pi3bplus`, ...). + pub fn from_slug(s: &str) -> Option { + let s: String = s.trim().to_ascii_lowercase().chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect(); + let s = s.strip_prefix("raspberrypi").or_else(|| s.strip_prefix("rpi")).unwrap_or(&s); + match s { + "pi5" | "5" => Some(RaspberryPiModel::Pi5), + "pi4" | "4" | "pi4b" => Some(RaspberryPiModel::Pi4), + "pi400" | "400" => Some(RaspberryPiModel::Pi400), + "pi3b+" | "pi3bplus" | "3b+" | "3bplus" => Some(RaspberryPiModel::Pi3BPlus), + "pizero2w" | "zero2w" | "pizero2" => Some(RaspberryPiModel::PiZero2W), + "pizerow" | "zerow" => Some(RaspberryPiModel::PiZeroW), + _ => None, + } + } +} + +/// Build the [`rvcsi_core::AdapterProfile`] for a Raspberry Pi model (its +/// [`RaspberryPiModel::nexmon_chip`]'s profile, with the `chip` string tagged +/// with the model for legibility). +pub fn raspberry_pi_profile(model: RaspberryPiModel) -> AdapterProfile { + let mut p = nexmon_adapter_profile(model.nexmon_chip()); + p.chip = Some(format!("{} ({})", model.nexmon_chip().slug(), model.slug())); + p +} + +/// The full registry of Nexmon-supported chips, for `rvcsi nexmon-chips` and SDK callers. +pub fn known_chips() -> &'static [NexmonChip] { + &[ + NexmonChip::Bcm43455c0, + NexmonChip::Bcm43436b0, + NexmonChip::Bcm4366c0, + NexmonChip::Bcm4375b1, + NexmonChip::Bcm4358, + NexmonChip::Bcm4339, + ] +} + +/// The full registry of Raspberry Pi models this crate knows about. +pub fn known_pi_models() -> &'static [RaspberryPiModel] { + &[ + RaspberryPiModel::Pi5, + RaspberryPiModel::Pi4, + RaspberryPiModel::Pi400, + RaspberryPiModel::Pi3BPlus, + RaspberryPiModel::PiZero2W, + RaspberryPiModel::PiZeroW, + ] +} + +impl crate::ffi::NexmonCsiHeader { + /// Resolve this packet's chip from its `chip_ver` word (best-effort; the raw + /// `chip_ver` field is always preserved). For a Raspberry Pi 5 (or 4/400/3B+) + /// capture this returns [`NexmonChip::Bcm43455c0`]. + pub fn chip(&self) -> NexmonChip { + NexmonChip::from_chip_ver(self.chip_ver) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pi5_uses_the_same_chip_as_pi4() { + assert_eq!(RaspberryPiModel::Pi5.nexmon_chip(), NexmonChip::Bcm43455c0); + assert_eq!(RaspberryPiModel::Pi4.nexmon_chip(), NexmonChip::Bcm43455c0); + assert!(RaspberryPiModel::Pi5.csi_supported()); + let p = raspberry_pi_profile(RaspberryPiModel::Pi5); + assert_eq!(p.adapter_kind, AdapterKind::Nexmon); + assert!(p.chip.as_deref().unwrap().contains("pi5")); + assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]); + assert_eq!(p.expected_subcarrier_counts, vec![64, 128, 256]); + assert!(p.accepts_channel(36)); // 5 GHz + assert!(p.accepts_channel(6)); // 2.4 GHz + assert!(p.accepts_subcarrier_count(256)); // VHT80 + assert!(!p.accepts_subcarrier_count(57)); + assert!(p.supports_monitor_mode && p.supports_injection); + } + + #[test] + fn chip_ver_resolution_best_effort() { + assert_eq!(NexmonChip::from_chip_ver(0x4345), NexmonChip::Bcm43455c0); + assert_eq!(NexmonChip::from_chip_ver(0x4339), NexmonChip::Bcm4339); + assert_eq!(NexmonChip::from_chip_ver(0x4366), NexmonChip::Bcm4366c0); + assert!(matches!(NexmonChip::from_chip_ver(0xABCD), NexmonChip::Unknown { chip_ver: 0xABCD })); + } + + #[test] + fn chip_traits() { + assert!(NexmonChip::Bcm43455c0.uses_int16_iq()); + assert!(!NexmonChip::Bcm4339.uses_int16_iq()); + assert!(NexmonChip::Bcm43455c0.dual_band()); + assert!(!NexmonChip::Bcm43436b0.dual_band()); + assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).supported_bandwidths_mhz, vec![20, 40]); + assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).expected_subcarrier_counts, vec![64, 128]); + // unknown chip -> a permissive-ish 802.11ac default + let u = nexmon_adapter_profile(NexmonChip::Unknown { chip_ver: 0 }); + assert_eq!(u.supported_bandwidths_mhz, vec![20, 40, 80]); + } + + #[test] + fn slug_parsing() { + assert_eq!(NexmonChip::from_slug("CYW43455"), Some(NexmonChip::Bcm43455c0)); + assert_eq!(NexmonChip::from_slug("bcm4366c0"), Some(NexmonChip::Bcm4366c0)); + assert_eq!(NexmonChip::from_slug("nope"), None); + assert_eq!(RaspberryPiModel::from_slug("Pi 5"), Some(RaspberryPiModel::Pi5)); + assert_eq!(RaspberryPiModel::from_slug("raspberry-pi-5"), Some(RaspberryPiModel::Pi5)); + assert_eq!(RaspberryPiModel::from_slug("pi3bplus"), Some(RaspberryPiModel::Pi3BPlus)); + assert_eq!(RaspberryPiModel::from_slug("pi42"), None); + assert_eq!(NexmonChip::Bcm43455c0.slug(), "bcm43455c0"); + assert_eq!(RaspberryPiModel::Pi5.slug(), "pi5"); + } + + #[test] + fn registries_nonempty_and_pi5_present() { + assert!(known_chips().contains(&NexmonChip::Bcm43455c0)); + assert!(known_pi_models().contains(&RaspberryPiModel::Pi5)); + } +} diff --git a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs index 7a304bf0..0656da7a 100644 --- a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs +++ b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs @@ -92,7 +92,7 @@ struct RvcsiNxUdpHeader { } /// `csi_format` selector for [`decode_nexmon_udp`]: `nsub` pairs of int16 LE -/// `(real, imag)` — the modern BCM43455c0 / 4358 / 4366c0 export (mirrors +/// `(real, imag)` — the modern BCM43455c0 chip ID / 4358 / 4366c0 export (mirrors /// `RVCSI_NX_CSI_FMT_INT16_IQ`). The legacy packed-float export is not yet wired. pub const NEXMON_CSI_FMT_INT16_IQ: i32 = 0; @@ -305,7 +305,7 @@ pub struct NexmonCsiHeader { pub spatial_stream: u16, /// Raw Broadcom chanspec word. pub chanspec: u16, - /// Chip version (e.g. `0x0142` = BCM43455c0). + /// Chip version (e.g. `0x4345` = BCM43455c0 chip ID). pub chip_ver: u16, /// Channel number decoded from the chanspec. pub channel: u16, @@ -560,7 +560,7 @@ mod tests { core: 1, spatial_stream: 0, chanspec, - chip_ver: 0x0142, // BCM43455c0 + chip_ver: 0x4345, // BCM43455c0 chip ID channel: 0, // filled by decode bandwidth_mhz: 0, // filled by decode is_5ghz: false, // filled by decode @@ -587,7 +587,7 @@ mod tests { assert_eq!(h.seq_cnt, 0x1234); assert_eq!(h.core, 1); assert_eq!(h.chanspec, chanspec); - assert_eq!(h.chip_ver, 0x0142); + assert_eq!(h.chip_ver, 0x4345); assert_eq!(h.channel, 6); assert_eq!(h.bandwidth_mhz, 20); assert!(!h.is_5ghz); diff --git a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs index 5c9d263e..3fef7ecb 100644 --- a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs +++ b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs @@ -25,9 +25,14 @@ use rvcsi_core::{ AdapterKind, AdapterProfile, CsiFrame, CsiSource, RvcsiError, SessionId, SourceHealth, SourceId, }; +pub mod chips; pub mod ffi; pub mod pcap; +pub use chips::{ + known_chips, known_pi_models, nexmon_adapter_profile, raspberry_pi_profile, NexmonChip, + RaspberryPiModel, +}; pub use ffi::{ decode_chanspec, decode_nexmon_udp, decode_record, encode_nexmon_udp, encode_record, parse_nexmon_udp_header, shim_abi_version, DecodedChanspec, NexmonCsiHeader, NexmonFfiError, @@ -219,6 +224,7 @@ pub struct NexmonPcapAdapter { source_id: SourceId, session_id: SessionId, profile: AdapterProfile, + detected_chip: NexmonChip, frames: Vec, headers: Vec, link_type: u32, @@ -226,9 +232,27 @@ pub struct NexmonPcapAdapter { skipped: u64, } +/// Resolve the chip when every decoded packet agrees on `chip_ver`; otherwise +/// (mixed or empty) fall back to a generic 802.11ac default. +fn detect_chip(headers: &[NexmonCsiHeader]) -> NexmonChip { + match headers.first() { + None => NexmonChip::Bcm43455c0, // a sensible default; profile stays generic-enough + Some(h0) => { + let ver = h0.chip_ver; + if headers.iter().all(|h| h.chip_ver == ver) { + NexmonChip::from_chip_ver(ver) + } else { + NexmonChip::Unknown { chip_ver: 0 } + } + } + } +} + impl NexmonPcapAdapter { /// Parse a libpcap byte buffer; `port` is the CSI UDP port to filter on - /// (`None` ⇒ [`NEXMON_DEFAULT_PORT`] = 5500). + /// (`None` ⇒ [`NEXMON_DEFAULT_PORT`] = 5500). The chip is auto-detected from + /// the packets' `chip_ver` (e.g. a Raspberry Pi 5 capture ⇒ BCM43455c0); + /// override with [`NexmonPcapAdapter::with_chip`] / [`NexmonPcapAdapter::with_pi_model`]. pub fn parse( source_id: impl Into, session_id: SessionId, @@ -271,10 +295,12 @@ impl NexmonPcapAdapter { if let Some(p) = want_port { skipped += reader.udp_payloads(None).filter(|(_, dp, _)| *dp != p).count() as u64; } + let detected_chip = detect_chip(&headers); Ok(NexmonPcapAdapter { source_id, session_id, - profile: AdapterProfile::nexmon_default(), + profile: nexmon_adapter_profile(detected_chip), + detected_chip, frames, headers, link_type, @@ -283,6 +309,28 @@ impl NexmonPcapAdapter { }) } + /// Override the validation profile to the given Nexmon chip (e.g. when the + /// `chip_ver` word is unreliable). This does not change the decoded frames. + pub fn with_chip(mut self, chip: NexmonChip) -> Self { + self.detected_chip = chip; + self.profile = nexmon_adapter_profile(chip); + self + } + + /// Override the validation profile to a Raspberry Pi model's chip + /// (`RaspberryPiModel::Pi5` ⇒ BCM43455c0, 20/40/80 MHz, 64/128/256 sc). + pub fn with_pi_model(mut self, model: RaspberryPiModel) -> Self { + self.detected_chip = model.nexmon_chip(); + self.profile = raspberry_pi_profile(model); + self + } + + /// The chip resolved from the capture's `chip_ver` words (or set via + /// [`NexmonPcapAdapter::with_chip`] / [`NexmonPcapAdapter::with_pi_model`]). + pub fn detected_chip(&self) -> NexmonChip { + self.detected_chip + } + /// Open and parse a `.pcap` file. pub fn open( source_id: impl Into, @@ -475,7 +523,7 @@ mod tests { core: 0, spatial_stream: 0, chanspec, - chip_ver: 0x0142, + chip_ver: 0x4345, channel: 0, bandwidth_mhz: 0, is_5ghz: false, @@ -594,4 +642,36 @@ mod tests { assert!(NexmonPcapAdapter::parse("p", SessionId(0), &[0u8; 8], None).is_err()); assert!(NexmonPcapAdapter::open("p", SessionId(0), "/no/such/file.pcap", None).is_err()); } + + #[test] + fn pcap_adapter_auto_detects_raspberry_pi_5_chip() { + // synth_nexmon_payload stamps chip_ver = 0x4345 (BCM4345 family chip ID), + // which is the CYW43455 / BCM43455c0 on a Raspberry Pi 3B+ / 4 / 400 / 5. + let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz + let nsub = 256u16; + let pcap = pcap_le_us( + LINKTYPE_ETHERNET, + &[ + (1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))), + (1u32, 50_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-59, chanspec, nsub, 2))), + ], + ); + let adapter = NexmonPcapAdapter::parse("pi5-cap", SessionId(1), &pcap, None).unwrap(); + assert_eq!(adapter.detected_chip(), NexmonChip::Bcm43455c0); + assert_eq!(adapter.headers()[0].chip(), NexmonChip::Bcm43455c0); + // the adapter's validation profile is the 43455c0 one (20/40/80, 64/128/256) + let p = adapter.profile(); + assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]); + assert!(p.accepts_subcarrier_count(256)); + assert!(p.accepts_channel(36)); + // 256-sc, ch 36 frame validates fine against the Pi 5 profile + let mut f = adapter.frames[0].clone(); + validate_frame(&mut f, &raspberry_pi_profile(RaspberryPiModel::Pi5), &ValidationPolicy::default(), None).unwrap(); + assert_eq!(f.validation, ValidationStatus::Accepted); + + // explicit override to a Pi 5 also works + let a2 = NexmonPcapAdapter::parse("p", SessionId(0), &pcap, None).unwrap().with_pi_model(RaspberryPiModel::Pi5); + assert_eq!(a2.detected_chip(), NexmonChip::Bcm43455c0); + assert!(a2.profile().chip.as_deref().unwrap().contains("pi5")); + } } diff --git a/v2/crates/rvcsi-cli/src/commands.rs b/v2/crates/rvcsi-cli/src/commands.rs index 9673292b..b3039df7 100644 --- a/v2/crates/rvcsi-cli/src/commands.rs +++ b/v2/crates/rvcsi-cli/src/commands.rs @@ -58,10 +58,13 @@ pub fn record_from_nexmon( Ok(()) } -/// `rvcsi record --source nexmon-pcap --in --out ` — +/// `rvcsi record --source nexmon-pcap --in --out [--chip pi5]` — /// transcode the real nexmon_csi UDP payloads inside a libpcap capture /// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into a `.rvcsi` capture file, -/// validating each frame. `port` is the CSI UDP port (`None` ⇒ 5500). +/// validating each frame. `port` is the CSI UDP port (`None` ⇒ 5500). `chip` is +/// an optional chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`, ...) — +/// when given, frames are validated against that device's profile and the +/// non-conforming ones dropped (and the profile is stamped on the capture). pub fn record_from_nexmon_pcap( out: &mut dyn Write, pcap_path: &str, @@ -69,21 +72,72 @@ pub fn record_from_nexmon_pcap( source_id: &str, session_id: u64, port: Option, + chip: Option<&str>, ) -> Result<()> { let bytes = std::fs::read(pcap_path).with_context(|| format!("reading {pcap_path}"))?; - let frames = runtime::decode_nexmon_pcap(&bytes, source_id, session_id, port) + let frames = runtime::decode_nexmon_pcap_for(&bytes, source_id, session_id, port, chip) .with_context(|| format!("parsing nexmon pcap {pcap_path}"))?; - let header = CaptureHeader::new( - SessionId(session_id), - SourceId::from(source_id), - AdapterProfile::nexmon_default(), - ); + let profile = match chip { + Some(spec) => runtime::nexmon_profile_for(spec) + .ok_or_else(|| anyhow::anyhow!("unknown nexmon chip / Raspberry Pi model `{spec}`"))?, + None => AdapterProfile::nexmon_default(), + }; + let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile); let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?; for f in &frames { rec.write_frame(f)?; } rec.finish()?; - writeln!(out, "recorded {} frame(s) from {pcap_path} to {out_path}", frames.len())?; + let chip_note = chip.map(|c| format!(" (chip {c})")).unwrap_or_default(); + writeln!(out, "recorded {} frame(s) from {pcap_path} to {out_path}{chip_note}", frames.len())?; + Ok(()) +} + +/// `rvcsi nexmon-chips` — list the Broadcom/Cypress chips nexmon_csi runs on and +/// the Raspberry Pi models that carry them (incl. the Pi 5 → BCM43455c0). +pub fn nexmon_chips_cmd(out: &mut dyn Write, json: bool) -> Result<()> { + use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip}; + if json { + let chips: Vec<_> = known_chips() + .iter() + .map(|c| { + let p = nexmon_adapter_profile(*c); + serde_json::json!({ + "slug": c.slug(), "description": c.description(), + "dual_band": c.dual_band(), "int16_iq_export": c.uses_int16_iq(), + "bandwidths_mhz": p.supported_bandwidths_mhz, + "expected_subcarrier_counts": p.expected_subcarrier_counts, + }) + }) + .collect(); + let pis: Vec<_> = known_pi_models() + .iter() + .map(|m| serde_json::json!({ + "slug": m.slug(), "chip": m.nexmon_chip().slug(), "csi_supported": m.csi_supported(), + })) + .collect(); + writeln!(out, "{}", serde_json::to_string_pretty(&serde_json::json!({ "chips": chips, "raspberry_pi_models": pis }))?)?; + return Ok(()); + } + writeln!(out, "Nexmon-supported Broadcom/Cypress chips:")?; + for c in known_chips() { + let p = nexmon_adapter_profile(*c); + writeln!( + out, + " {:<12} {} [bw {:?} MHz, sc {:?}{}]", + c.slug(), + c.description(), + p.supported_bandwidths_mhz, + p.expected_subcarrier_counts, + if c.uses_int16_iq() { "" } else { ", legacy packed-float export" } + )?; + } + writeln!(out, "\nRaspberry Pi models:")?; + for m in known_pi_models() { + let chip = m.nexmon_chip(); + let chip_slug = if matches!(chip, NexmonChip::Unknown { .. }) { "(no CSI support)".to_string() } else { chip.slug() }; + writeln!(out, " {:<10} -> {}{}", m.slug(), chip_slug, if m.csi_supported() { "" } else { " [WiFi present but not CSI-capable]" })?; + } Ok(()) } @@ -115,6 +169,7 @@ pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option, j " chip versions: {}", s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::>().join(", ") )?; + writeln!(out, " chip : {} (seen: {})", s.detected_chip, s.chip_names.join(", "))?; match s.rssi_dbm_range { Some((lo, hi)) => writeln!(out, " rssi range : {lo} .. {hi} dBm")?, None => writeln!(out, " rssi range : (none)")?, @@ -166,6 +221,9 @@ pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> { writeln!(out, " session : {}", summary.session_id)?; writeln!(out, " source : {}", summary.source_id)?; writeln!(out, " adapter : {}", summary.adapter_kind)?; + if let Some(chip) = &summary.chip { + writeln!(out, " chip : {chip}")?; + } writeln!(out, " frames : {}", summary.frame_count)?; writeln!( out, @@ -510,7 +568,7 @@ mod tests { core: 0, spatial_stream: 0, chanspec, - chip_ver: 0x0142, + chip_ver: 0x4345, channel: 0, bandwidth_mhz: 0, is_5ghz: false, @@ -526,25 +584,55 @@ mod tests { std::fs::write(pcap_file.path(), &pcap_bytes).unwrap(); let pcap_path = pcap_file.path().to_str().unwrap(); - // inspect-nexmon (human + json) + // inspect-nexmon (human + json) — chip_ver 0x4345 resolves to the BCM43455c0 + // (the Raspberry Pi 3B+/4/400/5 chip) let human = run(|o| inspect_nexmon(o, pcap_path, None, false)); assert!(human.contains("CSI frames : 8"), "{human}"); assert!(human.contains("channels : [36]")); - assert!(human.contains("0x0142")); + assert!(human.contains("0x4345")); + assert!(human.contains("chip : bcm43455c0"), "{human}"); let j = run(|o| inspect_nexmon(o, pcap_path, None, true)); let v: serde_json::Value = serde_json::from_str(&j).unwrap(); assert_eq!(v["csi_frame_count"], 8); assert_eq!(v["bandwidths_mhz"][0], 80); + assert_eq!(v["detected_chip"], "bcm43455c0"); + assert_eq!(v["chip_names"][0], "bcm43455c0"); - // record --source nexmon-pcap -> .rvcsi, then the normal commands work on it + // record --source nexmon-pcap --chip pi5 -> .rvcsi; the 256-sc VHT80 ch36 + // frames all fit a Raspberry Pi 5 (BCM43455c0) let cap_file = tempfile::NamedTempFile::new().unwrap(); let cap_path = cap_file.path().to_str().unwrap(); - let out = run(|o| record_from_nexmon_pcap(o, pcap_path, cap_path, "nx-pcap", 3, None)); - assert!(out.contains("recorded 8 frame(s)"), "{out}"); + let out = run(|o| record_from_nexmon_pcap(o, pcap_path, cap_path, "nx-pcap", 3, None, Some("pi5"))); + assert!(out.contains("recorded 8 frame(s)") && out.contains("chip pi5"), "{out}"); let summary = run(|o| inspect(o, cap_path, false)); assert!(summary.contains("frames : 8")); assert!(summary.contains("source : nx-pcap")); assert!(summary.contains("channels : [36]")); + assert!(summary.contains("pi5"), "{summary}"); // the Pi 5 profile was stamped on the capture + + // --chip pizero2w (2.4 GHz only, ≤128 sc) drops every 256-sc frame + let cap2 = tempfile::NamedTempFile::new().unwrap(); + let out2 = run(|o| record_from_nexmon_pcap(o, pcap_path, cap2.path().to_str().unwrap(), "z", 0, None, Some("pizero2w"))); + assert!(out2.contains("recorded 0 frame(s)"), "{out2}"); + // unknown --chip is an error + let mut buf = Vec::new(); + assert!(record_from_nexmon_pcap(&mut buf, pcap_path, cap_path, "x", 0, None, Some("not-a-chip")).is_err()); + } + + #[test] + fn nexmon_chips_listing_includes_pi5() { + let human = run(|o| nexmon_chips_cmd(o, false)); + assert!(human.contains("bcm43455c0"), "{human}"); + assert!(human.contains("pi5"), "{human}"); + assert!(human.to_lowercase().contains("raspberry pi"), "{human}"); + let j = run(|o| nexmon_chips_cmd(o, true)); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + let chips = v["chips"].as_array().unwrap(); + assert!(chips.iter().any(|c| c["slug"] == "bcm43455c0")); + let pis = v["raspberry_pi_models"].as_array().unwrap(); + let pi5 = pis.iter().find(|m| m["slug"] == "pi5").expect("pi5 in listing"); + assert_eq!(pi5["chip"], "bcm43455c0"); + assert_eq!(pi5["csi_supported"], true); } #[test] @@ -573,7 +661,7 @@ mod tests { assert!(events(&mut buf, "/no/such/file.rvcsi", false).is_err()); assert!(calibrate(&mut buf, "/no/such/file.rvcsi", None).is_err()); assert!(record_from_nexmon(&mut buf, "/no/x.bin", "/tmp/y.rvcsi", "s", 0).is_err()); - assert!(record_from_nexmon_pcap(&mut buf, "/no/x.pcap", "/tmp/y.rvcsi", "s", 0, None).is_err()); + assert!(record_from_nexmon_pcap(&mut buf, "/no/x.pcap", "/tmp/y.rvcsi", "s", 0, None, None).is_err()); assert!(inspect_nexmon(&mut buf, "/no/such/file.pcap", None, false).is_err()); } } diff --git a/v2/crates/rvcsi-cli/src/main.rs b/v2/crates/rvcsi-cli/src/main.rs index c5ea4b9c..9743e98a 100644 --- a/v2/crates/rvcsi-cli/src/main.rs +++ b/v2/crates/rvcsi-cli/src/main.rs @@ -42,6 +42,17 @@ enum Command { /// CSI UDP port (for `--source nexmon-pcap`; defaults to 5500). #[arg(long)] port: Option, + /// Validate against a specific chip / Raspberry Pi model — e.g. `pi5`, + /// `pi4`, `pi3b+`, `pizero2w`, `bcm43455c0`, `bcm4366c0` — dropping + /// frames that don't fit it. Default: permissive (any subcarrier count). + #[arg(long)] + chip: Option, + }, + /// List the Broadcom/Cypress chips nexmon_csi runs on + the Raspberry Pi models (incl. Pi 5). + NexmonChips { + /// Emit JSON instead of a human listing. + #[arg(long)] + json: bool, }, /// Summarize a nexmon_csi `.pcap` file (link type, CSI frames, channels, ...). InspectNexmon { @@ -153,13 +164,14 @@ fn main() -> anyhow::Result<()> { let stdout = io::stdout(); let mut out = stdout.lock(); match cli.command { - Command::Record { source, input, output, source_id, session, port } => match source.as_str() { + Command::Record { source, input, output, source_id, session, port, chip } => match source.as_str() { "nexmon" => commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)?, - "nexmon-pcap" => { - commands::record_from_nexmon_pcap(&mut out, &input, &output, &source_id, session, port)? - } + "nexmon-pcap" => commands::record_from_nexmon_pcap( + &mut out, &input, &output, &source_id, session, port, chip.as_deref(), + )?, other => anyhow::bail!("unknown --source `{other}` (expected `nexmon` or `nexmon-pcap`)"), }, + Command::NexmonChips { json } => commands::nexmon_chips_cmd(&mut out, json)?, Command::InspectNexmon { path, port, json } => commands::inspect_nexmon(&mut out, &path, port, json)?, Command::DecodeChanspec { chanspec, json } => commands::decode_chanspec_cmd(&mut out, &chanspec, json)?, Command::Inspect { path, json } => commands::inspect(&mut out, &path, json)?, diff --git a/v2/crates/rvcsi-node/__test__/api.test.cjs b/v2/crates/rvcsi-node/__test__/api.test.cjs index d9599ec7..863087f6 100644 --- a/v2/crates/rvcsi-node/__test__/api.test.cjs +++ b/v2/crates/rvcsi-node/__test__/api.test.cjs @@ -21,6 +21,9 @@ test('exports the expected functions and class', () => { 'nexmonDecodePcap', 'inspectNexmonPcap', 'decodeChanspec', + 'nexmonChipName', + 'nexmonProfile', + 'nexmonChips', 'inspectCaptureFile', 'eventsFromCaptureFile', 'exportCaptureToRfMemory', diff --git a/v2/crates/rvcsi-node/index.d.ts b/v2/crates/rvcsi-node/index.d.ts index 78392ed4..44871ce8 100644 --- a/v2/crates/rvcsi-node/index.d.ts +++ b/v2/crates/rvcsi-node/index.d.ts @@ -108,12 +108,29 @@ export interface ValidationBreakdown { recovered: number; } +/** A source's capability descriptor (channels / bandwidths / expected subcarrier counts). */ +export interface AdapterProfile { + adapter_kind: AdapterKind; + /** Chip string, e.g. `"bcm43455c0 (pi5)"`, or `null`. */ + chip: string | null; + firmware_version: string | null; + driver_version: string | null; + supported_channels: number[]; + supported_bandwidths_mhz: number[]; + expected_subcarrier_counts: number[]; + supports_live_capture: boolean; + supports_injection: boolean; + supports_monitor_mode: boolean; +} + /** Compact summary of a `.rvcsi` capture file. */ export interface CaptureSummary { capture_version: number; session_id: number; source_id: string; adapter_kind: string; + /** The header's adapter-profile `chip` string, if any (e.g. `"bcm43455c0 (pi5)"`). */ + chip: string | null; frame_count: number; first_timestamp_ns: number; last_timestamp_ns: number; @@ -136,8 +153,12 @@ export interface NexmonPcapSummary { channels: number[]; bandwidths_mhz: number[]; subcarrier_counts: number[]; - /** Distinct chip-version words (e.g. 0x0142 = BCM43455c0). */ + /** Distinct chip-version words (e.g. 0x4345 = the BCM4345 family). */ chip_versions: number[]; + /** Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5). */ + chip_names: string[]; + /** The chip the adapter settled on (all packets agreed) — `"bcm43455c0"` for a Pi 5 capture. */ + detected_chip: string; /** `[min, max]` RSSI in dBm, or `null` for an empty capture. */ rssi_dbm_range: [number, number] | null; } @@ -153,6 +174,35 @@ export interface DecodedChanspec { is_5ghz: boolean; } +/** One Nexmon-supported chip in the {@link nexmonChips} listing. */ +export interface NexmonChipInfo { + /** Slug, e.g. `"bcm43455c0"`. */ + slug: string; + /** Human description incl. a typical host device. */ + description: string; + /** Whether the chip supports the 5 GHz band. */ + dualBand: boolean; + /** Whether its firmware exports CSI in the modern int16 I/Q format. */ + int16IqExport: boolean; + bandwidthsMhz: number[]; + expectedSubcarrierCounts: number[]; +} + +/** One Raspberry Pi model in the {@link nexmonChips} listing. */ +export interface RaspberryPiModelInfo { + /** Slug, e.g. `"pi5"`. */ + slug: string; + /** The chip on this board (`"bcm43455c0"` for the Pi 5), or `null` if not CSI-capable. */ + chip: string | null; + csiSupported: boolean; +} + +/** The {@link nexmonChips} listing. */ +export interface NexmonChipsListing { + chips: NexmonChipInfo[]; + raspberryPiModels: RaspberryPiModelInfo[]; +} + /** rvCSI runtime version string. */ export function rvcsiVersion(): string; @@ -180,13 +230,16 @@ export function exportCaptureToRfMemory(capturePath: string, outJsonlPath: strin /** * Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` buffer - * into validated frames. `port` defaults to 5500. Throws on a non-pcap buffer. + * into validated frames. `port` defaults to 5500. `chip` (`'pi5'`, + * `'bcm43455c0'`, ...) validates against that device's profile and drops the + * non-conforming frames. Throws on a non-pcap buffer or an unknown `chip`. */ export function nexmonDecodePcap( pcap: Buffer | Uint8Array, sourceId: string, sessionId: number, port?: number, + chip?: string, ): CsiFrame[]; /** Summarize a nexmon_csi `.pcap` file. `port` defaults to 5500. */ @@ -195,6 +248,21 @@ export function inspectNexmonPcap(path: string, port?: number): NexmonPcapSummar /** Decode a Broadcom d11ac chanspec word. */ export function decodeChanspec(chanspec: number): DecodedChanspec; +/** + * Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug + * (`'bcm43455c0'` for a Raspberry Pi 3B+/4/400/5; `'unknown:0xNNNN'` otherwise). + */ +export function nexmonChipName(chipVer: number): string; + +/** + * The {@link AdapterProfile} for a chip / Raspberry-Pi-model spec (`'pi5'`, + * `'bcm43455c0'`, `'raspberry pi 4'`, ...). Throws on an unknown spec. + */ +export function nexmonProfile(spec: string): AdapterProfile; + +/** Listing of the Nexmon-supported chips + Raspberry Pi models (incl. the Pi 5 → BCM43455c0). */ +export function nexmonChips(): NexmonChipsListing; + /** Streaming capture runtime: a source + the DSP stage + the event pipeline. */ export class RvCsi { private constructor(rt: unknown); diff --git a/v2/crates/rvcsi-node/index.js b/v2/crates/rvcsi-node/index.js index c29aafb5..61e050ce 100644 --- a/v2/crates/rvcsi-node/index.js +++ b/v2/crates/rvcsi-node/index.js @@ -98,17 +98,25 @@ function exportCaptureToRfMemory(capturePath, outJsonlPath) { * @param {string} sourceId * @param {number} sessionId * @param {number} [port] CSI UDP port (default 5500) + * @param {string} [chip] chip / Raspberry-Pi-model spec to validate against + * (e.g. `'pi5'`, `'bcm43455c0'`); non-conforming frames are dropped * @returns {import('./index').CsiFrame[]} */ -function nexmonDecodePcap(pcap, sourceId, sessionId, port) { +function nexmonDecodePcap(pcap, sourceId, sessionId, port, chip) { return JSON.parse( - binding().nexmonDecodePcap(pcap, String(sourceId), u32(sessionId), port == null ? undefined : Number(port)), + binding().nexmonDecodePcap( + pcap, + String(sourceId), + u32(sessionId), + port == null ? undefined : Number(port), + chip == null ? undefined : String(chip), + ), ); } /** * Summarize a nexmon_csi `.pcap` file (link type, CSI frame count, channels, - * bandwidths, chip versions, RSSI range, time span). + * bandwidths, chip versions + resolved chip names, RSSI range, time span). * @param {string} path * @param {number} [port] CSI UDP port (default 5500) * @returns {import('./index').NexmonPcapSummary} @@ -126,6 +134,36 @@ function decodeChanspec(chanspec) { return JSON.parse(binding().decodeChanspec(u32(chanspec))); } +/** + * Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug + * (`'bcm43455c0'` for a Raspberry Pi 3B+/4/400/5; `'unknown:0xNNNN'` otherwise). + * @param {number} chipVer + * @returns {string} + */ +function nexmonChipName(chipVer) { + return binding().nexmonChipName(u32(chipVer)); +} + +/** + * The AdapterProfile (channels / bandwidths / expected subcarrier counts / + * capability flags) for a chip / Raspberry-Pi-model spec (`'pi5'`, + * `'bcm43455c0'`, ...). Throws on an unknown spec. + * @param {string} spec + * @returns {import('./index').AdapterProfile} + */ +function nexmonProfile(spec) { + return JSON.parse(binding().nexmonProfile(String(spec))); +} + +/** + * Listing of the Nexmon-supported chips + the Raspberry Pi models that carry + * them (incl. the Pi 5 → BCM43455c0). + * @returns {import('./index').NexmonChipsListing} + */ +function nexmonChips() { + return JSON.parse(binding().nexmonChips()); +} + /** Streaming capture runtime: a source + the DSP stage + the event pipeline. */ class RvCsi { /** @param {*} rt the underlying napi RvcsiRuntime handle */ @@ -203,6 +241,9 @@ module.exports = { nexmonDecodePcap, inspectNexmonPcap, decodeChanspec, + nexmonChipName, + nexmonProfile, + nexmonChips, inspectCaptureFile, eventsFromCaptureFile, exportCaptureToRfMemory, diff --git a/v2/crates/rvcsi-node/src/lib.rs b/v2/crates/rvcsi-node/src/lib.rs index cbd7944a..7f56381e 100644 --- a/v2/crates/rvcsi-node/src/lib.rs +++ b/v2/crates/rvcsi-node/src/lib.rs @@ -95,22 +95,26 @@ pub fn export_capture_to_rf_memory(capture_path: String, out_jsonl_path: String) /// Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` `Buffer` /// into a JSON array of validated `CsiFrame`s. `port` is the CSI UDP port -/// (omit / `null` ⇒ 5500). Throws if the buffer isn't a parseable classic pcap. +/// (omit / `null` ⇒ 5500); `chip` is an optional chip / Raspberry-Pi-model spec +/// (`"pi5"`, `"bcm43455c0"`, ...) — when given, frames are validated against +/// that device's profile and the non-conforming ones dropped. Throws if the +/// buffer isn't a parseable classic pcap or `chip` is unrecognised. #[napi] pub fn nexmon_decode_pcap( pcap: Buffer, source_id: String, session_id: u32, port: Option, + chip: Option, ) -> napi::Result { - let frames = runtime::decode_nexmon_pcap(pcap.as_ref(), &source_id, session_id as u64, port) + let frames = runtime::decode_nexmon_pcap_for(pcap.as_ref(), &source_id, session_id as u64, port, chip.as_deref()) .map_err(napi_err)?; to_json(&frames) } /// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, -/// bandwidths, chip versions, RSSI range, time span); returns JSON for a -/// `NexmonPcapSummary`. `port` defaults to 5500. +/// bandwidths, chip versions + resolved chip names, RSSI range, time span); +/// returns JSON for a `NexmonPcapSummary`. `port` defaults to 5500. #[napi] pub fn inspect_nexmon_pcap(path: String, port: Option) -> napi::Result { let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?; @@ -130,6 +134,54 @@ pub fn decode_chanspec(chanspec: u32) -> napi::Result { })) } +/// Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug +/// (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise). +#[napi] +pub fn nexmon_chip_name(chip_ver: u32) -> String { + rvcsi_adapter_nexmon::NexmonChip::from_chip_ver((chip_ver & 0xFFFF) as u16).slug() +} + +/// The `AdapterProfile` (channels / bandwidths / expected subcarrier counts / +/// capability flags) for a chip / Raspberry-Pi-model spec (`"pi5"`, +/// `"bcm43455c0"`, `"raspberry pi 4"`, ...); returns JSON. Throws if unknown. +#[napi] +pub fn nexmon_profile(spec: String) -> napi::Result { + let p = runtime::nexmon_profile_for(&spec) + .ok_or_else(|| napi::Error::from_reason(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?; + to_json(&p) +} + +/// JSON listing of the Nexmon-supported chips + the Raspberry Pi models that +/// carry them (incl. the Pi 5 → BCM43455c0): `{ chips: [...], raspberryPiModels: [...] }`. +#[napi] +pub fn nexmon_chips() -> napi::Result { + use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip}; + let chips: Vec<_> = known_chips() + .iter() + .map(|c| { + let p = nexmon_adapter_profile(*c); + serde_json::json!({ + "slug": c.slug(), "description": c.description(), + "dualBand": c.dual_band(), "int16IqExport": c.uses_int16_iq(), + "bandwidthsMhz": p.supported_bandwidths_mhz, + "expectedSubcarrierCounts": p.expected_subcarrier_counts, + }) + }) + .collect(); + let pis: Vec<_> = known_pi_models() + .iter() + .map(|m| { + let chip = m.nexmon_chip(); + serde_json::json!({ + "slug": m.slug(), + "chip": if matches!(chip, NexmonChip::Unknown { .. }) { serde_json::Value::Null } else { serde_json::Value::String(chip.slug()) }, + "csiSupported": m.csi_supported(), + }) + }) + .collect(); + to_json(&serde_json::json!({ "chips": chips, "raspberryPiModels": pis })) +} + // --------------------------------------------------------------------------- // Streaming runtime class // --------------------------------------------------------------------------- diff --git a/v2/crates/rvcsi-runtime/src/capture.rs b/v2/crates/rvcsi-runtime/src/capture.rs index e799a8b0..4d887395 100644 --- a/v2/crates/rvcsi-runtime/src/capture.rs +++ b/v2/crates/rvcsi-runtime/src/capture.rs @@ -309,7 +309,7 @@ mod tests { core: 0, spatial_stream: 0, chanspec, - chip_ver: 0x0142, + chip_ver: 0x4345, channel: 0, bandwidth_mhz: 0, is_5ghz: false, diff --git a/v2/crates/rvcsi-runtime/src/lib.rs b/v2/crates/rvcsi-runtime/src/lib.rs index 08108ae6..857344bb 100644 --- a/v2/crates/rvcsi-runtime/src/lib.rs +++ b/v2/crates/rvcsi-runtime/src/lib.rs @@ -21,9 +21,9 @@ pub mod summary; pub use capture::CaptureRuntime; pub use summary::{ - decode_nexmon_pcap, decode_nexmon_records, events_from_capture, export_capture_to_rf_memory, - rf_memory_self_check, summarize_capture, summarize_nexmon_pcap, CaptureSummary, - NexmonPcapSummary, ValidationBreakdown, + decode_nexmon_pcap, decode_nexmon_pcap_for, decode_nexmon_records, events_from_capture, + export_capture_to_rf_memory, nexmon_profile_for, rf_memory_self_check, summarize_capture, + summarize_nexmon_pcap, CaptureSummary, NexmonPcapSummary, ValidationBreakdown, }; /// ABI version of the linked napi-c Nexmon shim (re-exported for convenience). diff --git a/v2/crates/rvcsi-runtime/src/summary.rs b/v2/crates/rvcsi-runtime/src/summary.rs index bbc5f720..34363899 100644 --- a/v2/crates/rvcsi-runtime/src/summary.rs +++ b/v2/crates/rvcsi-runtime/src/summary.rs @@ -28,6 +28,8 @@ pub struct CaptureSummary { pub source_id: String, /// Adapter kind slug from the header's profile. pub adapter_kind: String, + /// The header's adapter-profile `chip` string, if any (e.g. `"bcm43455c0 (pi5)"`). + pub chip: Option, /// Number of frames in the capture. pub frame_count: usize, /// First / last frame timestamp (ns); `0` for an empty capture. @@ -104,6 +106,7 @@ pub fn summarize_capture(path: &str) -> Result { session_id: header.session_id.value(), source_id: header.source_id.0, adapter_kind: header.adapter_profile.adapter_kind.slug().to_string(), + chip: header.adapter_profile.chip.clone(), frame_count: frames.len(), first_timestamp_ns: first_ts, last_timestamp_ns: last_ts, @@ -119,19 +122,16 @@ pub fn summarize_capture(path: &str) -> Result { }) } -/// Validate a batch of raw (`Pending`) frames against a permissive profile, in -/// timestamp order; drop the hard-rejected ones and return the survivors. Used -/// for the Nexmon paths, where the firmware may report non-default subcarrier -/// counts and we want everything decodable to flow. -fn validate_frames_permissive(raw: Vec) -> Vec { - let profile = AdapterProfile::offline(rvcsi_core::AdapterKind::Nexmon); +/// Validate a batch of raw (`Pending`) frames against `profile`, in timestamp +/// order; drop the hard-rejected ones and return the survivors. +fn validate_frames_against(raw: Vec, profile: &AdapterProfile) -> Vec { let policy = ValidationPolicy::default(); let mut out = Vec::with_capacity(raw.len()); let mut prev_ts: Option = None; for mut f in raw { let ts = f.timestamp_ns; if f.validation == ValidationStatus::Pending { - match validate_frame(&mut f, &profile, &policy, prev_ts) { + match validate_frame(&mut f, profile, &policy, prev_ts) { Ok(()) if f.is_exposable() => { prev_ts = Some(ts); out.push(f); @@ -145,6 +145,23 @@ fn validate_frames_permissive(raw: Vec) -> Vec { out } +/// Validate against a permissive (offline-Nexmon) profile — accepts any +/// subcarrier count / channel. Used when no specific chip was requested. +fn validate_frames_permissive(raw: Vec) -> Vec { + validate_frames_against(raw, &AdapterProfile::offline(rvcsi_core::AdapterKind::Nexmon)) +} + +/// Resolve a chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`, +/// `"raspberry pi 4"`, `"4366c0"`, ...) to an [`AdapterProfile`], for the +/// `--chip` flag and SDK callers. Returns `None` for an unknown spec. +pub fn nexmon_profile_for(spec: &str) -> Option { + if let Some(model) = rvcsi_adapter_nexmon::RaspberryPiModel::from_slug(spec) { + return Some(rvcsi_adapter_nexmon::raspberry_pi_profile(model)); + } + rvcsi_adapter_nexmon::NexmonChip::from_slug(spec) + .map(rvcsi_adapter_nexmon::nexmon_adapter_profile) +} + /// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into /// validated [`CsiFrame`]s. Frames that hard-fail validation are dropped (never /// returned to JS). @@ -159,11 +176,27 @@ pub fn decode_nexmon_records( /// Decode the *real* nexmon_csi UDP payloads inside a libpcap (`.pcap`) buffer /// into validated [`CsiFrame`]s. `port` is the CSI UDP port (`None` ⇒ 5500). +/// Validation is permissive (any subcarrier count / channel survives); pass a +/// chip spec to [`decode_nexmon_pcap_for`] to bound against a specific device. pub fn decode_nexmon_pcap( pcap_bytes: &[u8], source_id: &str, session_id: u64, port: Option, +) -> Result, RvcsiError> { + decode_nexmon_pcap_for(pcap_bytes, source_id, session_id, port, None) +} + +/// Like [`decode_nexmon_pcap`] but, when `chip_spec` is `Some` (`"pi5"`, +/// `"bcm43455c0"`, ...), validates each frame against that device's profile and +/// drops the non-conforming ones (e.g. a 256-subcarrier VHT80 frame against a +/// 2.4 GHz-only `bcm43436b0` profile). An unrecognised spec is a `Config` error. +pub fn decode_nexmon_pcap_for( + pcap_bytes: &[u8], + source_id: &str, + session_id: u64, + port: Option, + chip_spec: Option<&str>, ) -> Result, RvcsiError> { let raw = rvcsi_adapter_nexmon::NexmonPcapAdapter::frames_from_pcap_bytes( SourceId::from(source_id), @@ -171,7 +204,14 @@ pub fn decode_nexmon_pcap( pcap_bytes, port, )?; - Ok(validate_frames_permissive(raw)) + match chip_spec { + None => Ok(validate_frames_permissive(raw)), + Some(spec) => { + let profile = nexmon_profile_for(spec) + .ok_or_else(|| RvcsiError::Config(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?; + Ok(validate_frames_against(raw, &profile)) + } + } } /// A compact summary of a nexmon_csi `.pcap` capture (the `rvcsi inspect-nexmon` @@ -194,8 +234,12 @@ pub struct NexmonPcapSummary { pub bandwidths_mhz: Vec, /// Distinct subcarrier (FFT) counts seen. pub subcarrier_counts: Vec, - /// Distinct chip-version words seen (e.g. `0x0142` = BCM43455c0). + /// Distinct chip-version words seen (e.g. `0x4345` = the BCM4345 family). pub chip_versions: Vec, + /// Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise). + pub chip_names: Vec, + /// The chip the adapter settled on (all packets agreed) — `"bcm43455c0"` for a Pi 5 capture. + pub detected_chip: String, /// Min / max RSSI (dBm) over the CSI packets; `None` if empty. pub rssi_dbm_range: Option<(i16, i16)>, } @@ -210,20 +254,25 @@ pub fn summarize_nexmon_pcap(path: &str, port: Option) -> Result) -> Result all dropped + assert_eq!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("pizero2w")).unwrap().len(), 0); + // unknown spec -> Config error + assert!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("not-a-chip")).is_err()); + // nexmon_profile_for resolves both chip slugs and Pi model slugs + assert!(nexmon_profile_for("pi5").is_some()); + assert!(nexmon_profile_for("bcm4366c0").is_some()); + assert!(nexmon_profile_for("nope").is_none()); } #[test] - fn summarize_nexmon_pcap_reports_metadata() { + fn summarize_nexmon_pcap_reports_metadata_and_pi5_chip() { let pcap = synth_nexmon_pcap_bytes(); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &pcap).unwrap(); @@ -520,7 +583,10 @@ mod tests { assert_eq!(s.channels, vec![36]); assert_eq!(s.bandwidths_mhz, vec![80]); assert_eq!(s.subcarrier_counts, vec![256]); - assert_eq!(s.chip_versions, vec![0x0142]); + assert_eq!(s.chip_versions, vec![0x4345]); + // 0x4345 resolves to the BCM43455c0 — the chip on a Raspberry Pi 3B+/4/400/5 + assert_eq!(s.chip_names, vec!["bcm43455c0".to_string()]); + assert_eq!(s.detected_chip, "bcm43455c0"); assert_eq!(s.rssi_dbm_range, Some((-61, -58))); assert_eq!(s.first_timestamp_ns, 1_000_000_000); assert!(s.last_timestamp_ns > s.first_timestamp_ns);