feat(rvcsi): real nexmon_csi UDP/PCAP fidelity — chanspec decode, libpcap reader, NexmonPcapAdapter

Raises the Nexmon path from a normalized record format to parsing what the
patched Broadcom firmware actually emits, end to end.

napi-c shim (ABI 1.0 -> 1.1, additive):
- rvcsi_nx_csi_udp_header / rvcsi_nx_csi_udp_decode — parse the real nexmon_csi
  UDP payload: the 18-byte header (magic 0x1111, rssi int8, fctl, src_mac[6],
  seq_cnt, core/spatial-stream, Broadcom chanspec, chip_ver) + nsub complex CSI
  samples (modern int16 LE I/Q export — what CSIKit/csireader.py read for the
  BCM43455c0 / 4358 / 4366c0; nsub = (len-18)/4). rvcsi_nx_csi_udp_write to
  synthesize payloads for tests. rvcsi_nx_decode_chanspec — d11ac chanspec ->
  channel (chanspec & 0xff) / bandwidth (bits [13:11], cross-checked against the
  FFT size) / band (bits [15:14], cross-checked against the channel number).
  Still allocation-free, bounds-checked, structured errors, never panics.
- ffi.rs wraps it: decode_chanspec / parse_nexmon_udp_header / decode_nexmon_udp
  / encode_nexmon_udp + DecodedChanspec / NexmonCsiHeader; every unsafe block
  documented; the ABI guard now expects 1.1.

rvcsi-adapter-nexmon:
- pcap.rs — a dependency-free classic-libpcap reader (all four byte-order /
  timestamp-resolution magics; Ethernet / raw-IPv4 / Linux-SLL link types;
  tolerates a truncated final record; pcapng is a follow-up) + extract_udp_payload
  + a synthetic_udp_pcap / synthetic_nexmon_pcap test/example generator.
- NexmonPcapAdapter (a CsiSource) — reads the CSI UDP packets out of a
  `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture, decodes each via the C
  shim, stamps the frame timestamp from the pcap packet time; non-CSI packets
  counted as "skipped" in health.

rvcsi-runtime: decode_nexmon_pcap, summarize_nexmon_pcap (+ NexmonPcapSummary:
link type, CSI frame count, channels, bandwidths, subcarrier counts, chip
versions, RSSI range, time span), CaptureRuntime::open_nexmon_pcap[_bytes].

rvcsi-node (napi-rs): nexmonDecodePcap, inspectNexmonPcap, decodeChanspec,
RvcsiRuntime.openNexmonPcap. @ruv/rvcsi SDK + .d.ts updated (NexmonPcapSummary,
DecodedChanspec). rvcsi-cli: `record --source nexmon-pcap`, `inspect-nexmon`,
`decode-chanspec`.

161 rvcsi tests pass (adapter-nexmon 9->22), 0 failures, clippy-clean.
ADR-096 §2.2/§2.3/§5, CHANGELOG, CLAUDE.md updated.

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
This commit is contained in:
Claude 2026-05-13 01:15:22 +00:00
parent 684a064816
commit b116a99481
No known key found for this signature in database
19 changed files with 2053 additions and 119 deletions

View file

@ -21,8 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK.
- **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`.
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, 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.

View file

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

View file

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

1
v2/Cargo.lock generated
View file

@ -6026,6 +6026,7 @@ dependencies = [
"napi",
"napi-build",
"napi-derive",
"rvcsi-adapter-nexmon",
"rvcsi-core",
"rvcsi-runtime",
"serde",

View file

@ -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 <string.h>
#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;
}

View file

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

View file

@ -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<Vec<u8>, 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<RvcsiNxUdpHeader> 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<NexmonCsiHeader, NexmonFfiError> {
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<Vec<u8>, 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<f32> = (0..nsub).map(|k| (k as i16 - 32) as f32).collect();
let q: Vec<f32> = (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);
}
}
}

