mirror of
https://github.com/ruvnet/RuView.git
synced 2026-05-19 08:10:07 +00:00
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:
parent
684a064816
commit
b116a99481
19 changed files with 2053 additions and 119 deletions
|
|
@ -21,8 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
- **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK.
|
||||
- **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1–FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`.
|
||||
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, parses/writes a byte-defined "rvCSI Nexmon record", a normalized superset of the nexmon_csi UDP payload), compiled via `build.rs`+`cc`, wrapped by a documented `ffi` module and a `NexmonAdapter` `CsiSource`; `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` Nexmon-dump→`.rvcsi`, `inspect`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary` objects, with a lazy native-addon load.
|
||||
- **Tests:** 142 across the rvcsi crates (core 29, dsp 28, events 18, adapter-file 20 + 1 doctest, adapter-nexmon 9 — round-tripping through the C shim, ruvector 20 + 1 doctest, runtime 10, cli 7), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Not yet wired in: live radio capture, the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`) — follow-ups on top of these crates.
|
||||
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture; the pcap timestamp stamps each frame). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — `nexmonDecodeRecords`/`nexmonDecodePcap`/`inspectNexmonPcap`/`decodeChanspec`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap` → `.rvcsi`), `inspect`, `inspect-nexmon`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load.
|
||||
- **Tests:** 161 across the rvcsi crates (core 29, dsp 28, events 18, adapter-file 20 + 1 doctest, adapter-nexmon 22 — round-tripping through the C shim and synthetic libpcap files, ruvector 20 + 1 doctest, runtime 13, cli 9), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Not yet wired in: live radio capture, the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates.
|
||||
- **`wifi-densepose-train`: `signal_features` module — wires `wifi-densepose-signal` into the training pipeline.** `wifi-densepose-signal` was previously a phantom dependency of `wifi-densepose-train` (listed in `Cargo.toml`, never imported). New `wifi_densepose_train::signal_features::extract_signal_features` (and `CsiSample::signal_features()`) run a windowed CSI observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (`FEATURE_LEN = 12`) amplitude/phase/PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and not yet fed back into the loss. Surfaced by the 2026-05-11 training-pipeline audit (findings #1 "vitals features absent from training" and #2 "`wifi-densepose-signal` ghost dep").
|
||||
- **`wifi-densepose-train`: `TrainingConfig` subcarrier-layout presets + a real-loader integration test.** New `TrainingConfig::for_subcarriers(native, target)` plus named presets `ht40_192()` (≈192-sc ESP32 HT40 → 56) and `multiband_168()` (168-sc ADR-078 multi-band mesh → 56), so non-MM-Fi CSI shapes are first-class instead of requiring manual `native_subcarriers`/`num_subcarriers` overrides; field docs now list the supported source counts and the multi-NIC mapping. New `tests/test_real_loader.rs` round-trips synthetic CSI through `.npy` files → `MmFiDataset::discover`/`get` (including the subcarrier-interpolation branch and the empty-root case) — exercising the on-disk loader path the deterministic `verify-training` proof intentionally bypasses. Addresses training-pipeline audit findings #6 (56-sc/1-NIC config default) and #7 (multi-band mesh not in config); the #4 concern ("proof uses synthetic data") is reframed — the proof *should* use a reproducible source, and this test covers the real loader it skips.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
1
v2/Cargo.lock
generated
|
|
@ -6026,6 +6026,7 @@ dependencies = [
|
|||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"rvcsi-adapter-nexmon",
|
||||
"rvcsi-core",
|
||||
"rvcsi-runtime",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
381
v2/crates/rvcsi-adapter-nexmon/src/pcap.rs
Normal file
381
v2/crates/rvcsi-adapter-nexmon/src/pcap.rs
Normal 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(ð, LINKTYPE_ETHERNET).is_none());
|
||||
// wrong ethertype
|
||||
let mut eth = eth_ip_udp(5500, &[0u8; 8]);
|
||||
eth[12] = 0x86;
|
||||
eth[13] = 0xdd; // IPv6
|
||||
assert!(extract_udp_payload(ð, LINKTYPE_ETHERNET).is_none());
|
||||
// unknown link type
|
||||
assert!(extract_udp_payload(ð, 9999).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
48
v2/crates/rvcsi-node/index.d.ts
vendored
48
v2/crates/rvcsi-node/index.d.ts
vendored
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue