feat(rvcsi): Raspberry Pi 5 (BCM43455c0) + Nexmon chip registry

Adds first-class support for the Raspberry Pi 5's WiFi chip (CYW43455 /
BCM43455c0 — the same 802.11ac wireless as the Pi 4 / Pi 3B+ / Pi 400, and the
chip with the most mature nexmon_csi support), plus a registry of the other
Nexmon-supported Broadcom/Cypress chips.

rvcsi-adapter-nexmon — new `chips.rs`:
- `NexmonChip` (Bcm43455c0, Bcm43436b0, Bcm4366c0, Bcm4375b1, Bcm4358, Bcm4339,
  Unknown{chip_ver}) + `RaspberryPiModel` (Pi5/Pi4/Pi400/Pi3BPlus/PiZero2W/
  PiZeroW) — Pi5/Pi4/Pi400/Pi3B+ → Bcm43455c0; PiZero2W → Bcm43436b0.
- `nexmon_adapter_profile(chip)` / `raspberry_pi_profile(model)` build the
  per-device `AdapterProfile` (channels: 2.4 GHz 1-13 + 5 GHz UNII for dual-band;
  bandwidths 20/40/80[/160]; expected subcarrier counts 64/128/256[/512]) that
  `validate_frame` bounds CSI frames against.
- `NexmonChip::from_chip_ver` (0x4345 → Bcm43455c0, 0x4339, 0x4358, 0x4366,
  0x4375 — best-effort; the raw `chip_ver` is always preserved) and `from_slug`
  / `RaspberryPiModel::from_slug` ("pi5", "raspberry pi 4", "bcm43455c0", ...).
- `NexmonCsiHeader::chip()`; `NexmonPcapAdapter` auto-detects the chip from the
  packets' `chip_ver` and uses the matching profile, overridable via
  `.with_chip(NexmonChip)` / `.with_pi_model(RaspberryPiModel)`; `.detected_chip()`.

rvcsi-runtime: `decode_nexmon_pcap_for(.., chip_spec)` (validate against a chip /
Pi model, drop non-conforming) + `nexmon_profile_for(spec)`; `NexmonPcapSummary`
gains `chip_names` + `detected_chip`; `CaptureSummary` gains `chip`.

rvcsi-cli: `record --source nexmon-pcap --chip pi5`; new `nexmon-chips`
subcommand (lists chips + Pi models, human or `--json`); `inspect-nexmon` and
`inspect` now print the resolved chip.

rvcsi-node (napi-rs): `nexmonDecodePcap` gains an optional `chip` arg;
`nexmonChipName(chipVer)`, `nexmonProfile(spec)`, `nexmonChips()`. @ruv/rvcsi
SDK + `.d.ts` updated (AdapterProfile / NexmonChipsListing interfaces, the new
fns, `chip` on CaptureSummary, `chip_names`/`detected_chip` on NexmonPcapSummary).

168 rvcsi tests pass (adapter-nexmon 22→28, cli 9→10), 0 failures, clippy-clean.
The synthetic test captures now stamp chip_ver = 0x4345 (the BCM4345 family chip
ID), so the chip-detection happy path is exercised end to end.
ADR-096, CHANGELOG, README, CLAUDE.md updated.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
This commit is contained in:
Claude 2026-05-13 01:32:27 +00:00
parent b116a99481
commit d40411e6d7
No known key found for this signature in database
17 changed files with 815 additions and 63 deletions

View File

@ -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, FR1FR10, 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.

View File

@ -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 |

View File

@ -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 |

View File

@ -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.
---

View File

@ -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.

View File

@ -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<NexmonChip> {
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<u16> {
let mut v: Vec<u16> = (1..=13).collect();
if chip.dual_band() {
v.extend_from_slice(FIVE_GHZ_CHANNELS);
}
v
}
fn bandwidths_for(chip: NexmonChip) -> Vec<u16> {
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<u16> {
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<RaspberryPiModel> {
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));
}
}

View File

@ -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);

View File

@ -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<CsiFrame>,
headers: Vec<NexmonCsiHeader>,
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<SourceId>,
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<SourceId>,
@ -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"));
}
}

View File

@ -58,10 +58,13 @@ pub fn record_from_nexmon(
Ok(())
}
/// `rvcsi record --source nexmon-pcap --in <csi.pcap> --out <cap.rvcsi>` —
/// `rvcsi record --source nexmon-pcap --in <csi.pcap> --out <cap.rvcsi> [--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<u16>,
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<u16>, j
" chip versions: {}",
s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::<Vec<_>>().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());
}
}

