diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9dd7ad..b0dac6a8 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, parses/writes a byte-defined "rvCSI Nexmon record", a normalized superset of the nexmon_csi UDP payload), compiled via `build.rs`+`cc`, wrapped by a documented `ffi` module and a `NexmonAdapter` `CsiSource`; `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`/`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` — everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` Nexmon-dump→`.rvcsi`, `inspect`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary` objects, with a lazy native-addon load. - - **Tests:** 142 across the rvcsi crates (core 29, dsp 28, events 18, adapter-file 20 + 1 doctest, adapter-nexmon 9 — round-tripping through the C shim, ruvector 20 + 1 doctest, runtime 10, cli 7), 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`) — 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). 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. - **`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 5e09894a..f13d8c93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,11 @@ 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; compiled via `build.rs`+`cc`) + `NexmonAdapter` | +| `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-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 helpers | +| `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 | -| `rvcsi-cli` | rvCSI: the `rvcsi` binary — record/inspect/replay/stream/events/health/calibrate/export | +| `rvcsi-cli` | rvCSI: the `rvcsi` binary — record/inspect/inspect-nexmon/decode-chanspec/replay/stream/events/health/calibrate/export | ### RuvSense Modules (`signal/src/ruvsense/`) | Module | Purpose | diff --git a/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md b/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md index 224ca27b..0f28320e 100644 --- a/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md +++ b/docs/adr/ADR-096-rvcsi-ffi-crate-layout.md @@ -38,43 +38,34 @@ 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, and `NexmonAdapter` (a `CsiSource`). | -| `rvcsi-ruvector` | no (`forbid`) | `rvcsi-core` | The RuVector RF-memory bridge: deterministic `window_embedding`/`event_embedding`, the `RfMemoryStore` trait, and `InMemoryRfMemory` + `JsonlRfMemory` (a standin until the production RuVector binding lands). | -| `rvcsi-node` | no (`deny(clippy::all)`) | all of the above | The **napi-rs** seam: the `.node` addon (cdylib + rlib) exposing a safe TS-facing surface; `build.rs` runs `napi_build::setup()`. | -| `rvcsi-cli` | no | core, dsp, events, adapter-file, adapter-nexmon, ruvector | The `rvcsi` binary: `inspect`, `replay`, `health`, `export`, `calibrate`, `stream` (ADR-095 FR7). | +| `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-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()`. | +| `rvcsi-cli` | no | core, adapter-file, adapter-nexmon, runtime | The `rvcsi` binary: `record` (Nexmon-dump or nexmon-pcap → `.rvcsi`), `inspect`, `inspect-nexmon`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate`, `export ruvector` (ADR-095 FR7). | -`rvcsi-events` does **not** call into `rvcsi-dsp`: window statistics are simple enough to compute in `WindowBuffer` itself, and keeping the two leaves independent removes a coordination point. Higher layers (the daemon, `rvcsi-node`, `rvcsi-cli`) wire `SignalPipeline::process_frame` → `WindowBuffer::push` when they want cleaned frames. +`rvcsi-events` does **not** call into `rvcsi-dsp`: window statistics are simple enough to compute in `WindowBuffer` itself, and keeping the two leaves independent removes a coordination point. `rvcsi-cli` does **not** depend on `rvcsi-node` (a binary can't link a napi cdylib's undefined Node symbols) — the shared logic lives in `rvcsi-runtime`, which both build on. Higher layers wire `SignalPipeline::process_frame` → `WindowBuffer::push` when they want cleaned frames. -The TypeScript SDK (`@ruv/rvcsi`) and the MCP tool server (`rvcsi-mcp`) and the long-running daemon (`rvcsi-daemon`) are *not* in this ADR's scope; they sit on top of `rvcsi-node` / the crates above and are tracked as follow-ups. +The MCP tool server (`rvcsi-mcp`) and the long-running daemon (`rvcsi-daemon`) — and live radio capture — are *not* in this ADR's scope; they sit on top of `rvcsi-runtime` / the crates above and are tracked as follow-ups. The `@ruv/rvcsi` npm package ships alongside `rvcsi-node`. -### 2.2 The napi-c shim — record format and contract +### 2.2 The napi-c shim — record formats and contract -`native/rvcsi_nexmon_shim.{c,h}` is the only C in the runtime. It parses (and, for the recorder and tests, writes) a compact, byte-defined **"rvCSI Nexmon record"** — a normalized superset of the nexmon_csi UDP payload (magic, RSSI, chanspec, then interleaved `int16` I/Q in Q8.8 fixed point): +`native/rvcsi_nexmon_shim.{c,h}` is the only C in the runtime. It handles **two byte formats** (ABI `1.1`): -``` -off size field - 0 4 magic = 0x52564E58 ('R','V','N','X') - 4 1 version = 1 - 5 1 flags (bit0 rssi present, bit1 noise floor present) - 6 2 subcarrier_count N (1 .. 2048) - 8 1 rssi_dbm (int8, valid iff flags bit0) - 9 1 noise_dbm (int8, valid iff flags bit1) - 10 2 channel (uint16) - 12 2 bandwidth_mhz (uint16) - 14 2 reserved (0) - 16 8 timestamp_ns (uint64) - 24 4*N N pairs of int16 (i, q), Q8.8 fixed point -total = 24 + 4*N -``` +**(1) The "rvCSI Nexmon record"** — a compact, self-describing record (`'RVNX'` magic, version, flags, RSSI/noise, channel, bandwidth, timestamp, then interleaved `int16` I/Q in Q8.8 fixed point; total `24 + 4*N`). Used by the `rvcsi capture`/`record` recorder, the file replay path, and tests. Functions: `rvcsi_nx_record_len`, `rvcsi_nx_parse_record`, `rvcsi_nx_write_record`. -Contract: +**(2) The *real* nexmon_csi UDP payload** — what the patched Broadcom firmware actually sends to the host (port 5500 by default): the 18-byte header `magic=0x1111 (2) · rssi int8 (1) · fctl (1) · src_mac (6) · seq_cnt (2) · core/stream (2) · chanspec (2) · chip_ver (2)`, followed by `nsub` complex CSI samples. The shim implements the **modern int16 I/Q export** (`nsub` pairs of little-endian `int16` `(real, imag)`, raw counts — what CSIKit / `csireader.py` read for the BCM43455c0 / 4358 / 4366c0); `nsub` is derived from the payload length, `(len − 18) / 4`. Functions: `rvcsi_nx_csi_udp_header` (just the 18-byte header), `rvcsi_nx_csi_udp_decode` (header + CSI body, `csi_format` selector), `rvcsi_nx_csi_udp_write` (synthesize a payload — tests/examples), and `rvcsi_nx_decode_chanspec` (decode a Broadcom d11ac chanspec word → `channel` = `chanspec & 0xff`, bandwidth from bits `[13:11]` cross-checked against the FFT size, band from bits `[15:14]` cross-checked against the channel number). The legacy nexmon *packed-float* export used by some 4339/4358 firmwares is a documented follow-up (it sits behind the same `csi_format` selector). -- **Allocation-free, global-free.** Every read is bounds-checked against the caller-supplied length; nothing can scribble outside caller buffers. -- **Structured errors, never panics.** `rvcsi_nx_parse_record` returns one of a small set of `RvcsiNxError` codes (`TOO_SHORT`, `BAD_MAGIC`, `BAD_VERSION`, `CAPACITY`, `TRUNCATED`, `ZERO_SUBCARRIERS`, `TOO_MANY_SUBCARRIERS`, `NULL_ARG`); `rvcsi_nx_strerror` maps each to a static string. -- **ABI versioned.** `rvcsi_nx_abi_version()` returns `major<<16 | minor`; the Rust side `debug_assert`s the major matches the header it was compiled against. -- The Rust `ffi` module wraps these in safe functions (`record_len`, `decode_record`, `encode_record`, `shim_abi_version`); the `unsafe` blocks are limited to the FFI calls themselves and each carries a `// SAFETY:` comment, per the project rule. +The `timestamp_ns` of a frame from format (2) comes from the **pcap packet timestamp**, not the wire (nexmon_csi doesn't carry one). The pcap file itself is parsed in **pure Rust** (`rvcsi-adapter-nexmon::pcap` — classic libpcap, all four byte-order/timestamp-resolution magics, Ethernet / raw-IPv4 / Linux-SLL link types; pcapng is a follow-up): peeling the Ethernet/IPv4/UDP headers down to the payload is not a vendor-fragility concern, so it doesn't belong in C. -A real Nexmon deployment feeds the UDP stream (or a PCAP demux) of these records to `NexmonAdapter::from_bytes`; `from_file` reads a capture dump. Production live capture (binding the UDP socket, monitor mode, firmware patch hooks) is a later increment that reuses the same record contract — the shim's job is the *parse*, not the *socket*. +Contract (both formats): + +- **Allocation-free, global-free.** Every read is bounds-checked against the caller-supplied length; nothing can scribble outside caller buffers; no `malloc`, no statics. +- **Structured errors, never panics.** Functions return one of a small set of `RvcsiNxError` codes (`TOO_SHORT`, `BAD_MAGIC`, `BAD_VERSION`, `CAPACITY`, `TRUNCATED`, `ZERO_SUBCARRIERS`, `TOO_MANY_SUBCARRIERS`, `NULL_ARG`, `BAD_NEXMON_MAGIC`, `BAD_CSI_LEN`, `UNKNOWN_FORMAT`); `rvcsi_nx_strerror` maps each to a static string. +- **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*. ### 2.3 The napi-rs surface — what crosses the seam @@ -82,8 +73,8 @@ A real Nexmon deployment feeds the UDP stream (or a PCAP demux) of these records - **Only normalized/validated data crosses.** The boundary types are JS-friendly mirrors of `CsiFrame`/`CsiWindow`/`CsiEvent`/`AdapterProfile`/`SourceHealth`, or plain JSON strings — never raw pointers, never `Pending` frames. A frame is run through `rvcsi_core::validate_frame` before it is handed to JS. - **Errors map to JS exceptions** via napi-rs's `Result` integration; `RvcsiError`'s `Display` is the message. -- **The build emits link args + `index.d.ts`/`index.js`** via `napi_build::setup()` in `build.rs`; the `@ruv/rvcsi` npm package wraps the prebuilt addon and re-exports the generated `.d.ts`. -- The addon also re-exports `nexmon_shim_abi_version()` so a JS caller can confirm the linked napi-c shim's ABI. +- **The build emits link args + `binding.js`/`binding.d.ts`** via `napi_build::setup()` in `build.rs`; the `@ruv/rvcsi` npm package's hand-written `index.js`/`index.d.ts` wrap that loader and `JSON.parse` the addon's returns into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects. +- The free functions exposed are: `rvcsiVersion`, `nexmonShimAbiVersion` (the linked shim's ABI), `nexmonDecodeRecords`, `nexmonDecodePcap`, `inspectNexmonPcap`, `decodeChanspec`, `inspectCaptureFile`, `eventsFromCaptureFile`, `exportCaptureToRfMemory`; plus the `RvcsiRuntime` streaming class (`openCaptureFile` / `openNexmonFile` / `openNexmonPcap` factories + `nextFrameJson` / `nextCleanFrameJson` / `drainEventsJson` / `healthJson`). ### 2.4 Build & test invariants @@ -129,13 +120,15 @@ A real Nexmon deployment feeds the UDP stream (or a PCAP demux) of these records --- -## 5. Status of the implementation (this PR) +## 5. Status of the implementation - `rvcsi-core` — implemented, `forbid(unsafe_code)`, 29 unit tests. -- `rvcsi-adapter-nexmon` + the napi-c shim — implemented; C compiled via `build.rs`+`cc`; `ffi` wrappers + `NexmonAdapter`; 9 tests round-tripping through the C shim. -- `rvcsi-dsp`, `rvcsi-events`, `rvcsi-adapter-file`, `rvcsi-ruvector` — implemented (parallel swarm), each with its own test suite. -- `rvcsi-node` (napi-rs surface) and `rvcsi-cli` — implemented (the addon's Rust surface + the `rvcsi` subcommands); the `@ruv/rvcsi` npm wrapper and a Node smoke test ship alongside. -- `rvcsi-mcp` (MCP tool server) and `rvcsi-daemon` (long-running capture service) — not in this PR; tracked as follow-ups on top of `rvcsi-node`. +- `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-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-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/Cargo.lock b/v2/Cargo.lock index bcb3fcbf..0238f6f7 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -6026,6 +6026,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "rvcsi-adapter-nexmon", "rvcsi-core", "rvcsi-runtime", "serde", diff --git a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c index 4b934b2a..39caa0cd 100644 --- a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c +++ b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c @@ -1,15 +1,16 @@ /* * rvCSI — Nexmon CSI compatibility shim implementation (napi-c layer). - * See rvcsi_nexmon_shim.h for the record layout and contract. + * See rvcsi_nexmon_shim.h for the record/packet layouts and the contract. * * Deliberately tiny, allocation-free, and dependency-free (libc only). Every - * read is bounds-checked; nothing here can scribble outside caller buffers. + * read is bounds-checked against the caller-supplied length; nothing here can + * scribble outside caller buffers, and nothing here panics or aborts. */ #include "rvcsi_nexmon_shim.h" #include -#define RVCSI_NX_ABI 0x00010000u /* major.minor = 1.0 */ +#define RVCSI_NX_ABI 0x00010001u /* major.minor = 1.1 (added the nexmon_csi UDP entry points) */ /* ---- little-endian load/store helpers (portable, no aliasing UB) ---- */ @@ -41,18 +42,24 @@ static void st_u64(uint8_t *p, uint64_t v) { } static void st_i16(uint8_t *p, int16_t v) { st_u16(p, (uint16_t)v); } -/* Q8.8 fixed-point <-> float, with saturation on encode. */ +/* Q8.8 fixed-point <-> float, with saturation on encode (rvCSI record format). */ static float q88_to_f(int16_t v) { return (float)v / 256.0f; } static int16_t f_to_q88(float f) { float scaled = f * 256.0f; if (scaled >= 32767.0f) return (int16_t)32767; if (scaled <= -32768.0f) return (int16_t)-32768; - /* round to nearest, ties away from zero */ - if (scaled >= 0.0f) - return (int16_t)(scaled + 0.5f); + if (scaled >= 0.0f) return (int16_t)(scaled + 0.5f); return (int16_t)(scaled - 0.5f); } +/* Plain int16 <-> float for the raw nexmon_csi int16 I/Q export. */ +static int16_t f_to_i16_sat(float f) { + if (f >= 32767.0f) return (int16_t)32767; + if (f <= -32768.0f) return (int16_t)-32768; + if (f >= 0.0f) return (int16_t)(f + 0.5f); + return (int16_t)(f - 0.5f); +} + uint32_t rvcsi_nx_abi_version(void) { return RVCSI_NX_ABI; } const char *rvcsi_nx_strerror(int code) { @@ -66,13 +73,15 @@ const char *rvcsi_nx_strerror(int code) { case RVCSI_NX_ERR_ZERO_SUBCARRIERS: return "record declares zero subcarriers"; case RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS: return "record declares too many subcarriers"; case RVCSI_NX_ERR_NULL_ARG: return "null argument"; + case RVCSI_NX_ERR_BAD_NEXMON_MAGIC: return "nexmon_csi UDP magic mismatch (expected 0x1111)"; + case RVCSI_NX_ERR_BAD_CSI_LEN: return "nexmon_csi CSI body length is not a positive multiple of 4"; + case RVCSI_NX_ERR_UNKNOWN_FORMAT: return "unknown CSI body format"; default: return "unknown error"; } } -/* Validate the header at buf[0..24); on success return N (subcarrier count) and - * the total record size via *out_total. On failure return a negative - * RvcsiNxError. */ +/* ===== rvCSI record (format 1) ======================================== */ + static int validate_header(const uint8_t *buf, size_t len, uint16_t *out_n, size_t *out_total) { if (len < (size_t)RVCSI_NX_HEADER_BYTES) return -RVCSI_NX_ERR_TOO_SHORT; @@ -157,3 +166,148 @@ size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta, } return total; } + +/* ===== real nexmon_csi UDP payload (format 2) ========================= */ + +/* Map a subcarrier (FFT) count to a bandwidth in MHz, per the standard nexmon + * exports: 64->20, 128->40, 256->80, 512->160 (and the half-bands 32->10, + * 16->5). Returns 0 if `nsub` doesn't look like one of those. */ +static uint16_t bw_from_nsub(uint16_t nsub) { + switch (nsub) { + case 16: return 5; + case 32: return 10; + case 64: return 20; + case 128: return 40; + case 256: return 80; + case 512: return 160; + default: return 0; + } +} + +/* Broadcom d11ac chanspec bandwidth field (bits [13:11]) -> MHz. */ +static uint16_t bw_from_chanspec(uint16_t chanspec) { + switch ((chanspec >> 11) & 0x7u) { + case 2: return 20; + case 3: return 40; + case 4: return 80; + case 5: return 160; + case 6: return 80; /* 80+80: report the per-segment width */ + default: return 0; + } +} + +void rvcsi_nx_decode_chanspec(uint16_t chanspec, uint16_t *out_channel, + uint16_t *out_bw_mhz, uint8_t *out_is_5ghz) { + uint16_t channel = (uint16_t)(chanspec & 0x00FFu); + uint16_t bw = bw_from_chanspec(chanspec); + /* Band bits [15:14]: d11ac 5 GHz == 0b11. Cross-check with the channel number + * for robustness against older chanspec encodings. */ + uint8_t band_is_5ghz = (((chanspec >> 14) & 0x3u) == 0x3u) ? 1u : 0u; + if (!band_is_5ghz && channel > 14u) band_is_5ghz = 1u; + if (band_is_5ghz && channel >= 1u && channel <= 13u && bw == 20u) { + /* almost certainly a 2.4 GHz control channel mislabeled by an old encoding */ + band_is_5ghz = 0u; + } + if (out_channel) *out_channel = channel; + if (out_bw_mhz) *out_bw_mhz = bw; + if (out_is_5ghz) *out_is_5ghz = band_is_5ghz; +} + +/* Validate + parse the 18-byte header; on success returns N (subcarrier count) + * and fills *out. On failure returns a negative RvcsiNxError. */ +static int parse_nexmon_header(const uint8_t *payload, size_t len, + RvcsiNxUdpHeader *out, uint16_t *out_n) { + if (payload == NULL || out == NULL) return -RVCSI_NX_ERR_NULL_ARG; + if (len < (size_t)RVCSI_NX_NEXMON_HDR_BYTES) return -RVCSI_NX_ERR_TOO_SHORT; + if (ld_u16(payload) != RVCSI_NX_NEXMON_MAGIC) return -RVCSI_NX_ERR_BAD_NEXMON_MAGIC; + + size_t csi_bytes = len - (size_t)RVCSI_NX_NEXMON_HDR_BYTES; + if (csi_bytes == 0u || (csi_bytes % 4u) != 0u) return -RVCSI_NX_ERR_BAD_CSI_LEN; + size_t nsub = csi_bytes / 4u; + if (nsub > RVCSI_NX_MAX_SUBCARRIERS) return -RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS; + + uint16_t core_stream = ld_u16(payload + 12); + uint16_t chanspec = ld_u16(payload + 14); + + memset(out, 0, sizeof(*out)); + out->rssi_dbm = (int16_t)(int8_t)payload[2]; + out->fctl = payload[3]; + memcpy(out->src_mac, payload + 4, 6); + out->seq_cnt = ld_u16(payload + 10); + out->core = (uint16_t)(core_stream & 0x7u); + out->spatial_stream = (uint16_t)((core_stream >> 3) & 0x7u); + out->chanspec = chanspec; + out->chip_ver = ld_u16(payload + 16); + rvcsi_nx_decode_chanspec(chanspec, &out->channel, &out->bandwidth_mhz, &out->is_5ghz); + out->subcarrier_count = (uint16_t)nsub; + /* Prefer the FFT-derived bandwidth when the chanspec bits are missing/odd. */ + { + uint16_t bw_n = bw_from_nsub((uint16_t)nsub); + if (bw_n != 0u) out->bandwidth_mhz = bw_n; + } + *out_n = (uint16_t)nsub; + return 0; +} + +int rvcsi_nx_csi_udp_header(const uint8_t *payload, size_t len, + RvcsiNxUdpHeader *out) { + uint16_t n; + int rc = parse_nexmon_header(payload, len, out, &n); + return (rc < 0) ? -rc : RVCSI_NX_OK; +} + +int rvcsi_nx_csi_udp_decode(const uint8_t *payload, size_t len, int csi_format, + RvcsiNxUdpHeader *hdr_out, RvcsiNxMeta *meta, + float *i_out, float *q_out, size_t cap) { + if (meta == NULL || i_out == NULL || q_out == NULL) return RVCSI_NX_ERR_NULL_ARG; + if (csi_format != RVCSI_NX_CSI_FMT_INT16_IQ) return RVCSI_NX_ERR_UNKNOWN_FORMAT; + + RvcsiNxUdpHeader hdr; + uint16_t n; + int rc = parse_nexmon_header(payload, len, &hdr, &n); + if (rc < 0) return -rc; + if ((size_t)n > cap) return RVCSI_NX_ERR_CAPACITY; + + meta->subcarrier_count = n; + meta->channel = hdr.channel; + meta->bandwidth_mhz = hdr.bandwidth_mhz; + meta->rssi_dbm = hdr.rssi_dbm; /* always present in the nexmon header */ + meta->noise_floor_dbm = RVCSI_NX_ABSENT_I16; /* not carried by nexmon_csi */ + meta->timestamp_ns = 0u; /* the caller stamps this from the pcap packet time */ + + const uint8_t *p = payload + RVCSI_NX_NEXMON_HDR_BYTES; + for (uint16_t k = 0; k < n; ++k) { + i_out[k] = (float)ld_i16(p); /* real, raw int16 count */ + q_out[k] = (float)ld_i16(p + 2); /* imag, raw int16 count */ + p += 4; + } + if (hdr_out) *hdr_out = hdr; + return RVCSI_NX_OK; +} + +size_t rvcsi_nx_csi_udp_write(uint8_t *buf, size_t cap, const RvcsiNxUdpHeader *hdr, + uint16_t subcarrier_count, const float *i_in, + const float *q_in) { + if (buf == NULL || hdr == NULL || i_in == NULL || q_in == NULL) return 0; + if (subcarrier_count == 0u || subcarrier_count > RVCSI_NX_MAX_SUBCARRIERS) return 0; + size_t total = (size_t)RVCSI_NX_NEXMON_HDR_BYTES + (size_t)subcarrier_count * 4u; + if (cap < total) return 0; + + memset(buf, 0, RVCSI_NX_NEXMON_HDR_BYTES); + st_u16(buf, RVCSI_NX_NEXMON_MAGIC); + buf[2] = (uint8_t)(int8_t)hdr->rssi_dbm; + buf[3] = hdr->fctl; + memcpy(buf + 4, hdr->src_mac, 6); + st_u16(buf + 10, hdr->seq_cnt); + st_u16(buf + 12, (uint16_t)((hdr->core & 0x7u) | ((hdr->spatial_stream & 0x7u) << 3))); + st_u16(buf + 14, hdr->chanspec); + st_u16(buf + 16, hdr->chip_ver); + + uint8_t *p = buf + RVCSI_NX_NEXMON_HDR_BYTES; + for (uint16_t k = 0; k < subcarrier_count; ++k) { + st_i16(p, f_to_i16_sat(i_in[k])); + st_i16(p + 2, f_to_i16_sat(q_in[k])); + p += 4; + } + return total; +} 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 5bcadeda..b54ce6f4 100644 --- a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h +++ b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h @@ -1,16 +1,25 @@ /* * rvCSI — Nexmon CSI compatibility shim (napi-c layer, ADR-095 D2, ADR-096). * - * This is the ONLY C in the rvCSI runtime. It parses (and, for tests, writes) - * a compact, byte-defined "rvCSI Nexmon record" — a normalized superset of the - * nexmon_csi UDP payload (magic, RSSI, chanspec, then interleaved int16 I/Q). - * The Rust side (`rvcsi-adapter-nexmon`) wraps these functions and never sees - * raw vendor structs; everything above this file is safe Rust. + * This is the ONLY C in the rvCSI runtime. It is the seam against fragile + * vendor/firmware byte formats; everything above this file is safe Rust. * - * Record layout (all integers little-endian): + * It exposes two record formats: * + * (1) the "rvCSI Nexmon record" — a compact, byte-defined, self-describing + * record (magic 'RVNX', RSSI, channel, timestamp, then interleaved int16 + * I/Q in Q8.8 fixed point). Used by the recorder, replay, and tests. + * + * (2) the *real* nexmon_csi UDP payload — what the patched Broadcom firmware + * (BCM43455c0 / 4358 / 4366c0, …) actually sends: an 18-byte header + * (magic 0x1111, RSSI, frame-control, source MAC, sequence, core/spatial + * stream, Broadcom chanspec, chip version) followed by `nsub` complex CSI + * samples. We implement the modern format (int16 LE I/Q interleaved — what + * CSIKit / csireader.py read for the 43455c0 et al.); the legacy packed- + * float export used by some 4339/4358 firmwares is a documented follow-up. + * + * Record (1) layout (all integers little-endian): * off size field - * --- ---- ----------------------------------------------------------- * 0 4 magic = 0x52564E58 ('R','V','N','X') * 4 1 version = RVCSI_NX_VERSION (1) * 5 1 flags bit0: rssi present, bit1: noise floor present @@ -22,10 +31,21 @@ * 14 2 reserved (0) * 16 8 timestamp_ns uint64 * 24 4*N N pairs of int16 (i, q), interleaved, fixed-point Q8.8 + * total = 24 + 4*N bytes; stored int16 v maps to float v / 256.0 * - * total record size = 24 + 4*N bytes - * - * Fixed-point: stored int16 value v maps to float v / 256.0. + * Format (2) — nexmon_csi UDP payload header (all little-endian): + * off size field + * 0 2 magic = 0x1111 + * 2 1 rssi int8 (dBm) + * 3 1 fctl uint8 (802.11 frame-control byte) + * 4 6 src_mac uint8[6] + * 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) + * 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. */ #ifndef RVCSI_NEXMON_SHIM_H #define RVCSI_NEXMON_SHIM_H @@ -44,20 +64,31 @@ extern "C" { #define RVCSI_NX_FLAG_RSSI 0x01u #define RVCSI_NX_FLAG_NOISE 0x02u +/* nexmon_csi UDP payload constants. */ +#define RVCSI_NX_NEXMON_MAGIC 0x1111u +#define RVCSI_NX_NEXMON_HDR_BYTES 18 + +/* CSI body formats for rvcsi_nx_csi_udp_decode. */ +#define RVCSI_NX_CSI_FMT_INT16_IQ 0 /* nsub pairs of int16 LE (real, imag) — the modern 43455c0/4358/4366c0 export */ +/* (1 = legacy nexmon packed-float — not yet implemented; see header comment) */ + /* Sentinel for "metadata field absent". */ #define RVCSI_NX_ABSENT_I16 ((int16_t)0x7FFF) -/* Error codes returned (negated) by rvcsi_nx_parse_record / rvcsi_nx_write_record. */ +/* Error codes returned (positive; the negated value is used internally). */ typedef enum { RVCSI_NX_OK = 0, RVCSI_NX_ERR_TOO_SHORT = 1, /* buffer shorter than the header */ - RVCSI_NX_ERR_BAD_MAGIC = 2, /* magic mismatch */ - RVCSI_NX_ERR_BAD_VERSION = 3, /* unsupported version */ + RVCSI_NX_ERR_BAD_MAGIC = 2, /* rvCSI-record magic mismatch */ + RVCSI_NX_ERR_BAD_VERSION = 3, /* unsupported rvCSI-record version */ RVCSI_NX_ERR_CAPACITY = 4, /* caller i/q buffer too small for N */ - RVCSI_NX_ERR_TRUNCATED = 5, /* buffer shorter than 24 + 4*N */ + RVCSI_NX_ERR_TRUNCATED = 5, /* buffer shorter than the declared record */ RVCSI_NX_ERR_ZERO_SUBCARRIERS = 6, RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS = 7, - RVCSI_NX_ERR_NULL_ARG = 8 + RVCSI_NX_ERR_NULL_ARG = 8, + RVCSI_NX_ERR_BAD_NEXMON_MAGIC = 9, /* nexmon_csi UDP magic != 0x1111 */ + RVCSI_NX_ERR_BAD_CSI_LEN = 10, /* (len - 18) not a positive multiple of 4 */ + RVCSI_NX_ERR_UNKNOWN_FORMAT = 11 /* csi_format not recognised */ } RvcsiNxError; /* Decoded per-record metadata (the I/Q samples are written separately into @@ -66,43 +97,86 @@ typedef struct RvcsiNxMeta { uint16_t subcarrier_count; uint16_t channel; uint16_t bandwidth_mhz; - int16_t rssi_dbm; /* RVCSI_NX_ABSENT_I16 if not present */ - int16_t noise_floor_dbm;/* RVCSI_NX_ABSENT_I16 if not present */ + int16_t rssi_dbm; /* RVCSI_NX_ABSENT_I16 if not present */ + int16_t noise_floor_dbm; /* RVCSI_NX_ABSENT_I16 if not present */ uint64_t timestamp_ns; } RvcsiNxMeta; -/* - * Length, in bytes, of the record that starts at `buf`, given `len` bytes are - * available. Returns 0 if `len` is too small to even read the header, the magic - * is wrong, the version is unsupported, the subcarrier count is out of range, - * or `len` < the full record. On success returns 24 + 4*N (>= 28). - */ +/* The parsed 18-byte nexmon_csi UDP header (raw vendor fields preserved). */ +typedef struct RvcsiNxUdpHeader { + int16_t rssi_dbm; /* sign-extended from the int8 in the packet */ + uint8_t fctl; + uint8_t src_mac[6]; + uint16_t seq_cnt; + uint16_t core; /* rx core index, core_stream bits [2:0] */ + uint16_t spatial_stream;/* spatial stream index, core_stream bits [5:3] */ + uint16_t chanspec; /* raw Broadcom chanspec word */ + uint16_t chip_ver; + uint16_t channel; /* decoded from chanspec */ + uint16_t bandwidth_mhz; /* decoded from chanspec (0 = unknown) */ + uint8_t is_5ghz; /* 1 if the chanspec band bits say 5 GHz, else 0 */ + uint16_t subcarrier_count; /* derived from the payload length: (len-18)/4 */ +} RvcsiNxUdpHeader; + +/* ----- rvCSI record (format 1) ---------------------------------------- */ + +/* Length, in bytes, of the rvCSI record at `buf` given `len` available, or 0 on + * any problem (too short / bad magic / bad version / N out of range / truncated). */ size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len); -/* - * Parse one record at `buf` (with `len` bytes available). Fills `*meta` and - * writes `subcarrier_count` floats into each of `i_out` and `q_out` (which must - * each have capacity `cap`). Returns RVCSI_NX_OK (0) on success, or one of the - * RvcsiNxError codes (positive) on failure. No allocation, no globals. - */ +/* Parse one rvCSI record at `buf`; fills `*meta` and writes `subcarrier_count` + * floats into each of `i_out`/`q_out` (capacity `cap` each). Returns RVCSI_NX_OK + * or a positive RvcsiNxError. No allocation, no globals. */ int rvcsi_nx_parse_record(const uint8_t *buf, size_t len, RvcsiNxMeta *meta, float *i_out, float *q_out, size_t cap); -/* - * Serialize one record into `buf` (capacity `cap`). `i_in`/`q_in` hold - * `meta->subcarrier_count` floats each (clamped to the Q8.8 range). Returns the - * number of bytes written (24 + 4*N) on success, or 0 on error (null arg, zero - * or too-many subcarriers, capacity too small). Used by Rust tests and the - * `rvcsi capture` recorder; production capture comes straight off the wire. - */ +/* Serialize one rvCSI record into `buf` (capacity `cap`). Returns the byte count + * (24 + 4*N) or 0 on error. */ size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta, const float *i_in, const float *q_in); +/* ----- real nexmon_csi UDP payload (format 2) ------------------------- */ + +/* Decode a Broadcom d11ac chanspec word into channel / bandwidth (MHz) / band. + * `out_channel` gets `chanspec & 0xff`; `out_bw_mhz` gets 20/40/80/160 (or 0 if + * the bandwidth bits are unrecognised); `out_is_5ghz` gets 1 for the 5 GHz band + * bits, 0 otherwise. Any out pointer may be NULL. Always succeeds. */ +void rvcsi_nx_decode_chanspec(uint16_t chanspec, uint16_t *out_channel, + uint16_t *out_bw_mhz, uint8_t *out_is_5ghz); + +/* Parse just the 18-byte nexmon_csi UDP header at `payload` (length `len`), + * filling `*out` (including the chanspec-decoded channel/bandwidth and the + * length-derived subcarrier count). Returns RVCSI_NX_OK or a positive error + * (TOO_SHORT, BAD_NEXMON_MAGIC, BAD_CSI_LEN, NULL_ARG). */ +int rvcsi_nx_csi_udp_header(const uint8_t *payload, size_t len, + RvcsiNxUdpHeader *out); + +/* Full decode of a nexmon_csi UDP payload: parses the 18-byte header, then the + * CSI body according to `csi_format` (currently only RVCSI_NX_CSI_FMT_INT16_IQ). + * Fills `*meta` (channel/bandwidth from the chanspec, rssi from the header, + * subcarrier_count from the length; `timestamp_ns` is left 0 — the caller stamps + * it from the pcap packet time). Writes `subcarrier_count` floats into each of + * `i_out`/`q_out` (capacity `cap`). If `hdr_out` is non-NULL it also receives the + * full parsed header. Returns RVCSI_NX_OK or a positive RvcsiNxError. */ +int rvcsi_nx_csi_udp_decode(const uint8_t *payload, size_t len, int csi_format, + RvcsiNxUdpHeader *hdr_out, RvcsiNxMeta *meta, + float *i_out, float *q_out, size_t cap); + +/* Write a synthetic nexmon_csi UDP payload (the 18-byte header + int16 I/Q body) + * into `buf` (capacity `cap`). Used by tests and the `nexmon` synthetic-source. + * `i_in`/`q_in` hold `subcarrier_count` raw int16-valued samples each (clamped to + * the int16 range on write). Returns the byte count (18 + 4*N) or 0 on error. */ +size_t rvcsi_nx_csi_udp_write(uint8_t *buf, size_t cap, const RvcsiNxUdpHeader *hdr, + uint16_t subcarrier_count, const float *i_in, + const float *q_in); + +/* ----- misc ----------------------------------------------------------- */ + /* Static, human-readable string for an RvcsiNxError code. Never NULL. */ const char *rvcsi_nx_strerror(int code); -/* ABI version of this shim — bumped if the record layout or function - * signatures change. The Rust side asserts it matches at startup. */ +/* ABI version of this shim (`major << 16 | minor`); the Rust side asserts the + * major matches. Bumped to 1.1 when the nexmon_csi UDP entry points were added. */ uint32_t rvcsi_nx_abi_version(void); #ifdef __cplusplus diff --git a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs index 71707dc2..7a304bf0 100644 --- a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs +++ b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs @@ -44,10 +44,58 @@ extern "C" { i_in: *const f32, q_in: *const f32, ) -> usize; + fn rvcsi_nx_decode_chanspec( + chanspec: u16, + out_channel: *mut u16, + out_bw_mhz: *mut u16, + out_is_5ghz: *mut u8, + ); + fn rvcsi_nx_csi_udp_header(payload: *const u8, len: usize, out: *mut RvcsiNxUdpHeader) -> i32; + fn rvcsi_nx_csi_udp_decode( + payload: *const u8, + len: usize, + csi_format: i32, + hdr_out: *mut RvcsiNxUdpHeader, + meta: *mut RvcsiNxMeta, + i_out: *mut f32, + q_out: *mut f32, + cap: usize, + ) -> i32; + fn rvcsi_nx_csi_udp_write( + buf: *mut u8, + cap: usize, + hdr: *const RvcsiNxUdpHeader, + subcarrier_count: u16, + i_in: *const f32, + q_in: *const f32, + ) -> usize; fn rvcsi_nx_strerror(code: i32) -> *const c_char; fn rvcsi_nx_abi_version() -> u32; } +/// Mirrors the C `RvcsiNxUdpHeader` (the parsed 18-byte nexmon_csi UDP header). +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +struct RvcsiNxUdpHeader { + rssi_dbm: i16, + fctl: u8, + src_mac: [u8; 6], + seq_cnt: u16, + core: u16, + spatial_stream: u16, + chanspec: u16, + chip_ver: u16, + channel: u16, + bandwidth_mhz: u16, + is_5ghz: u8, + subcarrier_count: u16, +} + +/// `csi_format` selector for [`decode_nexmon_udp`]: `nsub` pairs of int16 LE +/// `(real, imag)` — the modern BCM43455c0 / 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; + /// ABI version of the linked C shim (`major << 16 | minor`). pub fn shim_abi_version() -> u32 { // SAFETY: no arguments, returns a plain u32 by value. @@ -210,6 +258,230 @@ pub fn encode_record(rec: &NexmonRecord) -> Result, NexmonFfiError> { Ok(buf) } +// ===== real nexmon_csi UDP payload (format 2) ========================== + +/// A Broadcom d11ac `chanspec` decoded into (channel, bandwidth-MHz, 5 GHz?). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodedChanspec { + /// Raw chanspec word. + pub chanspec: u16, + /// `chanspec & 0xff`. + pub channel: u16, + /// 20 / 40 / 80 / 160, or `0` if the bandwidth bits are unrecognised. + pub bandwidth_mhz: u16, + /// `true` if the band bits (cross-checked against the channel number) say 5 GHz. + pub is_5ghz: bool, +} + +/// Decode a Broadcom d11ac chanspec word (via the C shim). +pub fn decode_chanspec(chanspec: u16) -> DecodedChanspec { + let (mut ch, mut bw, mut b5) = (0u16, 0u16, 0u8); + // SAFETY: three valid out-pointers to owned locals; the C side only writes them. + unsafe { rvcsi_nx_decode_chanspec(chanspec, &mut ch, &mut bw, &mut b5) }; + DecodedChanspec { + chanspec, + channel: ch, + bandwidth_mhz: bw, + is_5ghz: b5 != 0, + } +} + +/// The parsed 18-byte nexmon_csi UDP header (raw vendor fields preserved, plus +/// the chanspec-decoded channel/bandwidth/band and the length-derived subcarrier +/// count). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NexmonCsiHeader { + /// RSSI in dBm (sign-extended from the int8 in the packet). + pub rssi_dbm: i16, + /// 802.11 frame-control byte. + pub fctl: u8, + /// Source MAC address. + pub src_mac: [u8; 6], + /// 802.11 sequence-control word. + pub seq_cnt: u16, + /// Receive core index (`core_stream` bits [2:0]). + pub core: u16, + /// Spatial-stream index (`core_stream` bits [5:3]). + pub spatial_stream: u16, + /// Raw Broadcom chanspec word. + pub chanspec: u16, + /// Chip version (e.g. `0x0142` = BCM43455c0). + pub chip_ver: u16, + /// Channel number decoded from the chanspec. + pub channel: u16, + /// Bandwidth (MHz) — from the FFT size when known, else the chanspec bits. + pub bandwidth_mhz: u16, + /// `true` if the band bits say 5 GHz. + pub is_5ghz: bool, + /// Subcarrier (FFT) count, `(payload_len - 18) / 4`. + pub subcarrier_count: u16, +} + +impl From for NexmonCsiHeader { + fn from(h: RvcsiNxUdpHeader) -> Self { + NexmonCsiHeader { + rssi_dbm: h.rssi_dbm, + fctl: h.fctl, + src_mac: h.src_mac, + seq_cnt: h.seq_cnt, + core: h.core, + spatial_stream: h.spatial_stream, + chanspec: h.chanspec, + chip_ver: h.chip_ver, + channel: h.channel, + bandwidth_mhz: h.bandwidth_mhz, + is_5ghz: h.is_5ghz != 0, + subcarrier_count: h.subcarrier_count, + } + } +} + +impl NexmonCsiHeader { + fn to_c(&self) -> RvcsiNxUdpHeader { + RvcsiNxUdpHeader { + rssi_dbm: self.rssi_dbm, + fctl: self.fctl, + src_mac: self.src_mac, + seq_cnt: self.seq_cnt, + core: self.core, + spatial_stream: self.spatial_stream, + chanspec: self.chanspec, + chip_ver: self.chip_ver, + channel: self.channel, + bandwidth_mhz: self.bandwidth_mhz, + is_5ghz: self.is_5ghz as u8, + subcarrier_count: self.subcarrier_count, + } + } +} + +fn check(rc: i32) -> Result<(), NexmonFfiError> { + if rc == 0 { + Ok(()) + } else { + Err(NexmonFfiError::Shim { + code: rc, + message: strerror(rc), + }) + } +} + +/// Parse just the 18-byte nexmon_csi UDP header of `payload`. +pub fn parse_nexmon_udp_header(payload: &[u8]) -> Result { + let mut hdr = RvcsiNxUdpHeader::default(); + // SAFETY: `payload` valid for `payload.len()`; `hdr` is an owned struct the + // C side only writes on RVCSI_NX_OK (and zero-initialises first). + let rc = unsafe { rvcsi_nx_csi_udp_header(payload.as_ptr(), payload.len(), &mut hdr) }; + check(rc)?; + Ok(hdr.into()) +} + +/// Fully decode a nexmon_csi UDP payload (the 18-byte header + the CSI body). +/// Returns the parsed header and a [`NexmonRecord`] whose `timestamp_ns` is `0` +/// (the caller stamps it from the pcap packet time). `csi_format` is currently +/// only [`NEXMON_CSI_FMT_INT16_IQ`]. +pub fn decode_nexmon_udp( + payload: &[u8], + csi_format: i32, +) -> Result<(NexmonCsiHeader, NexmonRecord), NexmonFfiError> { + // First parse the header so we know `nsub` (and reject bad packets early). + let header = parse_nexmon_udp_header(payload)?; + let n = header.subcarrier_count as usize; + if n == 0 || n > MAX_SUBCARRIERS { + return Err(NexmonFfiError::Shim { + code: 7, + message: "subcarrier count out of range".to_string(), + }); + } + let mut hdr = RvcsiNxUdpHeader::default(); + let mut meta = RvcsiNxMeta { + subcarrier_count: 0, + channel: 0, + bandwidth_mhz: 0, + rssi_dbm: 0, + noise_floor_dbm: 0, + timestamp_ns: 0, + }; + let mut i_out = vec![0.0f32; n]; + let mut q_out = vec![0.0f32; n]; + // SAFETY: `payload` valid for its length; `i_out`/`q_out` valid for `n` + // f32s each (we pass `n` as the capacity); `hdr`/`meta` are owned structs + // the C side fully initialises on RVCSI_NX_OK and writes nothing else. + let rc = unsafe { + rvcsi_nx_csi_udp_decode( + payload.as_ptr(), + payload.len(), + csi_format, + &mut hdr, + &mut meta, + i_out.as_mut_ptr(), + q_out.as_mut_ptr(), + n, + ) + }; + check(rc)?; + debug_assert_eq!(meta.subcarrier_count as usize, n); + let rec = NexmonRecord { + subcarrier_count: meta.subcarrier_count, + channel: meta.channel, + bandwidth_mhz: meta.bandwidth_mhz, + rssi_dbm: (meta.rssi_dbm != ABSENT_I16).then_some(meta.rssi_dbm), + noise_floor_dbm: (meta.noise_floor_dbm != ABSENT_I16).then_some(meta.noise_floor_dbm), + timestamp_ns: meta.timestamp_ns, + i_values: i_out, + q_values: q_out, + }; + Ok((NexmonCsiHeader::from(hdr), rec)) +} + +/// Serialize a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q body) +/// — used by tests and the synthetic Nexmon source. `i_values`/`q_values` are the +/// raw int16-valued samples (clamped to the int16 range on write); their length +/// must equal `header.subcarrier_count`. +pub fn encode_nexmon_udp( + header: &NexmonCsiHeader, + i_values: &[f32], + q_values: &[f32], +) -> Result, NexmonFfiError> { + let n = header.subcarrier_count as usize; + if n == 0 || n > MAX_SUBCARRIERS || i_values.len() != n || q_values.len() != n { + return Err(NexmonFfiError::Shim { + code: 6, + message: "bad subcarrier count or i/q length".to_string(), + }); + } + let c_hdr = header.to_c(); + let cap = NEXMON_HEADER_BYTES + n * 4; + let mut buf = vec![0u8; cap]; + // SAFETY: `buf` valid for `cap` bytes; `i_in`/`q_in` valid for `n` f32s each + // (checked above); `c_hdr` is a fully initialised owned struct. + let written = unsafe { + rvcsi_nx_csi_udp_write( + buf.as_mut_ptr(), + cap, + &c_hdr as *const RvcsiNxUdpHeader, + header.subcarrier_count, + i_values.as_ptr(), + q_values.as_ptr(), + ) + }; + if written == 0 { + return Err(NexmonFfiError::Shim { + code: 4, + message: "csi_udp_write failed (capacity or argument error)".to_string(), + }); + } + debug_assert_eq!(written, cap); + buf.truncate(written); + Ok(buf) +} + +/// Bytes in the nexmon_csi UDP header (mirrors `RVCSI_NX_NEXMON_HDR_BYTES`). +pub const NEXMON_HEADER_BYTES: usize = 18; + +/// nexmon_csi UDP payload magic (`0x1111`, the first two LE bytes of the header). +pub const NEXMON_MAGIC: u16 = 0x1111; + #[cfg(test)] mod tests { use super::*; @@ -253,4 +525,120 @@ mod tests { }; assert!(encode_record(&rec).is_err()); } + + // ----- nexmon_csi UDP payload (format 2) ----- + + #[test] + fn chanspec_decode_known_values() { + // 2.4 GHz, channel 6, 20 MHz: band 2G (0x0000) | BW_20 (0x1000) | 0x06 + let c = decode_chanspec(0x1000 | 6); + assert_eq!(c.channel, 6); + assert_eq!(c.bandwidth_mhz, 20); + assert!(!c.is_5ghz); + // 5 GHz, channel 36, 80 MHz: band 5G (0xc000) | BW_80 (0x2000) | 0x24 + let c = decode_chanspec(0xc000 | 0x2000 | 36); + assert_eq!(c.channel, 36); + assert_eq!(c.bandwidth_mhz, 80); + assert!(c.is_5ghz); + // 5 GHz, channel 149, 40 MHz: band 5G | BW_40 (0x1800) | 0x95 + let c = decode_chanspec(0xc000 | 0x1800 | 149); + assert_eq!(c.channel, 149); + assert_eq!(c.bandwidth_mhz, 40); + assert!(c.is_5ghz); + // channel > 14 with no/odd band bits still resolves to 5 GHz + let c = decode_chanspec(40); + assert_eq!(c.channel, 40); + assert!(c.is_5ghz); + } + + fn synth_header(rssi: i16, chanspec: u16, nsub: u16) -> NexmonCsiHeader { + NexmonCsiHeader { + rssi_dbm: rssi, + fctl: 0x08, + src_mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01], + seq_cnt: 0x1234, + core: 1, + spatial_stream: 0, + chanspec, + chip_ver: 0x0142, // BCM43455c0 + channel: 0, // filled by decode + bandwidth_mhz: 0, // filled by decode + is_5ghz: false, // filled by decode + subcarrier_count: nsub, + } + } + + #[test] + fn nexmon_udp_roundtrip_and_metadata() { + let nsub = 64u16; // 20 MHz + let chanspec = 0x1000u16 | 6; // 2.4G, ch6, 20 MHz + let hdr = synth_header(-58, chanspec, nsub); + let i: Vec = (0..nsub).map(|k| (k as i16 - 32) as f32).collect(); + let q: Vec = (0..nsub).map(|k| -(k as i16) as f32 + 5.0).collect(); + let payload = encode_nexmon_udp(&hdr, &i, &q).expect("encode"); + assert_eq!(payload.len(), NEXMON_HEADER_BYTES + (nsub as usize) * 4); + assert_eq!(u16::from_le_bytes([payload[0], payload[1]]), NEXMON_MAGIC); + + // header-only parse + let h = parse_nexmon_udp_header(&payload).expect("hdr"); + assert_eq!(h.rssi_dbm, -58); + assert_eq!(h.fctl, 0x08); + assert_eq!(h.src_mac, [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]); + 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.channel, 6); + assert_eq!(h.bandwidth_mhz, 20); + assert!(!h.is_5ghz); + assert_eq!(h.subcarrier_count, nsub); + + // full decode — raw int16 counts come back exactly + let (h2, rec) = decode_nexmon_udp(&payload, NEXMON_CSI_FMT_INT16_IQ).expect("decode"); + assert_eq!(h2, h); + assert_eq!(rec.subcarrier_count, nsub); + assert_eq!(rec.channel, 6); + assert_eq!(rec.bandwidth_mhz, 20); + assert_eq!(rec.rssi_dbm, Some(-58)); + assert_eq!(rec.timestamp_ns, 0); // caller stamps from pcap + assert_eq!(rec.i_values.len(), nsub as usize); + assert_eq!(rec.i_values[0], -32.0); + assert_eq!(rec.i_values[33], 1.0); + assert_eq!(rec.q_values[0], 5.0); + assert_eq!(rec.q_values[10], -5.0); + } + + #[test] + fn nexmon_udp_rejects_bad_magic_and_lengths() { + let hdr = synth_header(-60, 0x1000 | 11, 64); + let i = vec![1.0f32; 64]; + let q = vec![0.0f32; 64]; + let mut payload = encode_nexmon_udp(&hdr, &i, &q).unwrap(); + // bad magic + payload[0] = 0xFF; + assert!(parse_nexmon_udp_header(&payload).is_err()); + payload[0] = 0x11; + // too short for header + assert!(parse_nexmon_udp_header(&payload[..10]).is_err()); + // CSI body not a multiple of 4 + assert!(parse_nexmon_udp_header(&payload[..NEXMON_HEADER_BYTES + 3]).is_err()); + // zero-length CSI body + assert!(parse_nexmon_udp_header(&payload[..NEXMON_HEADER_BYTES]).is_err()); + // unknown CSI format + assert!(decode_nexmon_udp(&payload, 99).is_err()); + } + + #[test] + fn nexmon_udp_80mhz_and_160mhz_bandwidths() { + for (nsub, want_bw) in [(256u16, 80u16), (512u16, 160u16), (128u16, 40u16)] { + let hdr = synth_header(-55, 0xc000 | 0x2000 | 36, nsub); + let i = vec![0.0f32; nsub as usize]; + let q = vec![0.0f32; nsub as usize]; + let payload = encode_nexmon_udp(&hdr, &i, &q).unwrap(); + let h = parse_nexmon_udp_header(&payload).unwrap(); + assert_eq!(h.bandwidth_mhz, want_bw, "nsub={nsub}"); + assert!(h.is_5ghz); + assert_eq!(h.channel, 36); + } + } } diff --git a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs index f0b31218..5c9d263e 100644 --- a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs +++ b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs @@ -6,10 +6,16 @@ //! UDP payload). Everything above [`ffi`] is safe Rust; all `unsafe` is //! confined to this crate, bounds-checked on the C side, and documented. //! -//! Typical use: a capture dump (a file of concatenated records, or the bytes of -//! a UDP stream demux) is fed to [`NexmonAdapter::from_bytes`], which yields -//! `Pending` [`CsiFrame`]s. The runtime then runs [`rvcsi_core::validate_frame`] -//! on each before exposing it. +//! Two source paths: +//! +//! * the compact, self-describing **rvCSI Nexmon record** — fed to +//! [`NexmonAdapter::from_bytes`] (records concatenated in a buffer/file); +//! * the **real nexmon_csi UDP payload** inside a libpcap capture +//! (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) — fed to +//! [`NexmonPcapAdapter::open`] / [`NexmonPcapAdapter::parse`]. +//! +//! Both yield `Pending` [`CsiFrame`]s; the runtime runs +//! [`rvcsi_core::validate_frame`] on each before exposing it. #![warn(missing_docs)] @@ -20,8 +26,38 @@ use rvcsi_core::{ }; pub mod ffi; +pub mod pcap; -pub use ffi::{decode_record, encode_record, shim_abi_version, NexmonRecord, RECORD_HEADER_BYTES}; +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, + NexmonRecord, NEXMON_CSI_FMT_INT16_IQ, NEXMON_HEADER_BYTES, NEXMON_MAGIC, RECORD_HEADER_BYTES, +}; +pub use pcap::{ + extract_udp_payload, synthetic_udp_pcap, PcapPacket, PcapReader, LINKTYPE_ETHERNET, + LINKTYPE_IPV4, LINKTYPE_LINUX_SLL, LINKTYPE_RAW, NEXMON_DEFAULT_PORT, PCAP_MAGIC_NS, + PCAP_MAGIC_US, +}; + +/// Build a synthetic nexmon_csi `.pcap` (LE/µs/Ethernet) from +/// `(timestamp_ns, NexmonCsiHeader, i_values, q_values)` entries, sending every +/// CSI packet to UDP port `port`. Useful for tests, examples and the `rvcsi` +/// self-tests; real captures come off a Pi running patched firmware. +pub fn synthetic_nexmon_pcap( + frames: &[(u64, NexmonCsiHeader, Vec, Vec)], + port: u16, +) -> Result, NexmonFfiError> { + let payloads: Vec> = frames + .iter() + .map(|(_, h, i, q)| encode_nexmon_udp(h, i, q)) + .collect::>()?; + let refs: Vec<(u64, u16, &[u8])> = frames + .iter() + .zip(payloads.iter()) + .map(|((ts, ..), p)| (*ts, port, p.as_slice())) + .collect(); + Ok(pcap::synthetic_udp_pcap(&refs)) +} /// A [`CsiSource`] that replays a buffer of rvCSI Nexmon records. /// @@ -171,6 +207,155 @@ impl CsiSource for NexmonAdapter { } } +/// A [`CsiSource`] that reads the *real* nexmon_csi UDP payloads out of a +/// libpcap (`.pcap`) capture (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`). +/// +/// The pcap is parsed eagerly on construction: every UDP packet to the CSI port +/// is decoded via the napi-c shim ([`decode_nexmon_udp`]); packets that aren't +/// CSI (wrong port / not IPv4-UDP / bad nexmon magic) are counted as `rejected` +/// and skipped. Each surviving frame carries the pcap packet timestamp and +/// `validation = Pending`. +pub struct NexmonPcapAdapter { + source_id: SourceId, + session_id: SessionId, + profile: AdapterProfile, + frames: Vec, + headers: Vec, + link_type: u32, + cursor: usize, + skipped: u64, +} + +impl NexmonPcapAdapter { + /// Parse a libpcap byte buffer; `port` is the CSI UDP port to filter on + /// (`None` ⇒ [`NEXMON_DEFAULT_PORT`] = 5500). + pub fn parse( + source_id: impl Into, + session_id: SessionId, + pcap_bytes: &[u8], + port: Option, + ) -> Result { + debug_assert_eq!(shim_abi_version() >> 16, 1, "rvcsi_nexmon_shim major ABI mismatch"); + let source_id = source_id.into(); + let reader = PcapReader::parse(pcap_bytes)?; + let link_type = reader.link_type(); + let want_port = port.or(Some(NEXMON_DEFAULT_PORT)); + let mut frames = Vec::new(); + let mut headers = Vec::new(); + let mut skipped = 0u64; + let mut next_fid = 0u64; + for (ts_ns, _dst_port, payload) in reader.udp_payloads(want_port) { + match decode_nexmon_udp(payload, NEXMON_CSI_FMT_INT16_IQ) { + Ok((hdr, rec)) => { + let mut frame = CsiFrame::from_iq( + next_fid.into(), + session_id, + source_id.clone(), + AdapterKind::Nexmon, + ts_ns, + rec.channel, + rec.bandwidth_mhz, + rec.i_values, + rec.q_values, + ); + next_fid += 1; + frame.rssi_dbm = rec.rssi_dbm; + frame.noise_floor_dbm = rec.noise_floor_dbm; + frames.push(frame); + headers.push(hdr); + } + Err(_) => skipped += 1, + } + } + // Count non-CSI UDP packets on other ports as "skipped" too, for health. + if let Some(p) = want_port { + skipped += reader.udp_payloads(None).filter(|(_, dp, _)| *dp != p).count() as u64; + } + Ok(NexmonPcapAdapter { + source_id, + session_id, + profile: AdapterProfile::nexmon_default(), + frames, + headers, + link_type, + cursor: 0, + skipped, + }) + } + + /// Open and parse a `.pcap` file. + pub fn open( + source_id: impl Into, + session_id: SessionId, + path: impl AsRef, + port: Option, + ) -> Result { + let bytes = std::fs::read(path)?; + Self::parse(source_id, session_id, &bytes, port) + } + + /// Decode every CSI frame in a `.pcap` buffer in one shot (`Pending` frames). + pub fn frames_from_pcap_bytes( + source_id: impl Into, + session_id: SessionId, + pcap_bytes: &[u8], + port: Option, + ) -> Result, RvcsiError> { + Ok(Self::parse(source_id, session_id, pcap_bytes, port)?.frames) + } + + /// The capture's link-layer type. + pub fn link_type(&self) -> u32 { + self.link_type + } + + /// The parsed nexmon_csi UDP headers, one per decoded frame, in order. + pub fn headers(&self) -> &[NexmonCsiHeader] { + &self.headers + } + + /// Total CSI frames decoded from the capture. + pub fn frame_count(&self) -> usize { + self.frames.len() + } +} + +impl CsiSource for NexmonPcapAdapter { + fn profile(&self) -> &AdapterProfile { + &self.profile + } + + fn session_id(&self) -> SessionId { + self.session_id + } + + fn source_id(&self) -> &SourceId { + &self.source_id + } + + fn next_frame(&mut self) -> Result, RvcsiError> { + let frame = self.frames.get(self.cursor).cloned(); + if frame.is_some() { + self.cursor += 1; + } + Ok(frame) + } + + fn health(&self) -> SourceHealth { + SourceHealth { + connected: self.cursor < self.frames.len(), + frames_delivered: self.cursor as u64, + frames_rejected: self.skipped, + status: Some(format!( + "pcap link_type={}, {} CSI frame(s), {} non-CSI/skipped", + self.link_type, + self.frames.len(), + self.skipped + )), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -193,8 +378,10 @@ mod tests { } #[test] - fn abi_version_is_one_point_oh() { - assert_eq!(shim_abi_version(), 0x0001_0000); + fn abi_version_is_one_point_one() { + // 1.1 — minor bump when the nexmon_csi UDP/chanspec entry points landed. + assert_eq!(shim_abi_version(), 0x0001_0001); + assert_eq!(shim_abi_version() >> 16, 1, "major ABI must stay 1"); } #[test] @@ -275,4 +462,136 @@ mod tests { assert_eq!(frames.len(), 2); assert_eq!(frames[1].timestamp_ns, 20); } + + // ----- NexmonPcapAdapter (real nexmon_csi UDP inside a libpcap file) ----- + + /// Build a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q). + fn synth_nexmon_payload(rssi: i16, chanspec: u16, nsub: u16, seq: u16) -> Vec { + let hdr = NexmonCsiHeader { + rssi_dbm: rssi, + fctl: 0x08, + src_mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x02], + seq_cnt: seq, + core: 0, + spatial_stream: 0, + chanspec, + chip_ver: 0x0142, + channel: 0, + bandwidth_mhz: 0, + is_5ghz: false, + subcarrier_count: nsub, + }; + let i: Vec = (0..nsub).map(|k| (k as i16 - 32) as f32).collect(); + let q: Vec = (0..nsub).map(|k| (seq as i16 + k as i16) as f32).collect(); + encode_nexmon_udp(&hdr, &i, &q).expect("encode nexmon payload") + } + + /// Wrap `payload` in an Ethernet/IPv4/UDP frame to `dst_port`. + fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec { + let mut f = vec![ + 1, 2, 3, 4, 5, 6, // dst mac + 10, 11, 12, 13, 14, 15, // src mac + ]; + f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4 + let total = (20 + 8 + payload.len()) as u16; + f.extend_from_slice(&[0x45, 0x00]); + f.extend_from_slice(&total.to_be_bytes()); + f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum + f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip + f.extend_from_slice(&54321u16.to_be_bytes()); // src port + f.extend_from_slice(&dst_port.to_be_bytes()); // dst port + f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len + f.extend_from_slice(&[0, 0]); // udp cksum + f.extend_from_slice(payload); + f + } + + /// Build a classic LE/microsecond pcap from `(ts_sec, ts_usec, frame)` records. + fn pcap_le_us(link_type: u32, recs: &[(u32, u32, Vec)]) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&0xa1b2_c3d4u32.to_le_bytes()); + b.extend_from_slice(&[2, 0, 4, 0]); // ver major/minor + b.extend_from_slice(&0u32.to_le_bytes()); // thiszone + b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs + b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen + b.extend_from_slice(&link_type.to_le_bytes()); + for (s, us, f) in recs { + b.extend_from_slice(&s.to_le_bytes()); + b.extend_from_slice(&us.to_le_bytes()); + b.extend_from_slice(&(f.len() as u32).to_le_bytes()); + b.extend_from_slice(&(f.len() as u32).to_le_bytes()); + b.extend_from_slice(f); + } + b + } + + #[test] + fn pcap_adapter_decodes_real_nexmon_csi_packets() { + let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz + let nsub = 256u16; + let recs = vec![ + (1_000u32, 100_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))), + (1_000u32, 600_000u32, eth_ip_udp(9999, &[0xaa; 8])), // unrelated UDP + (1_001u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-61, chanspec, nsub, 2))), + (1_001u32, 50_000u32, eth_ip_udp(5500, &[0x42; 30])), // bad nexmon magic -> skipped + ]; + let pcap = pcap_le_us(LINKTYPE_ETHERNET, &recs); + + let mut adapter = NexmonPcapAdapter::parse("nexmon-pcap", SessionId(9), &pcap, None).unwrap(); + assert_eq!(adapter.link_type(), LINKTYPE_ETHERNET); + assert_eq!(adapter.frame_count(), 2); + assert_eq!(adapter.headers().len(), 2); + assert_eq!(adapter.headers()[0].chanspec, chanspec); + assert_eq!(adapter.headers()[0].channel, 36); + assert_eq!(adapter.headers()[0].bandwidth_mhz, 80); + assert!(adapter.headers()[0].is_5ghz); + assert_eq!(adapter.headers()[1].seq_cnt, 2); + + let mut frames = Vec::new(); + while let Some(f) = adapter.next_frame().unwrap() { + frames.push(f); + } + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].adapter_kind, AdapterKind::Nexmon); + assert_eq!(frames[0].channel, 36); + assert_eq!(frames[0].bandwidth_mhz, 80); + assert_eq!(frames[0].rssi_dbm, Some(-58)); + assert_eq!(frames[0].subcarrier_count, nsub); + // pcap timestamp -> frame timestamp (1000 s + 100000 us) + assert_eq!(frames[0].timestamp_ns, 1_000 * 1_000_000_000 + 100_000 * 1_000); + assert_eq!(frames[1].timestamp_ns, 1_001 * 1_000_000_000); + + let h = adapter.health(); + assert!(!h.connected); + assert_eq!(h.frames_delivered, 2); + assert!(h.frames_rejected >= 2); // the bad-magic one + the unrelated-port one + } + + #[test] + fn pcap_adapter_validates_decoded_frames() { + let pcap = pcap_le_us( + LINKTYPE_ETHERNET, + &[(1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-60, 0x1000 | 6, 64, 7)))], + ); + let frames = NexmonPcapAdapter::frames_from_pcap_bytes("p", SessionId(0), &pcap, Some(5500)).unwrap(); + assert_eq!(frames.len(), 1); + // 64 sc, channel 6 — accepted by a permissive (offline) profile + let mut f = frames[0].clone(); + validate_frame( + &mut f, + &AdapterProfile::offline(AdapterKind::Nexmon), + &ValidationPolicy::default(), + None, + ) + .unwrap(); + assert_eq!(f.validation, ValidationStatus::Accepted); + assert_eq!(f.channel, 6); + assert_eq!(f.bandwidth_mhz, 20); + } + + #[test] + fn pcap_adapter_rejects_garbage_pcap() { + assert!(NexmonPcapAdapter::parse("p", SessionId(0), &[0u8; 8], None).is_err()); + assert!(NexmonPcapAdapter::open("p", SessionId(0), "/no/such/file.pcap", None).is_err()); + } } diff --git a/v2/crates/rvcsi-adapter-nexmon/src/pcap.rs b/v2/crates/rvcsi-adapter-nexmon/src/pcap.rs new file mode 100644 index 00000000..a0555e0a --- /dev/null +++ b/v2/crates/rvcsi-adapter-nexmon/src/pcap.rs @@ -0,0 +1,381 @@ +//! Minimal, dependency-free reader for the classic libpcap (`.pcap`) file +//! format — enough to pull the UDP payloads out of a nexmon_csi capture +//! (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`). +//! +//! Supports the standard byte-order / timestamp-resolution magics +//! (`0xa1b2c3d4`, `0xd4c3b2a1`, and the nanosecond variants `0xa1b23c4d` / +//! `0x4d3cb2a1`) and the link-layer types that show up for nexmon CSI captures: +//! Ethernet (`1`), raw IPv4 (`101` / `228`), and Linux SLL (`113`). pcapng is a +//! documented follow-up. No `unsafe`, no allocation beyond owning the packet +//! bytes, and every read is bounds-checked. + +use rvcsi_core::RvcsiError; + +/// Classic-pcap magic (microsecond timestamps), as the 32-bit value. +pub const PCAP_MAGIC_US: u32 = 0xa1b2_c3d4; +/// Classic-pcap magic (nanosecond timestamps), as the 32-bit value. +pub const PCAP_MAGIC_NS: u32 = 0xa1b2_3c4d; + +/// Link-layer types we know how to peel down to an IPv4 packet. +pub const LINKTYPE_ETHERNET: u32 = 1; +/// Raw IPv4 (no link header). +pub const LINKTYPE_RAW: u32 = 101; +/// Linux "cooked" capture v1 (16-byte pseudo-header). +pub const LINKTYPE_LINUX_SLL: u32 = 113; +/// Raw IPv4 (the IANA-assigned value). +pub const LINKTYPE_IPV4: u32 = 228; + +/// The default UDP port nexmon_csi sends CSI frames to. +pub const NEXMON_DEFAULT_PORT: u16 = 5500; + +/// One captured packet: its timestamp (ns since the Unix epoch) and raw bytes +/// (starting at the link layer named by [`PcapReader::link_type`]). +#[derive(Debug, Clone)] +pub struct PcapPacket { + /// Capture timestamp, nanoseconds since the Unix epoch. + pub timestamp_ns: u64, + /// The packet bytes (truncated to the capture's snaplen, as on disk). + pub data: Vec, +} + +/// A parsed classic-pcap file. +#[derive(Debug, Clone)] +pub struct PcapReader { + link_type: u32, + packets: Vec, +} + +fn parse_err(offset: usize, msg: impl Into) -> RvcsiError { + RvcsiError::parse(offset, format!("pcap: {}", msg.into())) +} + +struct Endian(bool /* big-endian writer? */); +impl Endian { + fn u32(&self, b: &[u8]) -> u32 { + if self.0 { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) + } else { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } + } +} + +impl PcapReader { + /// Parse a classic-pcap byte buffer. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() < 24 { + return Err(parse_err(0, "buffer shorter than the 24-byte global header")); + } + // The 4 magic bytes on disk identify both byte order and ts resolution. + // 0xa1b2c3d4 written by a LE host -> [d4,c3,b2,a1]; by a BE host -> [a1,b2,c3,d4]. + // 0xa1b23c4d (nanosecond ts): LE -> [4d,3c,b2,a1]; BE -> [a1,b2,3c,4d]. + let m = [bytes[0], bytes[1], bytes[2], bytes[3]]; + let (endian, ts_is_ns) = match m { + [0xd4, 0xc3, 0xb2, 0xa1] => (Endian(false), false), + [0xa1, 0xb2, 0xc3, 0xd4] => (Endian(true), false), + [0x4d, 0x3c, 0xb2, 0xa1] => (Endian(false), true), + [0xa1, 0xb2, 0x3c, 0x4d] => (Endian(true), true), + _ => { + let raw = u32::from_le_bytes(m); + return Err(parse_err( + 0, + format!("unrecognised pcap magic 0x{raw:08x} (pcapng is not supported)"), + )); + } + }; + // bytes 4..6 version_major, 6..8 version_minor, 8..12 thiszone, + // 12..16 sigfigs, 16..20 snaplen, 20..24 network (link type) + let link_type = endian.u32(&bytes[20..24]); + + let mut packets = Vec::new(); + let mut off = 24usize; + while off + 16 <= bytes.len() { + let ts_sec = endian.u32(&bytes[off..off + 4]) as u64; + let ts_frac = endian.u32(&bytes[off + 4..off + 8]) as u64; + let incl_len = endian.u32(&bytes[off + 8..off + 12]) as usize; + // orig_len at off+12..off+16 is informational; ignored. + let data_start = off + 16; + if incl_len > bytes.len().saturating_sub(data_start) { + // Truncated final record — stop cleanly rather than erroring. + break; + } + let timestamp_ns = ts_sec + .saturating_mul(1_000_000_000) + .saturating_add(if ts_is_ns { ts_frac } else { ts_frac.saturating_mul(1_000) }); + packets.push(PcapPacket { + timestamp_ns, + data: bytes[data_start..data_start + incl_len].to_vec(), + }); + off = data_start + incl_len; + } + Ok(PcapReader { link_type, packets }) + } + + /// The capture's link-layer type (one of the `LINKTYPE_*` constants, or another value). + pub fn link_type(&self) -> u32 { + self.link_type + } + + /// All captured packets, in file order. + pub fn packets(&self) -> &[PcapPacket] { + &self.packets + } + + /// Iterate the UDP payloads in the capture whose destination port matches + /// `port` (or all UDP payloads if `port` is `None`), as `(timestamp_ns, + /// dst_port, payload)`. Non-IPv4 / non-UDP / non-matching packets are skipped. + pub fn udp_payloads( + &self, + port: Option, + ) -> impl Iterator + '_ { + let link_type = self.link_type; + self.packets.iter().filter_map(move |pkt| { + let (dst_port, payload) = extract_udp_payload(&pkt.data, link_type)?; + if let Some(p) = port { + if dst_port != p { + return None; + } + } + Some((pkt.timestamp_ns, dst_port, payload)) + }) + } +} + +/// Strip the link / network / transport headers from a captured frame with the +/// given link type and return `(udp_dst_port, udp_payload)`, or `None` if it +/// isn't an IPv4/UDP packet we can peel. +pub fn extract_udp_payload(frame: &[u8], link_type: u32) -> Option<(u16, &[u8])> { + let ip = match link_type { + LINKTYPE_ETHERNET => { + if frame.len() < 14 { + return None; + } + let ethertype = u16::from_be_bytes([frame[12], frame[13]]); + if ethertype != 0x0800 { + return None; // not IPv4 (ignore VLAN-tagged for now) + } + &frame[14..] + } + LINKTYPE_LINUX_SLL => { + if frame.len() < 16 { + return None; + } + let proto = u16::from_be_bytes([frame[14], frame[15]]); + if proto != 0x0800 { + return None; + } + &frame[16..] + } + LINKTYPE_RAW | LINKTYPE_IPV4 => frame, + _ => return None, + }; + + // IPv4 header + if ip.len() < 20 { + return None; + } + if (ip[0] >> 4) != 4 { + return None; // not IPv4 + } + let ihl = (ip[0] & 0x0f) as usize * 4; + if ihl < 20 || ip.len() < ihl { + return None; + } + if ip[9] != 17 { + return None; // not UDP + } + let udp = &ip[ihl..]; + if udp.len() < 8 { + return None; + } + let dst_port = u16::from_be_bytes([udp[2], udp[3]]); + let udp_len = u16::from_be_bytes([udp[4], udp[5]]) as usize; // includes the 8-byte UDP header + let payload_len = udp_len.saturating_sub(8).min(udp.len() - 8); + Some((dst_port, &udp[8..8 + payload_len])) +} + +/// Build a synthetic classic-pcap byte buffer — little-endian, microsecond +/// timestamps, [`LINKTYPE_ETHERNET`] — wrapping the given UDP payloads, one +/// Ethernet/IPv4/UDP packet each. Entries are `(timestamp_ns, dst_port, +/// payload)`. Intended for tests, examples and the `rvcsi` self-tests: real +/// captures come off a Raspberry Pi running patched firmware +/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`). +pub fn synthetic_udp_pcap(packets: &[(u64, u16, &[u8])]) -> Vec { + fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec { + let mut f = vec![ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, // dst mac + 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // src mac + ]; + f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4 + let total = (20 + 8 + payload.len()) as u16; + f.extend_from_slice(&[0x45, 0x00]); + f.extend_from_slice(&total.to_be_bytes()); + f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum + f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip + f.extend_from_slice(&54321u16.to_be_bytes()); // src port + f.extend_from_slice(&dst_port.to_be_bytes()); // dst port + f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len + f.extend_from_slice(&[0, 0]); // udp cksum + f.extend_from_slice(payload); + f + } + let mut b = Vec::new(); + b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes()); + b.extend_from_slice(&[2, 0, 4, 0]); // version major/minor + b.extend_from_slice(&0u32.to_le_bytes()); // thiszone + b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs + b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen + b.extend_from_slice(&LINKTYPE_ETHERNET.to_le_bytes()); + for (ts_ns, dst_port, payload) in packets { + let frame = eth_ip_udp(*dst_port, payload); + let ts_sec = (ts_ns / 1_000_000_000) as u32; + let ts_usec = ((ts_ns % 1_000_000_000) / 1_000) as u32; + b.extend_from_slice(&ts_sec.to_le_bytes()); + b.extend_from_slice(&ts_usec.to_le_bytes()); + b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len + b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len + b.extend_from_slice(&frame); + } + b +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a synthetic Ethernet/IPv4/UDP frame carrying `payload` to `dst_port`. + fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec { + let mut f = Vec::new(); + // Ethernet II: dst[6] src[6] ethertype[2] + f.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); + f.extend_from_slice(&[0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]); + f.extend_from_slice(&0x0800u16.to_be_bytes()); + // IPv4: 20-byte header + let total_len = (20 + 8 + payload.len()) as u16; + let mut ip = vec![ + 0x45, 0x00, // version/IHL, DSCP/ECN + ]; + ip.extend_from_slice(&total_len.to_be_bytes()); + ip.extend_from_slice(&[0, 0, 0, 0, 64, 17]); // id, flags/frag, ttl, proto=UDP + ip.extend_from_slice(&[0, 0]); // header checksum (not checked here) + ip.extend_from_slice(&[10, 0, 0, 1]); // src ip + ip.extend_from_slice(&[10, 0, 0, 20]); // dst ip + assert_eq!(ip.len(), 20); + f.extend_from_slice(&ip); + // UDP: src_port[2] dst_port[2] length[2] checksum[2] + f.extend_from_slice(&54321u16.to_be_bytes()); + f.extend_from_slice(&dst_port.to_be_bytes()); + f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); + f.extend_from_slice(&[0, 0]); // checksum + f.extend_from_slice(payload); + f + } + + /// Build a minimal classic-pcap file (LE, microsecond) wrapping the frames. + fn pcap_le_us(link_type: u32, frames: &[(u32, u32, Vec)]) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); // version major + b.extend_from_slice(&4u16.to_le_bytes()); // version minor + b.extend_from_slice(&0i32.to_le_bytes()); // thiszone + b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs + b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen + b.extend_from_slice(&link_type.to_le_bytes()); + for (ts_sec, ts_usec, frame) in frames { + b.extend_from_slice(&ts_sec.to_le_bytes()); + b.extend_from_slice(&ts_usec.to_le_bytes()); + b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len + b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len + b.extend_from_slice(frame); + } + b + } + + #[test] + fn parses_global_header_and_iterates_udp_payloads() { + let p1 = vec![0xaa; 30]; + let p2 = vec![0xbb; 12]; + let other = vec![0xcc; 8]; + let frames = vec![ + (100u32, 250_000u32, eth_ip_udp(5500, &p1)), + (101u32, 500_000u32, eth_ip_udp(9999, &other)), // different port + (102u32, 0u32, eth_ip_udp(5500, &p2)), + ]; + let file = pcap_le_us(LINKTYPE_ETHERNET, &frames); + let r = PcapReader::parse(&file).unwrap(); + assert_eq!(r.link_type(), LINKTYPE_ETHERNET); + assert_eq!(r.packets().len(), 3); + + let csi: Vec<_> = r.udp_payloads(Some(5500)).collect(); + assert_eq!(csi.len(), 2); + assert_eq!(csi[0].0, 100 * 1_000_000_000 + 250_000 * 1_000); // ts_ns + assert_eq!(csi[0].1, 5500); + assert_eq!(csi[0].2, &p1[..]); + assert_eq!(csi[1].2, &p2[..]); + + // no filter -> all 3 UDP payloads + assert_eq!(r.udp_payloads(None).count(), 3); + } + + #[test] + fn handles_raw_ipv4_linktype() { + // raw IPv4 frame = the IPv4 packet directly (no Ethernet header) + let payload = vec![0x11; 20]; + let eth = eth_ip_udp(5500, &payload); + let raw_ip = eth[14..].to_vec(); // strip the 14-byte Ethernet header + let file = pcap_le_us(LINKTYPE_RAW, &[(5u32, 0u32, raw_ip)]); + let r = PcapReader::parse(&file).unwrap(); + let v: Vec<_> = r.udp_payloads(Some(5500)).collect(); + assert_eq!(v.len(), 1); + assert_eq!(v[0].2, &payload[..]); + } + + #[test] + fn nanosecond_magic_scales_timestamps_correctly() { + let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(7u32, 123u32, eth_ip_udp(5500, &[0u8; 8]))]); + // patch the magic to the nanosecond variant + file[0..4].copy_from_slice(&PCAP_MAGIC_NS.to_le_bytes()); + let r = PcapReader::parse(&file).unwrap(); + let v: Vec<_> = r.udp_payloads(Some(5500)).collect(); + assert_eq!(v[0].0, 7 * 1_000_000_000 + 123); // ts_frac taken as ns, not us + } + + #[test] + fn rejects_garbage_and_pcapng() { + assert!(PcapReader::parse(&[0u8; 10]).is_err()); // too short + assert!(PcapReader::parse(&[0u8; 24]).is_err()); // zero magic + // pcapng section-header-block magic (0x0a0d0d0a) — not supported + let mut ng = vec![0x0a, 0x0d, 0x0d, 0x0a]; + ng.extend_from_slice(&[0u8; 24]); + assert!(PcapReader::parse(&ng).is_err()); + } + + #[test] + fn truncated_final_record_is_tolerated() { + let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(1u32, 0u32, eth_ip_udp(5500, &[0u8; 16]))]); + // append a partial record header + claim a huge incl_len + file.extend_from_slice(&2u32.to_le_bytes()); + file.extend_from_slice(&0u32.to_le_bytes()); + file.extend_from_slice(&9999u32.to_le_bytes()); // incl_len > remaining + file.extend_from_slice(&9999u32.to_le_bytes()); + file.extend_from_slice(&[0xde, 0xad]); // only 2 bytes of "data" + let r = PcapReader::parse(&file).unwrap(); + assert_eq!(r.packets().len(), 1); // the complete one only + } + + #[test] + fn extract_udp_payload_rejects_non_udp() { + // build an Ethernet/IPv4 frame but with proto = TCP (6) + let mut eth = eth_ip_udp(5500, &[0u8; 8]); + // IPv4 proto byte is at Ethernet(14) + 9 = 23 + eth[14 + 9] = 6; // TCP + assert!(extract_udp_payload(ð, LINKTYPE_ETHERNET).is_none()); + // wrong ethertype + let mut eth = eth_ip_udp(5500, &[0u8; 8]); + eth[12] = 0x86; + eth[13] = 0xdd; // IPv6 + assert!(extract_udp_payload(ð, LINKTYPE_ETHERNET).is_none()); + // unknown link type + assert!(extract_udp_payload(ð, 9999).is_none()); + } +} diff --git a/v2/crates/rvcsi-cli/src/commands.rs b/v2/crates/rvcsi-cli/src/commands.rs index afcad905..9673292b 100644 --- a/v2/crates/rvcsi-cli/src/commands.rs +++ b/v2/crates/rvcsi-cli/src/commands.rs @@ -58,6 +58,102 @@ pub fn record_from_nexmon( Ok(()) } +/// `rvcsi record --source nexmon-pcap --in --out ` — +/// 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). +pub fn record_from_nexmon_pcap( + out: &mut dyn Write, + pcap_path: &str, + out_path: &str, + source_id: &str, + session_id: u64, + port: Option, +) -> 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) + .with_context(|| format!("parsing nexmon pcap {pcap_path}"))?; + let header = CaptureHeader::new( + SessionId(session_id), + SourceId::from(source_id), + AdapterProfile::nexmon_default(), + ); + 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())?; + Ok(()) +} + +/// `rvcsi inspect-nexmon ` — summarize a nexmon_csi `.pcap` (link +/// type, CSI frame count, channels, bandwidths, chip versions, RSSI range, +/// time span). `port` is the CSI UDP port (`None` ⇒ 5500). +pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option, json: bool) -> Result<()> { + let s = runtime::summarize_nexmon_pcap(pcap_path, port).with_context(|| format!("inspecting {pcap_path}"))?; + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&s)?)?; + return Ok(()); + } + writeln!(out, "nexmon pcap : {pcap_path}")?; + writeln!(out, " link type : {}", s.link_type)?; + writeln!(out, " CSI frames : {}", s.csi_frame_count)?; + writeln!(out, " skipped pkts : {}", s.skipped_packets)?; + writeln!( + out, + " time span : {} .. {} ns ({} ns)", + s.first_timestamp_ns, + s.last_timestamp_ns, + s.last_timestamp_ns.saturating_sub(s.first_timestamp_ns) + )?; + writeln!(out, " channels : {:?}", s.channels)?; + writeln!(out, " bandwidths : {:?} MHz", s.bandwidths_mhz)?; + writeln!(out, " subcarriers : {:?}", s.subcarrier_counts)?; + writeln!( + out, + " chip versions: {}", + s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::>().join(", ") + )?; + match s.rssi_dbm_range { + Some((lo, hi)) => writeln!(out, " rssi range : {lo} .. {hi} dBm")?, + None => writeln!(out, " rssi range : (none)")?, + } + Ok(()) +} + +/// `rvcsi decode-chanspec ` — decode a Broadcom d11ac chanspec word +/// to `{channel, bandwidth_mhz, is_5ghz}` (JSON, or a human line). +pub fn decode_chanspec_cmd(out: &mut dyn Write, chanspec_str: &str, json: bool) -> Result<()> { + let s = chanspec_str.trim(); + let value: u32 = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + u32::from_str_radix(hex, 16).with_context(|| format!("not a hex u16: {s}"))? + } else { + s.parse::().with_context(|| format!("not a decimal u16: {s}"))? + }; + let d = rvcsi_adapter_nexmon::decode_chanspec((value & 0xFFFF) as u16); + if json { + writeln!( + out, + "{}", + serde_json::to_string(&serde_json::json!({ + "chanspec": d.chanspec, "channel": d.channel, + "bandwidth_mhz": d.bandwidth_mhz, "is_5ghz": d.is_5ghz + }))? + )?; + } else { + writeln!( + out, + "chanspec 0x{:04x}: channel {} @ {} MHz ({})", + d.chanspec, + d.channel, + d.bandwidth_mhz, + if d.is_5ghz { "5 GHz" } else { "2.4 GHz" } + )?; + } + Ok(()) +} + /// `rvcsi inspect ` — print a summary of a `.rvcsi` capture file. pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> { let summary = runtime::summarize_capture(path).with_context(|| format!("inspecting {path}"))?; @@ -395,6 +491,80 @@ mod tests { assert!(replayed.contains("-- 6 frame(s)")); } + #[test] + fn nexmon_pcap_record_and_inspect_roundtrip() { + use rvcsi_adapter_nexmon::NexmonCsiHeader; + let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz + let nsub = 256u16; + let frames: Vec<(u64, NexmonCsiHeader, Vec, Vec)> = (0..8u64) + .map(|k| { + let i: Vec = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect(); + let q: Vec = (0..nsub).map(|s| (s as i16 % 5 + k as i16) as f32).collect(); + ( + 1_000_000_000 + k * 50_000_000, + NexmonCsiHeader { + rssi_dbm: -55 - k as i16, + fctl: 8, + src_mac: [0, 1, 2, 3, 4, 5], + seq_cnt: k as u16, + core: 0, + spatial_stream: 0, + chanspec, + chip_ver: 0x0142, + channel: 0, + bandwidth_mhz: 0, + is_5ghz: false, + subcarrier_count: nsub, + }, + i, + q, + ) + }) + .collect(); + let pcap_bytes = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap(); + let pcap_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(pcap_file.path(), &pcap_bytes).unwrap(); + let pcap_path = pcap_file.path().to_str().unwrap(); + + // inspect-nexmon (human + json) + 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")); + 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); + + // record --source nexmon-pcap -> .rvcsi, then the normal commands work on it + 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 summary = run(|o| inspect(o, cap_path, false)); + assert!(summary.contains("frames : 8")); + assert!(summary.contains("source : nx-pcap")); + assert!(summary.contains("channels : [36]")); + } + + #[test] + fn decode_chanspec_command() { + let out = run(|o| decode_chanspec_cmd(o, "0xe024", false)); // 5G | BW80(0x2000) | ch36 ... 0xe024 = 0xc000|0x2000|0x24 + assert!(out.contains("channel 36"), "{out}"); + assert!(out.contains("80 MHz")); + assert!(out.contains("5 GHz")); + let out = run(|o| decode_chanspec_cmd(o, "4102", false)); // 0x1006 = BW20(0x1000)|ch6 + assert!(out.contains("channel 6")); + assert!(out.contains("2.4 GHz")); + let j = run(|o| decode_chanspec_cmd(o, "0x1006", true)); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + assert_eq!(v["channel"], 6); + // bad input errors cleanly + let mut buf = Vec::new(); + assert!(decode_chanspec_cmd(&mut buf, "0xZZZZ", false).is_err()); + assert!(decode_chanspec_cmd(&mut buf, "not-a-number", false).is_err()); + } + #[test] fn errors_on_missing_capture() { let mut buf = Vec::new(); @@ -403,5 +573,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!(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 de8ecd69..c5ea4b9c 100644 --- a/v2/crates/rvcsi-cli/src/main.rs +++ b/v2/crates/rvcsi-cli/src/main.rs @@ -20,9 +20,14 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Transcode a Nexmon record dump into a `.rvcsi` capture (validating each frame). + /// Transcode a Nexmon source into a `.rvcsi` capture (validating each frame). Record { - /// Path to a buffer of "rvCSI Nexmon records" (the napi-c shim format). + /// Input format: `nexmon` (a buffer of "rvCSI Nexmon records", the napi-c + /// shim format) or `nexmon-pcap` (a real nexmon_csi libpcap capture, + /// `tcpdump -i wlan0 dst port 5500 -w csi.pcap`). + #[arg(long, default_value = "nexmon")] + source: String, + /// Path to the input (`.bin` of records, or a `.pcap`). #[arg(long = "in")] input: String, /// Path to write the `.rvcsi` capture file. @@ -34,6 +39,28 @@ enum Command { /// Session id for the capture. #[arg(long, default_value_t = 0)] session: u64, + /// CSI UDP port (for `--source nexmon-pcap`; defaults to 5500). + #[arg(long)] + port: Option, + }, + /// Summarize a nexmon_csi `.pcap` file (link type, CSI frames, channels, ...). + InspectNexmon { + /// Path to a nexmon_csi `.pcap` capture. + path: String, + /// CSI UDP port (defaults to 5500). + #[arg(long)] + port: Option, + /// Emit machine-readable JSON instead of a human summary. + #[arg(long)] + json: bool, + }, + /// Decode a Broadcom d11ac chanspec word (hex `0x…` or decimal). + DecodeChanspec { + /// The chanspec value, e.g. `0xe024` or `57380`. + chanspec: String, + /// Emit JSON instead of a human line. + #[arg(long)] + json: bool, }, /// Summarize a `.rvcsi` capture file (frame count, channels, quality, ...). Inspect { @@ -126,9 +153,15 @@ fn main() -> anyhow::Result<()> { let stdout = io::stdout(); let mut out = stdout.lock(); match cli.command { - Command::Record { input, output, source_id, session } => { - commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)? - } + Command::Record { source, input, output, source_id, session, port } => 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)? + } + other => anyhow::bail!("unknown --source `{other}` (expected `nexmon` or `nexmon-pcap`)"), + }, + 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)?, Command::Replay { path, json, limit, speed } => { if (speed - 1.0).abs() > f32::EPSILON { diff --git a/v2/crates/rvcsi-node/Cargo.toml b/v2/crates/rvcsi-node/Cargo.toml index 3855be89..78d0fb94 100644 --- a/v2/crates/rvcsi-node/Cargo.toml +++ b/v2/crates/rvcsi-node/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"] napi = { workspace = true } napi-derive = { workspace = true } rvcsi-core = { path = "../rvcsi-core" } +rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" } rvcsi-runtime = { path = "../rvcsi-runtime" } serde = { workspace = true } serde_json = { workspace = true } diff --git a/v2/crates/rvcsi-node/__test__/api.test.cjs b/v2/crates/rvcsi-node/__test__/api.test.cjs index 6a5bba8c..d9599ec7 100644 --- a/v2/crates/rvcsi-node/__test__/api.test.cjs +++ b/v2/crates/rvcsi-node/__test__/api.test.cjs @@ -18,6 +18,9 @@ test('exports the expected functions and class', () => { 'rvcsiVersion', 'nexmonShimAbiVersion', 'nexmonDecodeRecords', + 'nexmonDecodePcap', + 'inspectNexmonPcap', + 'decodeChanspec', 'inspectCaptureFile', 'eventsFromCaptureFile', 'exportCaptureToRfMemory', @@ -27,6 +30,7 @@ test('exports the expected functions and class', () => { assert.equal(typeof rvcsi.RvCsi, 'function', 'RvCsi should be a class'); assert.equal(typeof rvcsi.RvCsi.openCaptureFile, 'function'); assert.equal(typeof rvcsi.RvCsi.openNexmonFile, 'function'); + assert.equal(typeof rvcsi.RvCsi.openNexmonPcap, 'function'); }); test('native calls either work (addon built) or fail with a helpful message', () => { diff --git a/v2/crates/rvcsi-node/index.d.ts b/v2/crates/rvcsi-node/index.d.ts index 4669c679..78392ed4 100644 --- a/v2/crates/rvcsi-node/index.d.ts +++ b/v2/crates/rvcsi-node/index.d.ts @@ -124,6 +124,35 @@ export interface CaptureSummary { calibration_version: string | null; } +/** Compact summary of a nexmon_csi `.pcap` capture. */ +export interface NexmonPcapSummary { + /** libpcap link-layer type (1 = Ethernet, 101/228 = raw IPv4, 113 = Linux SLL, ...). */ + link_type: number; + csi_frame_count: number; + /** Non-CSI / skipped UDP packets (wrong port, not IPv4/UDP, bad nexmon magic). */ + skipped_packets: number; + first_timestamp_ns: number; + last_timestamp_ns: number; + channels: number[]; + bandwidths_mhz: number[]; + subcarrier_counts: number[]; + /** Distinct chip-version words (e.g. 0x0142 = BCM43455c0). */ + chip_versions: number[]; + /** `[min, max]` RSSI in dBm, or `null` for an empty capture. */ + rssi_dbm_range: [number, number] | null; +} + +/** A decoded Broadcom d11ac chanspec word. */ +export interface DecodedChanspec { + /** The raw 16-bit chanspec value. */ + chanspec: number; + /** `chanspec & 0xff`. */ + channel: number; + /** 20 / 40 / 80 / 160, or 0 if the bandwidth bits are unrecognised. */ + bandwidth_mhz: number; + is_5ghz: boolean; +} + /** rvCSI runtime version string. */ export function rvcsiVersion(): string; @@ -149,6 +178,23 @@ export function eventsFromCaptureFile(path: string): CsiEvent[]; /** Window a capture and store each window's embedding into a JSONL RF-memory file; returns the count. */ export function exportCaptureToRfMemory(capturePath: string, outJsonlPath: string): number; +/** + * 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. + */ +export function nexmonDecodePcap( + pcap: Buffer | Uint8Array, + sourceId: string, + sessionId: number, + port?: number, +): CsiFrame[]; + +/** Summarize a nexmon_csi `.pcap` file. `port` defaults to 5500. */ +export function inspectNexmonPcap(path: string, port?: number): NexmonPcapSummary; + +/** Decode a Broadcom d11ac chanspec word. */ +export function decodeChanspec(chanspec: number): DecodedChanspec; + /** Streaming capture runtime: a source + the DSP stage + the event pipeline. */ export class RvCsi { private constructor(rt: unknown); @@ -156,6 +202,8 @@ export class RvCsi { static openCaptureFile(path: string): RvCsi; /** Open a Nexmon capture file (concatenated rvCSI Nexmon records). */ static openNexmonFile(path: string, sourceId: string, sessionId: number): RvCsi; + /** Open a real nexmon_csi `.pcap` capture. `port` defaults to 5500. */ + static openNexmonPcap(path: string, sourceId: string, sessionId: number, port?: number): RvCsi; /** Next exposable, validated frame, or `null` at end-of-stream. */ nextFrame(): CsiFrame | null; /** Like {@link RvCsi.nextFrame} but with the DSP pipeline applied. */ diff --git a/v2/crates/rvcsi-node/index.js b/v2/crates/rvcsi-node/index.js index 50e52928..c29aafb5 100644 --- a/v2/crates/rvcsi-node/index.js +++ b/v2/crates/rvcsi-node/index.js @@ -91,6 +91,41 @@ function exportCaptureToRfMemory(capturePath, outJsonlPath) { return binding().exportCaptureToRfMemory(String(capturePath), String(outJsonlPath)); } +/** + * Decode the *real* nexmon_csi UDP payloads inside a libpcap `.pcap` buffer + * (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into validated CsiFrame objects. + * @param {Buffer|Uint8Array} pcap + * @param {string} sourceId + * @param {number} sessionId + * @param {number} [port] CSI UDP port (default 5500) + * @returns {import('./index').CsiFrame[]} + */ +function nexmonDecodePcap(pcap, sourceId, sessionId, port) { + return JSON.parse( + binding().nexmonDecodePcap(pcap, String(sourceId), u32(sessionId), port == null ? undefined : Number(port)), + ); +} + +/** + * Summarize a nexmon_csi `.pcap` file (link type, CSI frame count, channels, + * bandwidths, chip versions, RSSI range, time span). + * @param {string} path + * @param {number} [port] CSI UDP port (default 5500) + * @returns {import('./index').NexmonPcapSummary} + */ +function inspectNexmonPcap(path, port) { + return JSON.parse(binding().inspectNexmonPcap(String(path), port == null ? undefined : Number(port))); +} + +/** + * Decode a Broadcom d11ac chanspec word. + * @param {number} chanspec + * @returns {import('./index').DecodedChanspec} + */ +function decodeChanspec(chanspec) { + return JSON.parse(binding().decodeChanspec(u32(chanspec))); +} + /** Streaming capture runtime: a source + the DSP stage + the event pipeline. */ class RvCsi { /** @param {*} rt the underlying napi RvcsiRuntime handle */ @@ -112,6 +147,22 @@ class RvCsi { return new RvCsi(binding().RvcsiRuntime.openNexmonFile(String(path), String(sourceId), u32(sessionId))); } + /** + * Open a real nexmon_csi `.pcap` capture. + * @param {string} path @param {string} sourceId @param {number} sessionId + * @param {number} [port] CSI UDP port (default 5500) @returns {RvCsi} + */ + static openNexmonPcap(path, sourceId, sessionId, port) { + return new RvCsi( + binding().RvcsiRuntime.openNexmonPcap( + String(path), + String(sourceId), + u32(sessionId), + port == null ? undefined : Number(port), + ), + ); + } + /** Next exposable, validated frame, or `null` at end-of-stream. @returns {import('./index').CsiFrame|null} */ nextFrame() { const s = this._rt.nextFrameJson(); @@ -149,6 +200,9 @@ module.exports = { rvcsiVersion, nexmonShimAbiVersion, nexmonDecodeRecords, + nexmonDecodePcap, + inspectNexmonPcap, + decodeChanspec, inspectCaptureFile, eventsFromCaptureFile, exportCaptureToRfMemory, diff --git a/v2/crates/rvcsi-node/src/lib.rs b/v2/crates/rvcsi-node/src/lib.rs index 01f24385..cbd7944a 100644 --- a/v2/crates/rvcsi-node/src/lib.rs +++ b/v2/crates/rvcsi-node/src/lib.rs @@ -93,6 +93,43 @@ pub fn export_capture_to_rf_memory(capture_path: String, out_jsonl_path: String) Ok(n as u32) } +/// 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. +#[napi] +pub fn nexmon_decode_pcap( + pcap: Buffer, + source_id: String, + session_id: u32, + port: Option, +) -> napi::Result { + let frames = runtime::decode_nexmon_pcap(pcap.as_ref(), &source_id, session_id as u64, port) + .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. +#[napi] +pub fn inspect_nexmon_pcap(path: String, port: Option) -> napi::Result { + let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?; + to_json(&summary) +} + +/// Decode a Broadcom d11ac chanspec word; returns JSON +/// `{ chanspec, channel, bandwidth_mhz, is_5ghz }`. +#[napi] +pub fn decode_chanspec(chanspec: u32) -> napi::Result { + let d = rvcsi_adapter_nexmon::decode_chanspec((chanspec & 0xFFFF) as u16); + to_json(&serde_json::json!({ + "chanspec": d.chanspec, + "channel": d.channel, + "bandwidth_mhz": d.bandwidth_mhz, + "is_5ghz": d.is_5ghz, + })) +} + // --------------------------------------------------------------------------- // Streaming runtime class // --------------------------------------------------------------------------- @@ -121,6 +158,21 @@ impl RvcsiRuntime { }) } + /// Open a real nexmon_csi `.pcap` capture as the source. `port` is the CSI + /// UDP port (omit / `null` ⇒ 5500). + #[napi(factory)] + pub fn open_nexmon_pcap( + path: String, + source_id: String, + session_id: u32, + port: Option, + ) -> napi::Result { + Ok(RvcsiRuntime { + inner: CaptureRuntime::open_nexmon_pcap(&path, &source_id, session_id as u64, port) + .map_err(napi_err)?, + }) + } + /// Next exposable, validated frame as JSON, or `null` at end-of-stream. #[napi] pub fn next_frame_json(&mut self) -> napi::Result> { diff --git a/v2/crates/rvcsi-runtime/src/capture.rs b/v2/crates/rvcsi-runtime/src/capture.rs index 1820aa24..e799a8b0 100644 --- a/v2/crates/rvcsi-runtime/src/capture.rs +++ b/v2/crates/rvcsi-runtime/src/capture.rs @@ -65,6 +65,39 @@ impl CaptureRuntime { Ok(Self::open_nexmon_bytes(bytes, source_id, session_id)) } + /// Open a real nexmon_csi `.pcap` capture (`tcpdump -i wlan0 dst port 5500 -w …`) + /// as the source. `port` is the CSI UDP port (`None` ⇒ 5500). + pub fn open_nexmon_pcap( + path: &str, + source_id: &str, + session_id: u64, + port: Option, + ) -> Result { + let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::open( + SourceId::from(source_id), + SessionId(session_id), + path, + port, + )?; + Ok(Self::new(Box::new(source), ValidationPolicy::default())) + } + + /// Open a real nexmon_csi `.pcap` from an in-memory byte buffer. + pub fn open_nexmon_pcap_bytes( + pcap_bytes: &[u8], + source_id: &str, + session_id: u64, + port: Option, + ) -> Result { + let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse( + SourceId::from(source_id), + SessionId(session_id), + pcap_bytes, + port, + )?; + Ok(Self::new(Box::new(source), ValidationPolicy::default())) + } + /// Validate (if needed) a freshly pulled frame; `None` if it was hard-rejected. fn admit(&mut self, mut frame: CsiFrame) -> Option { self.frames_seen += 1; @@ -257,9 +290,61 @@ mod tests { assert_eq!(n, 40); } + #[test] + fn runs_a_real_nexmon_csi_pcap() { + use rvcsi_adapter_nexmon::NexmonCsiHeader; + let chanspec = 0x1000u16 | 6; // 2.4 GHz ch6 20 MHz + let nsub = 64u16; + let frames: Vec<(u64, NexmonCsiHeader, Vec, Vec)> = (0..12u64) + .map(|k| { + let i: Vec = (0..nsub).map(|s| (s as i16 - 32 + k as i16) as f32).collect(); + let q: Vec = (0..nsub).map(|_| 1.0f32).collect(); + ( + 1_000_000_000 + k * 50_000_000, + NexmonCsiHeader { + rssi_dbm: -55 - k as i16, + fctl: 8, + src_mac: [0, 1, 2, 3, 4, 5], + seq_cnt: k as u16, + core: 0, + spatial_stream: 0, + chanspec, + chip_ver: 0x0142, + channel: 0, + bandwidth_mhz: 0, + is_5ghz: false, + subcarrier_count: nsub, + }, + i, + q, + ) + }) + .collect(); + let pcap = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap(); + let mut rt = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "nexmon-pcap-rt", 1, None).unwrap(); + let mut got = 0; + while let Some(f) = rt.next_validated_frame().unwrap() { + assert_eq!(f.adapter_kind, AdapterKind::Nexmon); + assert_eq!(f.channel, 6); + assert_eq!(f.bandwidth_mhz, 20); + assert!(f.is_exposable()); + got += 1; + } + assert_eq!(got, 12); + let events = { + let mut rt2 = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "n", 2, None).unwrap(); + rt2.drain_events().unwrap() + }; + for e in &events { + e.validate().unwrap(); + } + } + #[test] fn missing_file_is_an_error() { assert!(CaptureRuntime::open_capture_file("/nope/x.rvcsi").is_err()); assert!(CaptureRuntime::open_nexmon_file("/nope/x.bin", "s", 0).is_err()); + assert!(CaptureRuntime::open_nexmon_pcap("/nope/x.pcap", "s", 0, None).is_err()); + assert!(CaptureRuntime::open_nexmon_pcap_bytes(&[0u8; 8], "s", 0, None).is_err()); } } diff --git a/v2/crates/rvcsi-runtime/src/lib.rs b/v2/crates/rvcsi-runtime/src/lib.rs index 8af58ec2..08108ae6 100644 --- a/v2/crates/rvcsi-runtime/src/lib.rs +++ b/v2/crates/rvcsi-runtime/src/lib.rs @@ -21,8 +21,9 @@ pub mod summary; pub use capture::CaptureRuntime; pub use summary::{ - decode_nexmon_records, events_from_capture, export_capture_to_rf_memory, rf_memory_self_check, - summarize_capture, CaptureSummary, ValidationBreakdown, + 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, }; /// 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 e9bf5d46..bbc5f720 100644 --- a/v2/crates/rvcsi-runtime/src/summary.rs +++ b/v2/crates/rvcsi-runtime/src/summary.rs @@ -119,33 +119,139 @@ pub fn summarize_capture(path: &str) -> Result { }) } -/// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into -/// validated [`CsiFrame`]s. Each frame is run through [`validate_frame`] against -/// a permissive profile (so synthetic / non-default subcarrier counts survive); -/// frames that hard-fail validation are dropped (never returned to JS). -pub fn decode_nexmon_records( - bytes: &[u8], - source_id: &str, - session_id: u64, -) -> Result, RvcsiError> { - let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?; +/// 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); 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; - match validate_frame(&mut f, &profile, &policy, prev_ts) { - Ok(()) => { - if f.is_exposable() { + if f.validation == ValidationStatus::Pending { + match validate_frame(&mut f, &profile, &policy, prev_ts) { + Ok(()) if f.is_exposable() => { prev_ts = Some(ts); out.push(f); } + _ => { /* hard-rejected — dropped */ } } - Err(_) => { /* hard-rejected — dropped, not returned to JS */ } + } else if f.is_exposable() { + out.push(f); } } - Ok(out) + out +} + +/// 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). +pub fn decode_nexmon_records( + bytes: &[u8], + source_id: &str, + session_id: u64, +) -> Result, RvcsiError> { + let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?; + Ok(validate_frames_permissive(raw)) +} + +/// Decode the *real* nexmon_csi UDP payloads inside a libpcap (`.pcap`) buffer +/// into validated [`CsiFrame`]s. `port` is the CSI UDP port (`None` ⇒ 5500). +pub fn decode_nexmon_pcap( + pcap_bytes: &[u8], + source_id: &str, + session_id: u64, + port: Option, +) -> Result, RvcsiError> { + let raw = rvcsi_adapter_nexmon::NexmonPcapAdapter::frames_from_pcap_bytes( + SourceId::from(source_id), + SessionId(session_id), + pcap_bytes, + port, + )?; + Ok(validate_frames_permissive(raw)) +} + +/// A compact summary of a nexmon_csi `.pcap` capture (the `rvcsi inspect-nexmon` +/// payload). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NexmonPcapSummary { + /// libpcap link-layer type of the capture. + pub link_type: u32, + /// CSI frames decoded from the capture. + pub csi_frame_count: usize, + /// Non-CSI / skipped UDP packets (wrong port, not IPv4/UDP, bad nexmon magic). + pub skipped_packets: u64, + /// First / last CSI packet timestamp (ns since the Unix epoch); `0` if empty. + pub first_timestamp_ns: u64, + /// Last CSI packet timestamp (ns). + pub last_timestamp_ns: u64, + /// Distinct WiFi channels seen (decoded from the chanspec). + pub channels: Vec, + /// Distinct bandwidths (MHz) seen. + pub bandwidths_mhz: Vec, + /// Distinct subcarrier (FFT) counts seen. + pub subcarrier_counts: Vec, + /// Distinct chip-version words seen (e.g. `0x0142` = BCM43455c0). + pub chip_versions: Vec, + /// Min / max RSSI (dBm) over the CSI packets; `None` if empty. + pub rssi_dbm_range: Option<(i16, i16)>, +} + +/// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, etc.). +pub fn summarize_nexmon_pcap(path: &str, port: Option) -> Result { + let bytes = std::fs::read(path)?; + let adapter = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse( + SourceId::from(format!("pcap:{path}")), + SessionId(0), + &bytes, + port, + )?; + let health = adapter.health(); + 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 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); + rssi_lo = rssi_lo.min(h.rssi_dbm); + rssi_hi = rssi_hi.max(h.rssi_dbm); + } + 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( + SourceId::from("pcap-ts"), + SessionId(0), + &bytes, + port, + )?; + use rvcsi_core::CsiSource; + while let Some(f) = a2.next_frame()? { + first_ts = first_ts.min(f.timestamp_ns); + last_ts = last_ts.max(f.timestamp_ns); + } + if headers.is_empty() { + first_ts = 0; + } + Ok(NexmonPcapSummary { + link_type: adapter.link_type(), + csi_frame_count: headers.len(), + skipped_packets: health.frames_rejected, + first_timestamp_ns: first_ts, + last_timestamp_ns: last_ts, + channels: sorted_unique(channels), + bandwidths_mhz: sorted_unique(bandwidths), + subcarrier_counts: sorted_unique(subs), + chip_versions: sorted_unique(chips), + rssi_dbm_range: (!headers.is_empty()).then_some((rssi_lo, rssi_hi)), + }) } /// Replay a `.rvcsi` capture through the DSP + event pipeline and collect every @@ -227,7 +333,7 @@ pub fn rf_memory_self_check(capture_path: &str) -> Result<(usize, f32), RvcsiErr mod tests { use super::*; use rvcsi_adapter_file::FileRecorder; - use rvcsi_adapter_nexmon::{encode_record, NexmonRecord}; + use rvcsi_adapter_nexmon::{encode_record, NexmonCsiHeader, NexmonRecord}; use rvcsi_core::{AdapterKind, FrameId}; fn write_capture(path: &std::path::Path, n: usize) { @@ -350,5 +456,73 @@ mod tests { fn missing_capture_file_is_a_structured_error() { assert!(summarize_capture("/nonexistent/path/x.rvcsi").is_err()); assert!(events_from_capture("/nonexistent/path/x.rvcsi").is_err()); + assert!(decode_nexmon_pcap(&[0u8; 8], "s", 0, None).is_err()); + assert!(summarize_nexmon_pcap("/nonexistent/path/x.pcap", None).is_err()); + } + + fn synth_nexmon_header(rssi: i16, chanspec: u16, nsub: u16, seq: u16) -> NexmonCsiHeader { + NexmonCsiHeader { + rssi_dbm: rssi, + fctl: 0x08, + src_mac: [0, 1, 2, 3, 4, 5], + seq_cnt: seq, + core: 0, + spatial_stream: 0, + chanspec, + chip_ver: 0x0142, + channel: 0, + bandwidth_mhz: 0, + is_5ghz: false, + subcarrier_count: nsub, + } + } + + fn synth_nexmon_pcap_bytes() -> Vec { + let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz + let nsub = 256u16; + let frames: Vec<(u64, NexmonCsiHeader, Vec, Vec)> = (0..4u64) + .map(|k| { + let i: Vec = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect(); + let q: Vec = (0..nsub).map(|s| (s as i16 % 7 + k as i16) as f32).collect(); + (1_000_000_000 + k * 50_000_000, synth_nexmon_header(-58 - k as i16, chanspec, nsub, k as u16 + 1), i, q) + }) + .collect(); + rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).expect("build pcap") + } + + #[test] + fn decode_nexmon_pcap_yields_validated_frames() { + let pcap = synth_nexmon_pcap_bytes(); + let frames = decode_nexmon_pcap(&pcap, "nexmon-pcap", 7, None).unwrap(); + assert_eq!(frames.len(), 4); + for f in &frames { + assert!(f.is_exposable()); + assert_eq!(f.adapter_kind, AdapterKind::Nexmon); + assert_eq!(f.channel, 36); + assert_eq!(f.bandwidth_mhz, 80); + assert_eq!(f.subcarrier_count, 256); + } + assert_eq!(frames[0].timestamp_ns, 1_000_000_000); + assert_eq!(frames[3].timestamp_ns, 1_000_000_000 + 3 * 50_000_000); + // 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); + } + + #[test] + fn summarize_nexmon_pcap_reports_metadata() { + let pcap = synth_nexmon_pcap_bytes(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &pcap).unwrap(); + let s = summarize_nexmon_pcap(tmp.path().to_str().unwrap(), None).unwrap(); + assert_eq!(s.link_type, rvcsi_adapter_nexmon::LINKTYPE_ETHERNET); + assert_eq!(s.csi_frame_count, 4); + 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.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); } }