View file

@ -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<f32>, Vec<f32>)],
port: u16,
) -> Result<Vec<u8>, NexmonFfiError> {
let payloads: Vec<Vec<u8>> = frames
.iter()
.map(|(_, h, i, q)| encode_nexmon_udp(h, i, q))
.collect::<Result<_, _>>()?;
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<CsiFrame>,
headers: Vec<NexmonCsiHeader>,
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<SourceId>,
session_id: SessionId,
pcap_bytes: &[u8],
port: Option<u16>,
) -> Result<Self, RvcsiError> {
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<SourceId>,
session_id: SessionId,
path: impl AsRef<Path>,
port: Option<u16>,
) -> Result<Self, RvcsiError> {
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<SourceId>,
session_id: SessionId,
pcap_bytes: &[u8],
port: Option<u16>,
) -> Result<Vec<CsiFrame>, 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<Option<CsiFrame>, 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<u8> {
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<f32> = (0..nsub).map(|k| (k as i16 - 32) as f32).collect();
let q: Vec<f32> = (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<u8> {
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<u8>)]) -> Vec<u8> {
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());
}
}

View file

@ -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<u8>,
}
/// A parsed classic-pcap file.
#[derive(Debug, Clone)]
pub struct PcapReader {
link_type: u32,
packets: Vec<PcapPacket>,
}
fn parse_err(offset: usize, msg: impl Into<String>) -> 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<PcapReader, RvcsiError> {
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<u16>,
) -> impl Iterator<Item = (u64, u16, &[u8])> + '_ {
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<u8> {
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
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<u8> {
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<u8>)]) -> Vec<u8> {
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(&eth, 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(&eth, LINKTYPE_ETHERNET).is_none());
// unknown link type
assert!(extract_udp_payload(&eth, 9999).is_none());
}
}

View file

@ -58,6 +58,102 @@ pub fn record_from_nexmon(
Ok(())
}
/// `rvcsi record --source nexmon-pcap --in <csi.pcap> --out <cap.rvcsi>` —
/// 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<u16>,
) -> 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 <csi.pcap>` — 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<u16>, 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::<Vec<_>>().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 <hex-or-dec>` — 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::<u32>().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 <path>` — 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<f32>, Vec<f32>)> = (0..8u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
let q: Vec<f32> = (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());
}
}

View file

@ -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<u16>,
},
/// 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<u16>,
/// 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 {

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

@ -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<u16>,
) -> napi::Result<String> {
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<u16>) -> napi::Result<String> {
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<String> {
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<u16>,
) -> napi::Result<RvcsiRuntime> {
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<Option<String>> {

View file

@ -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<u16>,
) -> Result<Self, RvcsiError> {
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<u16>,
) -> Result<Self, RvcsiError> {
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<CsiFrame> {
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<f32>, Vec<f32>)> = (0..12u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 32 + k as i16) as f32).collect();
let q: Vec<f32> = (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());
}
}

View file

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

View file

@ -119,33 +119,139 @@ pub fn summarize_capture(path: &str) -> Result<CaptureSummary, RvcsiError> {
})
}
/// 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<Vec<CsiFrame>, 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<CsiFrame>) -> Vec<CsiFrame> {
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<u64> = 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<Vec<CsiFrame>, 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<u16>,
) -> Result<Vec<CsiFrame>, 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<u16>,
/// Distinct bandwidths (MHz) seen.
pub bandwidths_mhz: Vec<u16>,
/// Distinct subcarrier (FFT) counts seen.
pub subcarrier_counts: Vec<u16>,
/// Distinct chip-version words seen (e.g. `0x0142` = BCM43455c0).
pub chip_versions: Vec<u16>,
/// 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<u16>) -> Result<NexmonPcapSummary, RvcsiError> {
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<u8> {
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz
let nsub = 256u16;
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..4u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
let q: Vec<f32> = (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);
}
}