View File

@ -42,6 +42,17 @@ enum Command {
/// CSI UDP port (for `--source nexmon-pcap`; defaults to 5500).
#[arg(long)]
port: Option<u16>,
/// 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<String>,
},
/// 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)?,

View File

@ -21,6 +21,9 @@ test('exports the expected functions and class', () => {
'nexmonDecodePcap',
'inspectNexmonPcap',
'decodeChanspec',
'nexmonChipName',
'nexmonProfile',
'nexmonChips',
'inspectCaptureFile',
'eventsFromCaptureFile',
'exportCaptureToRfMemory',

View File

@ -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);

View File

@ -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,

View File

@ -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<u16>,
chip: Option<String>,
) -> napi::Result<String> {
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<u16>) -> napi::Result<String> {
let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?;
@ -130,6 +134,54 @@ pub fn decode_chanspec(chanspec: u32) -> napi::Result<String> {
}))
}
/// 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<String> {
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<String> {
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
// ---------------------------------------------------------------------------

View File

@ -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,

View File

@ -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).

View File

@ -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<String>,
/// 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<CaptureSummary, RvcsiError> {
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<CaptureSummary, RvcsiError> {
})
}
/// 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<CsiFrame>) -> Vec<CsiFrame> {
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<CsiFrame>, profile: &AdapterProfile) -> Vec<CsiFrame> {
let policy = ValidationPolicy::default();
let mut out = Vec::with_capacity(raw.len());
let mut prev_ts: Option<u64> = 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<CsiFrame>) -> Vec<CsiFrame> {
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<CsiFrame>) -> Vec<CsiFrame> {
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<AdapterProfile> {
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<u16>,
) -> Result<Vec<CsiFrame>, 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<u16>,
chip_spec: Option<&str>,
) -> Result<Vec<CsiFrame>, 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<u16>,
/// Distinct subcarrier (FFT) counts seen.
pub subcarrier_counts: Vec<u16>,
/// Distinct chip-version words seen (e.g. `0x0142` = BCM43455c0).
/// Distinct chip-version words seen (e.g. `0x4345` = the BCM4345 family).
pub chip_versions: Vec<u16>,
/// Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise).
pub chip_names: Vec<String>,
/// 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<u16>) -> Result<NexmonPcap
port,
)?;
let health = adapter.health();
let detected_chip = adapter.detected_chip().slug();
let headers = adapter.headers();
let mut channels = Vec::new();
let mut bandwidths = Vec::new();
let mut subs = Vec::new();
let mut chips = Vec::new();
let mut chip_names = Vec::new();
let (mut rssi_lo, mut rssi_hi) = (i16::MAX, i16::MIN);
for h in headers {
channels.push(h.channel);
bandwidths.push(h.bandwidth_mhz);
subs.push(h.subcarrier_count);
chips.push(h.chip_ver);
chip_names.push(h.chip().slug());
rssi_lo = rssi_lo.min(h.rssi_dbm);
rssi_hi = rssi_hi.max(h.rssi_dbm);
}
chip_names.sort();
chip_names.dedup();
let (mut first_ts, mut last_ts) = (u64::MAX, 0u64);
// re-iterate frames for timestamps (headers don't carry the pcap time)
let mut a2 = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
@ -250,6 +299,8 @@ pub fn summarize_nexmon_pcap(path: &str, port: Option<u16>) -> Result<NexmonPcap
bandwidths_mhz: sorted_unique(bandwidths),
subcarrier_counts: sorted_unique(subs),
chip_versions: sorted_unique(chips),
chip_names,
detected_chip,
rssi_dbm_range: (!headers.is_empty()).then_some((rssi_lo, rssi_hi)),
})
}
@ -469,7 +520,7 @@ mod tests {
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 0x0142,
chip_ver: 0x4345,
channel: 0,
bandwidth_mhz: 0,
is_5ghz: false,
@ -507,10 +558,22 @@ mod tests {
// explicit-port form works too
assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(5500)).unwrap().len(), 4);
assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(9999)).unwrap().len(), 0);
// --chip pi5 / bcm43455c0: the 256-sc VHT80 ch36 frames all conform
assert_eq!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("pi5")).unwrap().len(), 4);
assert_eq!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("bcm43455c0")).unwrap().len(), 4);
// --chip pizero2w (bcm43436b0): 2.4 GHz only, max 128 sc -> 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);