diff --git a/CHANGELOG.md b/CHANGELOG.md index 36731afc63..da1e68d815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*` + crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/ + `-runtime`/`-node`/`-cli` — added inline in #542) now live in + [`github.com/ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi): published to crates.io + as `rvcsi-* 0.3.x`, to npm as `@ruv/rvcsi`, with a Claude Code plugin marketplace and + a RuView-style README. RuView vendors it under `vendor/rvcsi` (alongside + `vendor/ruvector` / `vendor/midstream` / `vendor/sublinear-time-solver`) and no longer + carries inline copies in `v2/crates/`; consumers depend on the published crates (or the + submodule's `crates/rvcsi-*` paths). `v2/Cargo.toml`, `CLAUDE.md`, and the README docs + table updated accordingly. The ADRs (ADR-095, ADR-096), PRD, and DDD model stay in + `docs/` here as the design record of the incubation. + ### Fixed - **README: corrected the camera-supervised pose-accuracy claim.** The README stated "92.9% PCK@20" for camera-supervised training; that figure does not appear in diff --git a/CLAUDE.md b/CLAUDE.md index 9ca1313028..2fb504308f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,15 +23,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`). | `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) | | `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) | | `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready | -| `rvcsi-core` | rvCSI: normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` trait, `validate_frame` pipeline (ADR-095/096) | -| `rvcsi-dsp` | rvCSI: reusable DSP stages (DC removal, phase unwrap, Hampel, smoothing, variance, baseline subtraction, motion/presence/breathing features, `SignalPipeline`) | -| `rvcsi-events` | rvCSI: `WindowBuffer` + `EventDetector` state machines (presence/motion/quality/baseline-drift) + `EventPipeline` | -| `rvcsi-adapter-file` | rvCSI: `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` (deterministic replay) | -| `rvcsi-adapter-nexmon` | rvCSI: the **napi-c** seam — `native/rvcsi_nexmon_shim.{c,h}` (the only C; ABI 1.1; rvCSI-record + real nexmon_csi UDP + chanspec; `build.rs`+`cc`) + pure-Rust pcap reader + Nexmon-chip / Raspberry-Pi-model registry (incl. **Pi 5** = BCM43455c0) + `NexmonAdapter` / `NexmonPcapAdapter` (chip auto-detect) | -| `rvcsi-ruvector` | rvCSI: deterministic RF-memory embeddings, `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` (RuVector standin) | -| `rvcsi-runtime` | rvCSI: composition layer — `CaptureRuntime` (source + validate + DSP + events) + one-shot capture/nexmon-pcap helpers | -| `rvcsi-node` | rvCSI: the **napi-rs** seam — `["cdylib","rlib"]` Node addon; ships the `@ruv/rvcsi` npm package | -| `rvcsi-cli` | rvCSI: the `rvcsi` binary — record/inspect/inspect-nexmon/decode-chanspec/replay/stream/events/health/calibrate/export | +| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. | ### RuvSense Modules (`signal/src/ruvsense/`) | Module | Purpose | diff --git a/README.md b/README.md index 7e7ad04cdc..eecb9847ea 100644 --- a/README.md +++ b/README.md @@ -522,7 +522,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail | [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror | | [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) | | [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language | -| [rvCSI — edge RF sensing runtime](docs/prd/rvcsi-platform-prd.md) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md); 9 `rvcsi-*` crates + the `@ruv/rvcsi` napi-rs SDK) | +| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. | | [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization | | [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable | | [Extended Documentation](docs/readme-details.md) | Latest additions, key features, installation, quick start, signal processing, training, CLI, testing, deployment, and changelog | diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 6ce3e3b107..3c36e29e02 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -21,16 +21,11 @@ members = [ "crates/wifi-densepose-geo", "crates/nvsim", "crates/nvsim-server", - # rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout) - "crates/rvcsi-core", - "crates/rvcsi-dsp", - "crates/rvcsi-events", - "crates/rvcsi-adapter-file", - "crates/rvcsi-adapter-nexmon", - "crates/rvcsi-ruvector", - "crates/rvcsi-runtime", - "crates/rvcsi-node", - "crates/rvcsi-cli", + # rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout): + # lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as + # `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the + # published crates (or the submodule's `crates/rvcsi-*` paths) — not as v2 + # workspace members, since `vendor/rvcsi/Cargo.toml` is its own workspace. ] # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. diff --git a/v2/crates/rvcsi-adapter-file/Cargo.toml b/v2/crates/rvcsi-adapter-file/Cargo.toml deleted file mode 100644 index 9c4d50b748..0000000000 --- a/v2/crates/rvcsi-adapter-file/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "rvcsi-adapter-file" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI file/replay adapter — records and replays .rvcsi capture sessions deterministically (ADR-095 FR1/FR10, D9)" -repository.workspace = true -keywords = ["wifi", "csi", "replay", "rvcsi"] -categories = ["science"] - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } -tempfile = "3.10" diff --git a/v2/crates/rvcsi-adapter-file/src/format.rs b/v2/crates/rvcsi-adapter-file/src/format.rs deleted file mode 100644 index df0389dcb4..0000000000 --- a/v2/crates/rvcsi-adapter-file/src/format.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! The `.rvcsi` capture container format (ADR-095 FR1/FR10, D9). -//! -//! A `.rvcsi` file is plain [JSONL]: the **first line** is a -//! [`CaptureHeader`] object describing the session; every **subsequent line** -//! is one [`rvcsi_core::CsiFrame`] serialized as JSON. This keeps the format -//! simple, deterministic, append-friendly and trivially debuggable with `head` -//! / `jq`. -//! -//! [JSONL]: https://jsonlines.org/ - -use rvcsi_core::{AdapterProfile, SessionId, SourceId, ValidationPolicy}; -use serde::{Deserialize, Serialize}; - -/// Current `.rvcsi` capture format version. Written into every header and -/// checked on read. -pub const CAPTURE_VERSION: u32 = 1; - -/// Header object — the first line of every `.rvcsi` capture file. -/// -/// It records enough context to replay the session faithfully: the originating -/// session/source ids, the source's [`AdapterProfile`], the -/// [`ValidationPolicy`] that was in force, the calibration version (if any), -/// and an opaque `runtime_config_json` blob the caller may use for whatever it -/// likes (defaults to `"{}"`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CaptureHeader { - /// Capture format version (always [`CAPTURE_VERSION`] when written). - pub rvcsi_capture_version: u32, - /// Session this capture belongs to. - pub session_id: SessionId, - /// Source the frames were captured from. - pub source_id: SourceId, - /// Capability descriptor of the source at capture time. - pub adapter_profile: AdapterProfile, - /// Validation policy that was in force during capture. - pub validation_policy: ValidationPolicy, - /// Calibration version frames were processed against, if any. - pub calibration_version: Option, - /// Opaque caller-supplied runtime config (JSON; default `"{}"`). - pub runtime_config_json: String, - /// Wall-clock creation time, nanoseconds since the Unix epoch (`0` if unknown). - pub created_unix_ns: u64, -} - -impl CaptureHeader { - /// Build a header for `session_id` / `source_id` / `adapter_profile` with - /// sensible defaults: version [`CAPTURE_VERSION`], [`ValidationPolicy::default`], - /// no calibration version, `runtime_config_json == "{}"`, and - /// `created_unix_ns` taken from the system clock (or `0` if it is unavailable - /// or before the epoch). - pub fn new(session_id: SessionId, source_id: SourceId, adapter_profile: AdapterProfile) -> Self { - CaptureHeader { - rvcsi_capture_version: CAPTURE_VERSION, - session_id, - source_id, - adapter_profile, - validation_policy: ValidationPolicy::default(), - calibration_version: None, - runtime_config_json: "{}".to_string(), - created_unix_ns: now_unix_ns(), - } - } - - /// Builder: override the validation policy. - pub fn with_validation_policy(mut self, policy: ValidationPolicy) -> Self { - self.validation_policy = policy; - self - } - - /// Builder: set the calibration version. - pub fn with_calibration_version(mut self, version: impl Into) -> Self { - self.calibration_version = Some(version.into()); - self - } - - /// Builder: set the opaque runtime config blob. - pub fn with_runtime_config_json(mut self, json: impl Into) -> Self { - self.runtime_config_json = json.into(); - self - } - - /// Builder: pin `created_unix_ns` (useful for deterministic tests). - pub fn with_created_unix_ns(mut self, ns: u64) -> Self { - self.created_unix_ns = ns; - self - } -} - -/// Best-effort "nanoseconds since the Unix epoch" using the system clock; -/// returns `0` when the clock is unavailable or set before the epoch. -fn now_unix_ns() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::AdapterKind; - - #[test] - fn header_defaults() { - let h = CaptureHeader::new( - SessionId(7), - SourceId::from("file:lab.rvcsi"), - AdapterProfile::offline(AdapterKind::File), - ); - assert_eq!(h.rvcsi_capture_version, CAPTURE_VERSION); - assert_eq!(h.runtime_config_json, "{}"); - assert!(h.calibration_version.is_none()); - assert_eq!(h.validation_policy, ValidationPolicy::default()); - } - - #[test] - fn header_builders() { - let h = CaptureHeader::new( - SessionId(1), - SourceId::from("s"), - AdapterProfile::offline(AdapterKind::File), - ) - .with_calibration_version("room@v2") - .with_runtime_config_json(r#"{"foo":1}"#) - .with_created_unix_ns(42); - assert_eq!(h.calibration_version.as_deref(), Some("room@v2")); - assert_eq!(h.runtime_config_json, r#"{"foo":1}"#); - assert_eq!(h.created_unix_ns, 42); - } - - #[test] - fn header_json_roundtrips() { - let h = CaptureHeader::new( - SessionId(3), - SourceId::from("esp32"), - AdapterProfile::esp32_default(), - ) - .with_created_unix_ns(123); - let json = serde_json::to_string(&h).unwrap(); - let back: CaptureHeader = serde_json::from_str(&json).unwrap(); - assert_eq!(h, back); - } -} diff --git a/v2/crates/rvcsi-adapter-file/src/lib.rs b/v2/crates/rvcsi-adapter-file/src/lib.rs deleted file mode 100644 index a601af9878..0000000000 --- a/v2/crates/rvcsi-adapter-file/src/lib.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! # rvCSI file/replay adapter -//! -//! The `.rvcsi` capture container, its [`FileRecorder`], and the -//! [`FileReplayAdapter`] [`CsiSource`](rvcsi_core::CsiSource) (ADR-095 FR1/FR10, -//! D9). -//! -//! A `.rvcsi` file is plain [JSONL]: the first line is a [`CaptureHeader`] -//! describing the session; every subsequent line is one -//! [`rvcsi_core::CsiFrame`] serialized as compact JSON. The format is simple, -//! deterministic, append-friendly and trivially inspectable with `head` / `jq`. -//! -//! Typical use: -//! -//! ```no_run -//! use rvcsi_adapter_file::{CaptureHeader, FileRecorder, FileReplayAdapter}; -//! use rvcsi_core::{AdapterKind, AdapterProfile, CsiSource, SessionId, SourceId}; -//! -//! # fn demo() -> rvcsi_core::Result<()> { -//! let header = CaptureHeader::new( -//! SessionId(1), -//! SourceId::from("file:lab.rvcsi"), -//! AdapterProfile::offline(AdapterKind::File), -//! ); -//! let mut rec = FileRecorder::create("lab.rvcsi", &header)?; -//! // rec.write_frame(&frame)?; ... -//! rec.finish()?; -//! -//! let mut replay = FileReplayAdapter::open("lab.rvcsi")?; -//! while let Some(frame) = replay.next_frame()? { -//! // hand `frame` downstream — its ValidationStatus is preserved as recorded -//! let _ = frame; -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! [JSONL]: https://jsonlines.org/ - -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -mod format; -mod recorder; -mod replay; - -pub use format::{CaptureHeader, CAPTURE_VERSION}; -pub use recorder::FileRecorder; -pub use replay::FileReplayAdapter; - -use std::path::Path; - -use rvcsi_core::{CsiFrame, Result}; - -/// Read an entire `.rvcsi` capture into memory: its [`CaptureHeader`] and every -/// [`CsiFrame`] it contains, in recording order. -/// -/// This is a convenience wrapper over [`FileReplayAdapter`]; for large captures -/// or streaming use, prefer iterating [`FileReplayAdapter`] directly. Errors are -/// the same as [`FileReplayAdapter::open`] / [`FileReplayAdapter::next_frame`]: -/// an [`rvcsi_core::RvcsiError::Io`] for a missing/unreadable file, an -/// [`rvcsi_core::RvcsiError::Parse`] (offset `0`) for a bad header, or an -/// [`rvcsi_core::RvcsiError::Parse`] carrying the 1-based line number for a -/// malformed frame line. -pub fn read_all(path: impl AsRef) -> Result<(CaptureHeader, Vec)> { - use rvcsi_core::CsiSource; - let mut adapter = FileReplayAdapter::open(path)?; - let header = adapter.header().clone(); - let mut frames = Vec::new(); - while let Some(frame) = adapter.next_frame()? { - frames.push(frame); - } - Ok((header, frames)) -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{ - AdapterKind, AdapterProfile, CsiSource, FrameId, RvcsiError, SessionId, SourceId, - ValidationStatus, - }; - use std::fs::File; - use std::io::{Read, Write}; - - fn header() -> CaptureHeader { - CaptureHeader::new( - SessionId(1), - SourceId::from("it-test"), - AdapterProfile::offline(AdapterKind::File), - ) - .with_created_unix_ns(0) - .with_calibration_version("room@v1") - .with_runtime_config_json(r#"{"window_ms":500}"#) - } - - /// A small varied set of frames: two accepted (quality 0.9), two degraded - /// with reasons, one recovered — varying timestamps / channels / subcarrier - /// counts. - fn sample_frames() -> Vec { - let mut frames = Vec::new(); - - let mut f0 = CsiFrame::from_iq( - FrameId(0), - SessionId(1), - SourceId::from("it-test"), - AdapterKind::File, - 1_000, - 1, - 20, - vec![1.0, 2.0, 3.0, 4.0], - vec![0.5, 0.5, 0.5, 0.5], - ) - .with_rssi(-55); - f0.validation = ValidationStatus::Accepted; - f0.quality_score = 0.9; - frames.push(f0); - - let mut f1 = CsiFrame::from_iq( - FrameId(1), - SessionId(1), - SourceId::from("it-test"), - AdapterKind::File, - 2_000, - 6, - 40, - vec![0.1; 8], - vec![0.2; 8], - ); - f1.validation = ValidationStatus::Degraded; - f1.quality_score = 0.4; - f1.quality_reasons = vec!["missing rssi".to_string(), "low snr".to_string()]; - frames.push(f1); - - let mut f2 = CsiFrame::from_iq( - FrameId(2), - SessionId(1), - SourceId::from("it-test"), - AdapterKind::File, - 3_000, - 11, - 20, - vec![5.0, 6.0], - vec![1.0, -1.0], - ) - .with_rssi(-70) - .with_noise_floor(-95); - f2.validation = ValidationStatus::Accepted; - f2.quality_score = 0.9; - frames.push(f2); - - let mut f3 = CsiFrame::from_iq( - FrameId(3), - SessionId(1), - SourceId::from("it-test"), - AdapterKind::File, - 2_500, // deliberately out of order — replay preserves it verbatim - 6, - 20, - vec![0.0; 3], - vec![0.0; 3], - ); - f3.validation = ValidationStatus::Recovered; - f3.quality_score = 0.3; - frames.push(f3); - - let mut f4 = CsiFrame::from_iq( - FrameId(4), - SessionId(1), - SourceId::from("it-test"), - AdapterKind::File, - 4_000, - 36, - 80, - vec![2.0; 6], - vec![0.0; 6], - ); - f4.validation = ValidationStatus::Degraded; - f4.quality_score = 0.5; - f4.quality_reasons = vec!["amplitude spike".to_string()]; - frames.push(f4); - - frames - } - - #[test] - fn record_then_replay_roundtrips_exactly() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = header(); - let frames = sample_frames(); - - let mut rec = FileRecorder::create(tmp.path(), &header).unwrap(); - for f in &frames { - rec.write_frame(f).unwrap(); - } - assert_eq!(rec.frames_written(), frames.len() as u64); - rec.finish().unwrap(); - - let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap(); - assert_eq!(adapter.header(), &header); - let mut got = Vec::new(); - while let Some(f) = adapter.next_frame().unwrap() { - got.push(f); - } - assert_eq!(got, frames); - assert_eq!(adapter.health().frames_delivered, frames.len() as u64); - assert!(!adapter.health().connected); - } - - #[test] - fn re_serializing_replayed_frames_is_byte_identical() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = header(); - let frames = sample_frames(); - let mut rec = FileRecorder::create(tmp.path(), &header).unwrap(); - for f in &frames { - rec.write_frame(f).unwrap(); - } - rec.finish().unwrap(); - - let mut original = String::new(); - File::open(tmp.path()).unwrap().read_to_string(&mut original).unwrap(); - - // Round-trip the whole capture and re-emit it; bytes must match. - let (h, fs) = read_all(tmp.path()).unwrap(); - let tmp2 = tempfile::NamedTempFile::new().unwrap(); - let mut rec2 = FileRecorder::create(tmp2.path(), &h).unwrap(); - for f in &fs { - rec2.write_frame(f).unwrap(); - } - rec2.finish().unwrap(); - let mut reemitted = String::new(); - File::open(tmp2.path()).unwrap().read_to_string(&mut reemitted).unwrap(); - - assert_eq!(original, reemitted); - } - - #[test] - fn read_all_matches_replay() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = header(); - let frames = sample_frames(); - let mut rec = FileRecorder::create(tmp.path(), &header).unwrap(); - for f in &frames { - rec.write_frame(f).unwrap(); - } - rec.finish().unwrap(); - - let (h, fs) = read_all(tmp.path()).unwrap(); - assert_eq!(h, header); - assert_eq!(fs, frames); - } - - #[test] - fn header_only_capture_has_no_frames() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = header(); - FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap(); - - let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap(); - assert!(adapter.next_frame().unwrap().is_none()); - - let (h, fs) = read_all(tmp.path()).unwrap(); - assert_eq!(h, header); - assert!(fs.is_empty()); - } - - #[test] - fn bad_header_line_is_parse_error_at_offset_zero() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - { - let mut f = File::create(tmp.path()).unwrap(); - f.write_all(b"not json\n").unwrap(); - } - match FileReplayAdapter::open(tmp.path()) { - Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0), - other => panic!("expected Parse at offset 0, got {other:?}"), - } - match read_all(tmp.path()) { - Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0), - other => panic!("expected Parse at offset 0, got {other:?}"), - } - } - - #[test] - fn garbage_frame_after_good_frames_reports_line_number() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = header(); - { - let mut f = File::create(tmp.path()).unwrap(); - serde_json::to_writer(&mut f, &header).unwrap(); - f.write_all(b"\n").unwrap(); - // lines 2 + 3: good frames - let frames = sample_frames(); - serde_json::to_writer(&mut f, &frames[0]).unwrap(); - f.write_all(b"\n").unwrap(); - serde_json::to_writer(&mut f, &frames[1]).unwrap(); - f.write_all(b"\n").unwrap(); - // line 4: garbage - f.write_all(b"{ not a frame }\n").unwrap(); - } - let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap(); - assert!(adapter.next_frame().unwrap().is_some()); // line 2 - assert!(adapter.next_frame().unwrap().is_some()); // line 3 - match adapter.next_frame() { - Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 4), - other => panic!("expected Parse at line 4, got {other:?}"), - } - } - - #[test] - fn nonexistent_path_is_io_error() { - match FileReplayAdapter::open("/no/such/file/at/all.rvcsi") { - Err(RvcsiError::Io(_)) => {} - other => panic!("expected Io error, got {other:?}"), - } - match read_all("/no/such/file/at/all.rvcsi") { - Err(RvcsiError::Io(_)) => {} - other => panic!("expected Io error, got {other:?}"), - } - } - - #[test] - fn counters_are_consistent() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = header(); - let frames = sample_frames(); - let mut rec = FileRecorder::create(tmp.path(), &header).unwrap(); - for (i, f) in frames.iter().enumerate() { - rec.write_frame(f).unwrap(); - assert_eq!(rec.frames_written(), (i + 1) as u64); - } - rec.finish().unwrap(); - - let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap(); - let mut n = 0u64; - while adapter.next_frame().unwrap().is_some() { - n += 1; - assert_eq!(adapter.health().frames_delivered, n); - } - assert_eq!(n, frames.len() as u64); - } -} diff --git a/v2/crates/rvcsi-adapter-file/src/recorder.rs b/v2/crates/rvcsi-adapter-file/src/recorder.rs deleted file mode 100644 index 81d69cd7ec..0000000000 --- a/v2/crates/rvcsi-adapter-file/src/recorder.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! [`FileRecorder`] — writes a `.rvcsi` capture: a header line followed by one -//! JSON line per [`CsiFrame`]. - -use std::fs::File; -use std::io::{BufWriter, Write}; -use std::path::Path; - -use rvcsi_core::{CsiFrame, Result}; - -use crate::format::CaptureHeader; - -/// Append-only writer for a `.rvcsi` capture file. -/// -/// Create one with [`FileRecorder::create`] (which writes the header line), -/// push frames with [`FileRecorder::write_frame`], and call -/// [`FileRecorder::finish`] (or just drop it after [`FileRecorder::flush`]) to -/// be sure everything reached disk. -pub struct FileRecorder { - writer: BufWriter, - frames_written: u64, -} - -impl FileRecorder { - /// Create `path` (truncating any existing file) and write `header` as the - /// first line. - pub fn create(path: impl AsRef, header: &CaptureHeader) -> Result { - let file = File::create(path.as_ref())?; - let mut writer = BufWriter::new(file); - write_json_line(&mut writer, header)?; - Ok(FileRecorder { - writer, - frames_written: 0, - }) - } - - /// Append one frame as a JSON line. - pub fn write_frame(&mut self, frame: &CsiFrame) -> Result<()> { - write_json_line(&mut self.writer, frame)?; - self.frames_written += 1; - Ok(()) - } - - /// Flush buffered bytes to the underlying file. - pub fn flush(&mut self) -> Result<()> { - self.writer.flush()?; - Ok(()) - } - - /// Number of frames written so far (the header line is not counted). - pub fn frames_written(&self) -> u64 { - self.frames_written - } - - /// Flush and close the file, consuming the recorder. - pub fn finish(mut self) -> Result<()> { - self.flush() - } -} - -/// Serialize `value` as a single JSON line (no embedded newlines — `serde_json` -/// compact form never produces them) followed by `\n`. -fn write_json_line(writer: &mut W, value: &T) -> Result<()> { - serde_json::to_writer(&mut *writer, value)?; - writer.write_all(b"\n")?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{AdapterKind, AdapterProfile, FrameId, SessionId, SourceId}; - use std::io::Read; - - fn frame(id: u64, ts: u64) -> CsiFrame { - CsiFrame::from_iq( - FrameId(id), - SessionId(1), - SourceId::from("rec-test"), - AdapterKind::File, - ts, - 6, - 20, - vec![1.0, 2.0, 3.0], - vec![0.5, 0.5, 0.5], - ) - } - - #[test] - fn writes_header_then_frames_and_counts() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = CaptureHeader::new( - SessionId(1), - SourceId::from("rec-test"), - AdapterProfile::offline(AdapterKind::File), - ) - .with_created_unix_ns(0); - let mut rec = FileRecorder::create(tmp.path(), &header).unwrap(); - assert_eq!(rec.frames_written(), 0); - rec.write_frame(&frame(0, 100)).unwrap(); - rec.write_frame(&frame(1, 200)).unwrap(); - assert_eq!(rec.frames_written(), 2); - rec.finish().unwrap(); - - let mut contents = String::new(); - File::open(tmp.path()).unwrap().read_to_string(&mut contents).unwrap(); - let lines: Vec<&str> = contents.lines().collect(); - assert_eq!(lines.len(), 3); - let parsed_header: CaptureHeader = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(parsed_header, header); - let f0: CsiFrame = serde_json::from_str(lines[1]).unwrap(); - assert_eq!(f0, frame(0, 100)); - } -} diff --git a/v2/crates/rvcsi-adapter-file/src/replay.rs b/v2/crates/rvcsi-adapter-file/src/replay.rs deleted file mode 100644 index 48a580203e..0000000000 --- a/v2/crates/rvcsi-adapter-file/src/replay.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! [`FileReplayAdapter`] — a [`CsiSource`] that replays a `.rvcsi` capture -//! file, frame by frame, exactly as it was recorded. - -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::Path; - -use rvcsi_core::{ - AdapterProfile, CsiFrame, CsiSource, Result, RvcsiError, SessionId, SourceHealth, SourceId, -}; - -use crate::format::{CaptureHeader, CAPTURE_VERSION}; - -/// Deterministic replay source backed by a `.rvcsi` capture file. -/// -/// The header is parsed eagerly on [`FileReplayAdapter::open`]; frames are -/// parsed lazily, one line at a time, on each [`CsiSource::next_frame`] call. -/// Timestamps, ordering and per-frame [`rvcsi_core::ValidationStatus`] are -/// preserved verbatim — replay does not re-validate or re-order anything, it -/// only deserializes what was stored. -/// -/// `replay_speed` is carried for the daemon/CLI to pace playback with; the -/// adapter itself never sleeps. -#[derive(Debug)] -pub struct FileReplayAdapter { - header: CaptureHeader, - profile: AdapterProfile, - source_id: SourceId, - reader: BufReader, - /// 1-based line number of the line a subsequent `next_frame` will read. - next_line: usize, - frames_delivered: u64, - at_eof: bool, - replay_speed: f32, - last_status: Option, -} - -impl FileReplayAdapter { - /// Open `path` for replay at real-time speed (`replay_speed == 1.0`). - pub fn open(path: impl AsRef) -> Result { - Self::open_with_speed(path, 1.0) - } - - /// Open `path` for replay, carrying `replay_speed` for downstream pacing. - pub fn open_with_speed(path: impl AsRef, replay_speed: f32) -> Result { - let file = File::open(path.as_ref())?; - let mut reader = BufReader::new(file); - - let mut first = String::new(); - let n = reader.read_line(&mut first)?; - if n == 0 { - return Err(RvcsiError::parse(0, "empty capture file: missing header line")); - } - let header: CaptureHeader = serde_json::from_str(first.trim_end_matches(['\n', '\r'])) - .map_err(|e| RvcsiError::parse(0, format!("invalid .rvcsi header line: {e}")))?; - if header.rvcsi_capture_version != CAPTURE_VERSION { - return Err(RvcsiError::parse( - 0, - format!( - "unsupported .rvcsi capture version {} (this build supports {})", - header.rvcsi_capture_version, CAPTURE_VERSION - ), - )); - } - - let profile = header.adapter_profile.clone(); - let source_id = header.source_id.clone(); - Ok(FileReplayAdapter { - header, - profile, - source_id, - reader, - next_line: 2, - frames_delivered: 0, - at_eof: false, - replay_speed, - last_status: None, - }) - } - - /// The capture header parsed from the file. - pub fn header(&self) -> &CaptureHeader { - &self.header - } - - /// Playback speed multiplier carried for the daemon/CLI (the adapter itself - /// does not sleep). - pub fn replay_speed(&self) -> f32 { - self.replay_speed - } - - /// Whether the underlying file has been fully consumed. - pub fn is_at_eof(&self) -> bool { - self.at_eof - } -} - -impl CsiSource for FileReplayAdapter { - fn profile(&self) -> &AdapterProfile { - &self.profile - } - - fn session_id(&self) -> SessionId { - self.header.session_id - } - - fn source_id(&self) -> &SourceId { - &self.source_id - } - - fn next_frame(&mut self) -> core::result::Result, RvcsiError> { - if self.at_eof { - return Ok(None); - } - loop { - let mut line = String::new(); - let read = self.reader.read_line(&mut line)?; - if read == 0 { - self.at_eof = true; - return Ok(None); - } - let line_no = self.next_line; - self.next_line += 1; - let trimmed = line.trim_end_matches(['\n', '\r']); - if trimmed.is_empty() { - // Tolerate blank lines (e.g. a trailing newline at EOF). - continue; - } - let frame: CsiFrame = serde_json::from_str(trimmed).map_err(|e| { - self.last_status = Some(format!("parse error at line {line_no}")); - RvcsiError::parse(line_no, format!("invalid frame line {line_no}: {e}")) - })?; - self.frames_delivered += 1; - return Ok(Some(frame)); - } - } - - fn health(&self) -> SourceHealth { - SourceHealth { - connected: !self.at_eof, - frames_delivered: self.frames_delivered, - frames_rejected: 0, - status: self.last_status.clone(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::recorder::FileRecorder; - use rvcsi_core::{AdapterKind, FrameId, ValidationStatus}; - use std::io::Write; - - fn frame(id: u64, ts: u64) -> CsiFrame { - CsiFrame::from_iq( - FrameId(id), - SessionId(1), - SourceId::from("rep-test"), - AdapterKind::File, - ts, - 6, - 20, - vec![1.0, 2.0], - vec![0.0, 1.0], - ) - } - - fn write_capture(path: &Path, frames: &[CsiFrame]) -> CaptureHeader { - let header = CaptureHeader::new( - SessionId(1), - SourceId::from("rep-test"), - AdapterProfile::offline(AdapterKind::File), - ) - .with_created_unix_ns(0); - let mut rec = FileRecorder::create(path, &header).unwrap(); - for f in frames { - rec.write_frame(f).unwrap(); - } - rec.finish().unwrap(); - header - } - - #[test] - fn open_speed_default_is_one() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), &[]); - let a = FileReplayAdapter::open(tmp.path()).unwrap(); - assert_eq!(a.replay_speed(), 1.0); - let b = FileReplayAdapter::open_with_speed(tmp.path(), 4.0).unwrap(); - assert_eq!(b.replay_speed(), 4.0); - } - - #[test] - fn replays_frames_in_order() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let frames = vec![frame(0, 10), frame(1, 20), frame(2, 30)]; - let header = write_capture(tmp.path(), &frames); - let mut a = FileReplayAdapter::open(tmp.path()).unwrap(); - assert_eq!(a.header(), &header); - assert_eq!(a.session_id(), SessionId(1)); - assert_eq!(a.source_id(), &SourceId::from("rep-test")); - let mut got = Vec::new(); - while let Some(f) = a.next_frame().unwrap() { - got.push(f); - } - assert_eq!(got, frames); - assert!(a.is_at_eof()); - assert!(!a.health().connected); - assert_eq!(a.health().frames_delivered, 3); - // Repeated calls after EOF stay at None. - assert!(a.next_frame().unwrap().is_none()); - } - - #[test] - fn header_only_file_yields_no_frames() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), &[]); - let mut a = FileReplayAdapter::open(tmp.path()).unwrap(); - assert!(a.next_frame().unwrap().is_none()); - assert_eq!(a.health().frames_delivered, 0); - } - - #[test] - fn validation_status_preserved() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let mut f = frame(0, 1); - f.validation = ValidationStatus::Degraded; - f.quality_score = 0.42; - f.quality_reasons = vec!["missing rssi".to_string()]; - write_capture(tmp.path(), &[f.clone()]); - let mut a = FileReplayAdapter::open(tmp.path()).unwrap(); - let back = a.next_frame().unwrap().unwrap(); - assert_eq!(back, f); - assert_eq!(back.validation, ValidationStatus::Degraded); - assert_eq!(back.quality_reasons, vec!["missing rssi".to_string()]); - } - - #[test] - fn bad_header_is_parse_error_at_offset_zero() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - { - let mut f = File::create(tmp.path()).unwrap(); - f.write_all(b"not json\n").unwrap(); - } - let err = FileReplayAdapter::open(tmp.path()).unwrap_err(); - match err { - RvcsiError::Parse { offset, .. } => assert_eq!(offset, 0), - other => panic!("expected Parse, got {other:?}"), - } - } - - #[test] - fn garbage_frame_line_is_parse_error_with_line_number() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = CaptureHeader::new( - SessionId(1), - SourceId::from("rep-test"), - AdapterProfile::offline(AdapterKind::File), - ) - .with_created_unix_ns(0); - { - let mut f = File::create(tmp.path()).unwrap(); - serde_json::to_writer(&mut f, &header).unwrap(); - f.write_all(b"\n").unwrap(); - // line 2: a good frame - serde_json::to_writer(&mut f, &frame(0, 1)).unwrap(); - f.write_all(b"\n").unwrap(); - // line 3: garbage - f.write_all(b"{not a frame}\n").unwrap(); - } - let mut a = FileReplayAdapter::open(tmp.path()).unwrap(); - assert!(a.next_frame().unwrap().is_some()); // line 2 ok - let err = a.next_frame().unwrap_err(); // line 3 - match err { - RvcsiError::Parse { offset, .. } => assert_eq!(offset, 3), - other => panic!("expected Parse at line 3, got {other:?}"), - } - } - - #[test] - fn nonexistent_path_is_io_error() { - let err = FileReplayAdapter::open("/no/such/rvcsi/file.rvcsi").unwrap_err(); - assert!(matches!(err, RvcsiError::Io(_)), "expected Io, got {err:?}"); - } - - #[test] - fn wrong_version_rejected() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let mut header = CaptureHeader::new( - SessionId(1), - SourceId::from("x"), - AdapterProfile::offline(AdapterKind::File), - ); - header.rvcsi_capture_version = 999; - { - let mut f = File::create(tmp.path()).unwrap(); - serde_json::to_writer(&mut f, &header).unwrap(); - f.write_all(b"\n").unwrap(); - } - let err = FileReplayAdapter::open(tmp.path()).unwrap_err(); - assert!(matches!(err, RvcsiError::Parse { offset: 0, .. })); - } -} diff --git a/v2/crates/rvcsi-adapter-nexmon/Cargo.toml b/v2/crates/rvcsi-adapter-nexmon/Cargo.toml deleted file mode 100644 index 789a51381b..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "rvcsi-adapter-nexmon" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI Nexmon adapter — wraps the isolated napi-c shim that parses Nexmon CSI UDP/PCAP records into normalized CsiFrames (ADR-095 D2/D15, ADR-096)" -repository.workspace = true -keywords = ["wifi", "csi", "nexmon", "rvcsi"] -categories = ["science"] -build = "build.rs" -links = "rvcsi_nexmon_shim" - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -thiserror = { workspace = true } - -[build-dependencies] -cc = { workspace = true } diff --git a/v2/crates/rvcsi-adapter-nexmon/build.rs b/v2/crates/rvcsi-adapter-nexmon/build.rs deleted file mode 100644 index 9f05985aee..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/build.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Compiles the isolated napi-c shim (`native/rvcsi_nexmon_shim.c`) into a -//! static library linked into `rvcsi-adapter-nexmon`. This is the only place -//! the rvCSI runtime invokes a C compiler (ADR-095 D2, ADR-096). - -fn main() { - println!("cargo:rerun-if-changed=native/rvcsi_nexmon_shim.c"); - println!("cargo:rerun-if-changed=native/rvcsi_nexmon_shim.h"); - - cc::Build::new() - .file("native/rvcsi_nexmon_shim.c") - .include("native") - .warnings(true) - .extra_warnings(true) - // The shim is allocation-free and freestanding-ish; keep it tight. - .flag_if_supported("-std=c11") - .flag_if_supported("-fno-strict-aliasing") - .compile("rvcsi_nexmon_shim"); -} diff --git a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c deleted file mode 100644 index 39caa0cd58..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.c +++ /dev/null @@ -1,313 +0,0 @@ -/* - * rvCSI — Nexmon CSI compatibility shim implementation (napi-c layer). - * 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 against the caller-supplied length; nothing here can - * scribble outside caller buffers, and nothing here panics or aborts. - */ -#include "rvcsi_nexmon_shim.h" - -#include - -#define RVCSI_NX_ABI 0x00010001u /* major.minor = 1.1 (added the nexmon_csi UDP entry points) */ - -/* ---- little-endian load/store helpers (portable, no aliasing UB) ---- */ - -static uint16_t ld_u16(const uint8_t *p) { - return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); -} -static uint32_t ld_u32(const uint8_t *p) { - return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | - ((uint32_t)p[3] << 24); -} -static uint64_t ld_u64(const uint8_t *p) { - return (uint64_t)ld_u32(p) | ((uint64_t)ld_u32(p + 4) << 32); -} -static int16_t ld_i16(const uint8_t *p) { return (int16_t)ld_u16(p); } - -static void st_u16(uint8_t *p, uint16_t v) { - p[0] = (uint8_t)(v & 0xFF); - p[1] = (uint8_t)((v >> 8) & 0xFF); -} -static void st_u32(uint8_t *p, uint32_t v) { - p[0] = (uint8_t)(v & 0xFF); - p[1] = (uint8_t)((v >> 8) & 0xFF); - p[2] = (uint8_t)((v >> 16) & 0xFF); - p[3] = (uint8_t)((v >> 24) & 0xFF); -} -static void st_u64(uint8_t *p, uint64_t v) { - st_u32(p, (uint32_t)(v & 0xFFFFFFFFu)); - st_u32(p + 4, (uint32_t)((v >> 32) & 0xFFFFFFFFu)); -} -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 (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; - 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) { - switch (code) { - case RVCSI_NX_OK: return "ok"; - case RVCSI_NX_ERR_TOO_SHORT: return "buffer too short for header"; - case RVCSI_NX_ERR_BAD_MAGIC: return "bad magic (not an rvCSI Nexmon record)"; - case RVCSI_NX_ERR_BAD_VERSION: return "unsupported record version"; - case RVCSI_NX_ERR_CAPACITY: return "output buffer too small for subcarrier count"; - case RVCSI_NX_ERR_TRUNCATED: return "buffer shorter than the declared record"; - 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"; - } -} - -/* ===== 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; - if (ld_u32(buf) != RVCSI_NX_MAGIC) return -RVCSI_NX_ERR_BAD_MAGIC; - if (buf[4] != (uint8_t)RVCSI_NX_VERSION) return -RVCSI_NX_ERR_BAD_VERSION; - uint16_t n = ld_u16(buf + 6); - if (n == 0) return -RVCSI_NX_ERR_ZERO_SUBCARRIERS; - if (n > RVCSI_NX_MAX_SUBCARRIERS) return -RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS; - size_t total = (size_t)RVCSI_NX_HEADER_BYTES + (size_t)n * 4u; - if (len < total) return -RVCSI_NX_ERR_TRUNCATED; - *out_n = n; - *out_total = total; - return 0; -} - -size_t rvcsi_nx_record_len(const uint8_t *buf, size_t len) { - if (buf == NULL) return 0; - uint16_t n; - size_t total; - if (validate_header(buf, len, &n, &total) < 0) return 0; - return total; -} - -int rvcsi_nx_parse_record(const uint8_t *buf, size_t len, RvcsiNxMeta *meta, - float *i_out, float *q_out, size_t cap) { - if (buf == NULL || meta == NULL || i_out == NULL || q_out == NULL) - return RVCSI_NX_ERR_NULL_ARG; - - uint16_t n; - size_t total; - int rc = validate_header(buf, len, &n, &total); - if (rc < 0) return -rc; - if ((size_t)n > cap) return RVCSI_NX_ERR_CAPACITY; - - uint8_t flags = buf[5]; - meta->subcarrier_count = n; - meta->channel = ld_u16(buf + 10); - meta->bandwidth_mhz = ld_u16(buf + 12); - meta->rssi_dbm = - (flags & RVCSI_NX_FLAG_RSSI) ? (int16_t)(int8_t)buf[8] : RVCSI_NX_ABSENT_I16; - meta->noise_floor_dbm = - (flags & RVCSI_NX_FLAG_NOISE) ? (int16_t)(int8_t)buf[9] : RVCSI_NX_ABSENT_I16; - meta->timestamp_ns = ld_u64(buf + 16); - - const uint8_t *p = buf + RVCSI_NX_HEADER_BYTES; - for (uint16_t k = 0; k < n; ++k) { - i_out[k] = q88_to_f(ld_i16(p)); - q_out[k] = q88_to_f(ld_i16(p + 2)); - p += 4; - } - return RVCSI_NX_OK; -} - -size_t rvcsi_nx_write_record(uint8_t *buf, size_t cap, const RvcsiNxMeta *meta, - const float *i_in, const float *q_in) { - if (buf == NULL || meta == NULL || i_in == NULL || q_in == NULL) return 0; - uint16_t n = meta->subcarrier_count; - if (n == 0 || n > RVCSI_NX_MAX_SUBCARRIERS) return 0; - size_t total = (size_t)RVCSI_NX_HEADER_BYTES + (size_t)n * 4u; - if (cap < total) return 0; - - memset(buf, 0, RVCSI_NX_HEADER_BYTES); - st_u32(buf, RVCSI_NX_MAGIC); - buf[4] = (uint8_t)RVCSI_NX_VERSION; - uint8_t flags = 0; - if (meta->rssi_dbm != RVCSI_NX_ABSENT_I16) flags |= RVCSI_NX_FLAG_RSSI; - if (meta->noise_floor_dbm != RVCSI_NX_ABSENT_I16) flags |= RVCSI_NX_FLAG_NOISE; - buf[5] = flags; - st_u16(buf + 6, n); - buf[8] = (uint8_t)(int8_t)((flags & RVCSI_NX_FLAG_RSSI) ? meta->rssi_dbm : 0); - buf[9] = (uint8_t)(int8_t)((flags & RVCSI_NX_FLAG_NOISE) ? meta->noise_floor_dbm : 0); - st_u16(buf + 10, meta->channel); - st_u16(buf + 12, meta->bandwidth_mhz); - st_u16(buf + 14, 0); - st_u64(buf + 16, meta->timestamp_ns); - - uint8_t *p = buf + RVCSI_NX_HEADER_BYTES; - for (uint16_t k = 0; k < n; ++k) { - st_i16(p, f_to_q88(i_in[k])); - st_i16(p + 2, f_to_q88(q_in[k])); - p += 4; - } - return total; -} - -/* ===== real nexmon_csi UDP payload (format 2) ========================= */ - -/* Map a subcarrier (FFT) count to a bandwidth in MHz, per the standard nexmon - * exports: 64->20, 128->40, 256->80, 512->160 (and the half-bands 32->10, - * 16->5). Returns 0 if `nsub` doesn't look like one of those. */ -static uint16_t bw_from_nsub(uint16_t nsub) { - switch (nsub) { - case 16: return 5; - case 32: return 10; - case 64: return 20; - case 128: return 40; - case 256: return 80; - case 512: return 160; - default: return 0; - } -} - -/* Broadcom d11ac chanspec bandwidth field (bits [13:11]) -> MHz. */ -static uint16_t bw_from_chanspec(uint16_t chanspec) { - switch ((chanspec >> 11) & 0x7u) { - case 2: return 20; - case 3: return 40; - case 4: return 80; - case 5: return 160; - case 6: return 80; /* 80+80: report the per-segment width */ - default: return 0; - } -} - -void rvcsi_nx_decode_chanspec(uint16_t chanspec, uint16_t *out_channel, - uint16_t *out_bw_mhz, uint8_t *out_is_5ghz) { - uint16_t channel = (uint16_t)(chanspec & 0x00FFu); - uint16_t bw = bw_from_chanspec(chanspec); - /* Band bits [15:14]: d11ac 5 GHz == 0b11. Cross-check with the channel number - * for robustness against older chanspec encodings. */ - uint8_t band_is_5ghz = (((chanspec >> 14) & 0x3u) == 0x3u) ? 1u : 0u; - if (!band_is_5ghz && channel > 14u) band_is_5ghz = 1u; - if (band_is_5ghz && channel >= 1u && channel <= 13u && bw == 20u) { - /* almost certainly a 2.4 GHz control channel mislabeled by an old encoding */ - band_is_5ghz = 0u; - } - if (out_channel) *out_channel = channel; - if (out_bw_mhz) *out_bw_mhz = bw; - if (out_is_5ghz) *out_is_5ghz = band_is_5ghz; -} - -/* Validate + parse the 18-byte header; on success returns N (subcarrier count) - * and fills *out. On failure returns a negative RvcsiNxError. */ -static int parse_nexmon_header(const uint8_t *payload, size_t len, - RvcsiNxUdpHeader *out, uint16_t *out_n) { - if (payload == NULL || out == NULL) return -RVCSI_NX_ERR_NULL_ARG; - if (len < (size_t)RVCSI_NX_NEXMON_HDR_BYTES) return -RVCSI_NX_ERR_TOO_SHORT; - if (ld_u16(payload) != RVCSI_NX_NEXMON_MAGIC) return -RVCSI_NX_ERR_BAD_NEXMON_MAGIC; - - size_t csi_bytes = len - (size_t)RVCSI_NX_NEXMON_HDR_BYTES; - if (csi_bytes == 0u || (csi_bytes % 4u) != 0u) return -RVCSI_NX_ERR_BAD_CSI_LEN; - size_t nsub = csi_bytes / 4u; - if (nsub > RVCSI_NX_MAX_SUBCARRIERS) return -RVCSI_NX_ERR_TOO_MANY_SUBCARRIERS; - - uint16_t core_stream = ld_u16(payload + 12); - uint16_t chanspec = ld_u16(payload + 14); - - memset(out, 0, sizeof(*out)); - out->rssi_dbm = (int16_t)(int8_t)payload[2]; - out->fctl = payload[3]; - memcpy(out->src_mac, payload + 4, 6); - out->seq_cnt = ld_u16(payload + 10); - out->core = (uint16_t)(core_stream & 0x7u); - out->spatial_stream = (uint16_t)((core_stream >> 3) & 0x7u); - out->chanspec = chanspec; - out->chip_ver = ld_u16(payload + 16); - rvcsi_nx_decode_chanspec(chanspec, &out->channel, &out->bandwidth_mhz, &out->is_5ghz); - out->subcarrier_count = (uint16_t)nsub; - /* Prefer the FFT-derived bandwidth when the chanspec bits are missing/odd. */ - { - uint16_t bw_n = bw_from_nsub((uint16_t)nsub); - if (bw_n != 0u) out->bandwidth_mhz = bw_n; - } - *out_n = (uint16_t)nsub; - return 0; -} - -int rvcsi_nx_csi_udp_header(const uint8_t *payload, size_t len, - RvcsiNxUdpHeader *out) { - uint16_t n; - int rc = parse_nexmon_header(payload, len, out, &n); - return (rc < 0) ? -rc : RVCSI_NX_OK; -} - -int rvcsi_nx_csi_udp_decode(const uint8_t *payload, size_t len, int csi_format, - RvcsiNxUdpHeader *hdr_out, RvcsiNxMeta *meta, - float *i_out, float *q_out, size_t cap) { - if (meta == NULL || i_out == NULL || q_out == NULL) return RVCSI_NX_ERR_NULL_ARG; - if (csi_format != RVCSI_NX_CSI_FMT_INT16_IQ) return RVCSI_NX_ERR_UNKNOWN_FORMAT; - - RvcsiNxUdpHeader hdr; - uint16_t n; - int rc = parse_nexmon_header(payload, len, &hdr, &n); - if (rc < 0) return -rc; - if ((size_t)n > cap) return RVCSI_NX_ERR_CAPACITY; - - meta->subcarrier_count = n; - meta->channel = hdr.channel; - meta->bandwidth_mhz = hdr.bandwidth_mhz; - meta->rssi_dbm = hdr.rssi_dbm; /* always present in the nexmon header */ - meta->noise_floor_dbm = RVCSI_NX_ABSENT_I16; /* not carried by nexmon_csi */ - meta->timestamp_ns = 0u; /* the caller stamps this from the pcap packet time */ - - const uint8_t *p = payload + RVCSI_NX_NEXMON_HDR_BYTES; - for (uint16_t k = 0; k < n; ++k) { - i_out[k] = (float)ld_i16(p); /* real, raw int16 count */ - q_out[k] = (float)ld_i16(p + 2); /* imag, raw int16 count */ - p += 4; - } - if (hdr_out) *hdr_out = hdr; - return RVCSI_NX_OK; -} - -size_t rvcsi_nx_csi_udp_write(uint8_t *buf, size_t cap, const RvcsiNxUdpHeader *hdr, - uint16_t subcarrier_count, const float *i_in, - const float *q_in) { - if (buf == NULL || hdr == NULL || i_in == NULL || q_in == NULL) return 0; - if (subcarrier_count == 0u || subcarrier_count > RVCSI_NX_MAX_SUBCARRIERS) return 0; - size_t total = (size_t)RVCSI_NX_NEXMON_HDR_BYTES + (size_t)subcarrier_count * 4u; - if (cap < total) return 0; - - memset(buf, 0, RVCSI_NX_NEXMON_HDR_BYTES); - st_u16(buf, RVCSI_NX_NEXMON_MAGIC); - buf[2] = (uint8_t)(int8_t)hdr->rssi_dbm; - buf[3] = hdr->fctl; - memcpy(buf + 4, hdr->src_mac, 6); - st_u16(buf + 10, hdr->seq_cnt); - st_u16(buf + 12, (uint16_t)((hdr->core & 0x7u) | ((hdr->spatial_stream & 0x7u) << 3))); - st_u16(buf + 14, hdr->chanspec); - st_u16(buf + 16, hdr->chip_ver); - - uint8_t *p = buf + RVCSI_NX_NEXMON_HDR_BYTES; - for (uint16_t k = 0; k < subcarrier_count; ++k) { - st_i16(p, f_to_i16_sat(i_in[k])); - st_i16(p + 2, f_to_i16_sat(q_in[k])); - p += 4; - } - return total; -} diff --git a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h b/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h deleted file mode 100644 index 1f58c39533..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/native/rvcsi_nexmon_shim.h +++ /dev/null @@ -1,186 +0,0 @@ -/* - * rvCSI — Nexmon CSI compatibility shim (napi-c layer, ADR-095 D2, ADR-096). - * - * 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. - * - * 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 - * 6 2 subcarrier_count N (1 .. RVCSI_NX_MAX_SUBCARRIERS) - * 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), interleaved, fixed-point Q8.8 - * total = 24 + 4*N bytes; stored int16 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. 0x4345 = BCM43455c0) - * 18 ... CSI: nsub complex samples; for RVCSI_NX_CSI_FMT_INT16_IQ that is - * 4*nsub bytes = nsub pairs of int16 LE (real, imag), raw counts. - * nsub is derived from the payload length: nsub = (len - 18) / 4. - */ -#ifndef RVCSI_NEXMON_SHIM_H -#define RVCSI_NEXMON_SHIM_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#define RVCSI_NX_MAGIC 0x52564E58u /* 'R','V','N','X' little-endian */ -#define RVCSI_NX_VERSION 1 -#define RVCSI_NX_HEADER_BYTES 24 -#define RVCSI_NX_MAX_SUBCARRIERS 2048 -#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 (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, /* 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 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_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 - * caller-provided float arrays). */ -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 */ - uint64_t timestamp_ns; -} RvcsiNxMeta; - -/* 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 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 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 (`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 -} -#endif - -#endif /* RVCSI_NEXMON_SHIM_H */ diff --git a/v2/crates/rvcsi-adapter-nexmon/src/chips.rs b/v2/crates/rvcsi-adapter-nexmon/src/chips.rs deleted file mode 100644 index 192c9a2724..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/src/chips.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! The Nexmon-supported Broadcom chip registry and Raspberry Pi model map -//! (ADR-095 D15, ADR-096) — including the **Raspberry Pi 5**. -//! -//! nexmon_csi runs on a handful of patched Broadcom/Cypress chips. This module -//! names them ([`NexmonChip`]), maps Raspberry Pi models to their chip -//! ([`RaspberryPiModel`]), resolves the on-the-wire `chip_ver` word back to a -//! chip (best-effort — the raw value is always preserved), and builds a -//! [`rvcsi_core::AdapterProfile`] (supported channels / bandwidths / expected -//! subcarrier counts) for each — so `validate_frame` can bound CSI frames -//! against the device that produced them. -//! -//! The Raspberry Pi 5 carries the same **CYW43455 (BCM43455c0)** 802.11ac -//! wireless as the Pi 3B+ / Pi 4 / Pi 400 — the chip with the most mature -//! nexmon_csi support — so Pi 5 CSI captures use the [`NexmonChip::Bcm43455c0`] -//! profile (20/40/80 MHz, 64/128/256 subcarriers, 2.4 + 5 GHz). The chip is also -//! auto-detected at runtime from each frame's `chip_ver` (see -//! [`crate::NexmonPcapAdapter`]). - -use rvcsi_core::{AdapterKind, AdapterProfile}; - -/// A Broadcom/Cypress WiFi chip nexmon_csi is known to run on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum NexmonChip { - /// BCM43455c0 / CYW43455 — 802.11ac, 2.4 + 5 GHz, 20/40/80 MHz. The - /// flagship nexmon_csi target: **Raspberry Pi 3B+, Pi 4, Pi 400 and Pi 5**, - /// plus the Pi Zero W. Modern int16 I/Q CSI export. - Bcm43455c0, - /// BCM43436b0 — 802.11n, 2.4 GHz only, 20/40 MHz. Raspberry Pi Zero 2 W. - Bcm43436b0, - /// BCM4366c0 — 802.11ac, 2.4 + 5 GHz, up to 80 MHz. ASUS RT-AC86U. Modern int16 export. - Bcm4366c0, - /// BCM4375b1 — 802.11ax-class, 2.4 + 5 GHz. Some Samsung Galaxy S10/S20. - Bcm4375b1, - /// BCM4358 — 802.11ac. Nexus 6P (and similar). Some firmwares use the legacy - /// packed-float CSI export (see [`NexmonChip::uses_int16_iq`]). - Bcm4358, - /// BCM4339 — 802.11ac. Nexus 5. Legacy packed-float CSI export. - Bcm4339, - /// A chip we don't recognise — the raw `chip_ver` word from the packet. - Unknown { - /// The `chip_ver` word as it appeared on the wire. - chip_ver: u16, - }, -} - -impl NexmonChip { - /// Stable lower-case slug (`"bcm43455c0"`, `"bcm4366c0"`, ...; `"unknown:0xNNNN"` for [`NexmonChip::Unknown`]). - pub fn slug(self) -> String { - match self { - NexmonChip::Bcm43455c0 => "bcm43455c0".to_string(), - NexmonChip::Bcm43436b0 => "bcm43436b0".to_string(), - NexmonChip::Bcm4366c0 => "bcm4366c0".to_string(), - NexmonChip::Bcm4375b1 => "bcm4375b1".to_string(), - NexmonChip::Bcm4358 => "bcm4358".to_string(), - NexmonChip::Bcm4339 => "bcm4339".to_string(), - NexmonChip::Unknown { chip_ver } => format!("unknown:0x{chip_ver:04x}"), - } - } - - /// A friendlier display name including a typical host device. - pub fn description(self) -> &'static str { - match self { - NexmonChip::Bcm43455c0 => "BCM43455c0 / CYW43455 (Raspberry Pi 3B+/4/400/5, Pi Zero W) — 802.11ac, 2.4+5 GHz", - NexmonChip::Bcm43436b0 => "BCM43436b0 (Raspberry Pi Zero 2 W) — 802.11n, 2.4 GHz", - NexmonChip::Bcm4366c0 => "BCM4366c0 (ASUS RT-AC86U) — 802.11ac, 2.4+5 GHz", - NexmonChip::Bcm4375b1 => "BCM4375b1 (Samsung Galaxy S10/S20) — 802.11ax-class, 2.4+5 GHz", - NexmonChip::Bcm4358 => "BCM4358 (Nexus 6P) — 802.11ac", - NexmonChip::Bcm4339 => "BCM4339 (Nexus 5) — 802.11ac", - NexmonChip::Unknown { .. } => "unknown Broadcom/Cypress chip", - } - } - - /// Whether this chip's nexmon_csi firmware exports CSI in the modern int16 - /// LE I/Q format ([`crate::NEXMON_CSI_FMT_INT16_IQ`]). The BCM4339 and some - /// BCM4358 firmwares use the legacy *packed-float* export instead (not yet - /// implemented by the shim — see `ffi::NEXMON_CSI_FMT_INT16_IQ`). - pub fn uses_int16_iq(self) -> bool { - !matches!(self, NexmonChip::Bcm4339 | NexmonChip::Bcm4358) - } - - /// Whether the chip supports the 5 GHz band (and therefore 802.11ac wide channels). - pub fn dual_band(self) -> bool { - matches!( - self, - NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4375b1 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339 - ) - } - - /// Resolve a `chip_ver` word from a nexmon_csi UDP header to a chip - /// (best-effort — matches the Broadcom chip-ID convention `0x4345` = BCM4345 - /// family, `0x4339`, `0x4358`, `0x4366`, `0x4375`; anything else is - /// [`NexmonChip::Unknown`]). The c0/b0 revision suffix isn't carried by this - /// word; the int16-vs-packed-float export distinction is handled separately. - pub fn from_chip_ver(chip_ver: u16) -> NexmonChip { - match chip_ver { - 0x4345 => NexmonChip::Bcm43455c0, - 0x4339 => NexmonChip::Bcm4339, - 0x4358 => NexmonChip::Bcm4358, - 0x4366 => NexmonChip::Bcm4366c0, - 0x4375 => NexmonChip::Bcm4375b1, - // 43436's chip id varies by source; treat it as unknown unless we see it. - other => NexmonChip::Unknown { chip_ver: other }, - } - } - - /// Parse a chip name/slug (`"bcm43455c0"`, `"43455c0"`, `"cyw43455"`, ...). - pub fn from_slug(s: &str) -> Option { - let s = s.trim().to_ascii_lowercase(); - match s.as_str() { - "bcm43455c0" | "43455c0" | "43455" | "bcm43455" | "cyw43455" => Some(NexmonChip::Bcm43455c0), - "bcm43436b0" | "43436b0" | "43436" | "bcm43436" => Some(NexmonChip::Bcm43436b0), - "bcm4366c0" | "4366c0" | "4366" | "bcm4366" => Some(NexmonChip::Bcm4366c0), - "bcm4375b1" | "4375b1" | "4375" | "bcm4375" => Some(NexmonChip::Bcm4375b1), - "bcm4358" | "4358" => Some(NexmonChip::Bcm4358), - "bcm4339" | "4339" => Some(NexmonChip::Bcm4339), - _ => None, - } - } -} - -/// 5 GHz UNII channels (a representative set; nexmon picks a control channel via `makecsiparams`). -const FIVE_GHZ_CHANNELS: &[u16] = &[ - 36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, - 153, 157, 161, 165, -]; - -fn channels_for(chip: NexmonChip) -> Vec { - let mut v: Vec = (1..=13).collect(); - if chip.dual_band() { - v.extend_from_slice(FIVE_GHZ_CHANNELS); - } - v -} - -fn bandwidths_for(chip: NexmonChip) -> Vec { - match chip { - NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339 => vec![20, 40, 80], - NexmonChip::Bcm4375b1 => vec![20, 40, 80, 160], - NexmonChip::Bcm43436b0 => vec![20, 40], - NexmonChip::Unknown { .. } => vec![20, 40, 80], - } -} - -/// Subcarrier (FFT) count per supported bandwidth: 20→64, 40→128, 80→256, 160→512. -fn subcarrier_counts_for(chip: NexmonChip) -> Vec { - bandwidths_for(chip) - .iter() - .map(|bw| (bw / 20) * 64) - .collect() -} - -/// Build the [`rvcsi_core::AdapterProfile`] for a Nexmon chip — the channels / -/// bandwidths / expected subcarrier counts `validate_frame` will bound CSI -/// frames against, plus the live-capability flags (Nexmon supports monitor mode -/// and injection on these chips). -pub fn nexmon_adapter_profile(chip: NexmonChip) -> AdapterProfile { - AdapterProfile { - adapter_kind: AdapterKind::Nexmon, - chip: Some(chip.slug()), - firmware_version: None, - driver_version: None, - supported_channels: channels_for(chip), - supported_bandwidths_mhz: bandwidths_for(chip), - expected_subcarrier_counts: subcarrier_counts_for(chip), - supports_live_capture: true, - supports_injection: true, - supports_monitor_mode: true, - } -} - -/// Raspberry Pi models with on-board WiFi that nexmon_csi can extract CSI from. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum RaspberryPiModel { - /// Raspberry Pi 3 Model B+ — CYW43455 / BCM43455c0. - Pi3BPlus, - /// Raspberry Pi 4 Model B — CYW43455 / BCM43455c0. - Pi4, - /// Raspberry Pi 400 — CYW43455 / BCM43455c0. - Pi400, - /// **Raspberry Pi 5** — CYW43455 / BCM43455c0 (same wireless as the Pi 4). - Pi5, - /// Raspberry Pi Zero W — CYW43438? No — the Zero W uses the BCM43438 (2.4 GHz - /// only), which nexmon_csi does **not** support; included here only so callers - /// can detect and reject it. Use a Zero 2 W instead. - PiZeroW, - /// Raspberry Pi Zero 2 W — BCM43436b0 (2.4 GHz only). - PiZero2W, -} - -impl RaspberryPiModel { - /// The Broadcom/Cypress WiFi chip on this board. - pub fn nexmon_chip(self) -> NexmonChip { - match self { - RaspberryPiModel::Pi3BPlus - | RaspberryPiModel::Pi4 - | RaspberryPiModel::Pi400 - | RaspberryPiModel::Pi5 => NexmonChip::Bcm43455c0, - RaspberryPiModel::PiZero2W => NexmonChip::Bcm43436b0, - RaspberryPiModel::PiZeroW => NexmonChip::Unknown { chip_ver: 0x4343 }, // BCM43438 — not CSI-capable - } - } - - /// Whether nexmon_csi can extract CSI from this board's WiFi. - pub fn csi_supported(self) -> bool { - !matches!(self, RaspberryPiModel::PiZeroW) - } - - /// Stable slug (`"pi5"`, `"pi4"`, `"pi3b+"`, `"pi400"`, `"pizero2w"`, `"pizerow"`). - pub fn slug(self) -> &'static str { - match self { - RaspberryPiModel::Pi3BPlus => "pi3b+", - RaspberryPiModel::Pi4 => "pi4", - RaspberryPiModel::Pi400 => "pi400", - RaspberryPiModel::Pi5 => "pi5", - RaspberryPiModel::PiZeroW => "pizerow", - RaspberryPiModel::PiZero2W => "pizero2w", - } - } - - /// Parse a model slug (accepts `pi5`, `pi 5`, `rpi5`, `raspberrypi5`, `pi3b+`/`pi3bplus`, ...). - pub fn from_slug(s: &str) -> Option { - let s: String = s.trim().to_ascii_lowercase().chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect(); - let s = s.strip_prefix("raspberrypi").or_else(|| s.strip_prefix("rpi")).unwrap_or(&s); - match s { - "pi5" | "5" => Some(RaspberryPiModel::Pi5), - "pi4" | "4" | "pi4b" => Some(RaspberryPiModel::Pi4), - "pi400" | "400" => Some(RaspberryPiModel::Pi400), - "pi3b+" | "pi3bplus" | "3b+" | "3bplus" => Some(RaspberryPiModel::Pi3BPlus), - "pizero2w" | "zero2w" | "pizero2" => Some(RaspberryPiModel::PiZero2W), - "pizerow" | "zerow" => Some(RaspberryPiModel::PiZeroW), - _ => None, - } - } -} - -/// Build the [`rvcsi_core::AdapterProfile`] for a Raspberry Pi model (its -/// [`RaspberryPiModel::nexmon_chip`]'s profile, with the `chip` string tagged -/// with the model for legibility). -pub fn raspberry_pi_profile(model: RaspberryPiModel) -> AdapterProfile { - let mut p = nexmon_adapter_profile(model.nexmon_chip()); - p.chip = Some(format!("{} ({})", model.nexmon_chip().slug(), model.slug())); - p -} - -/// The full registry of Nexmon-supported chips, for `rvcsi nexmon-chips` and SDK callers. -pub fn known_chips() -> &'static [NexmonChip] { - &[ - NexmonChip::Bcm43455c0, - NexmonChip::Bcm43436b0, - NexmonChip::Bcm4366c0, - NexmonChip::Bcm4375b1, - NexmonChip::Bcm4358, - NexmonChip::Bcm4339, - ] -} - -/// The full registry of Raspberry Pi models this crate knows about. -pub fn known_pi_models() -> &'static [RaspberryPiModel] { - &[ - RaspberryPiModel::Pi5, - RaspberryPiModel::Pi4, - RaspberryPiModel::Pi400, - RaspberryPiModel::Pi3BPlus, - RaspberryPiModel::PiZero2W, - RaspberryPiModel::PiZeroW, - ] -} - -impl crate::ffi::NexmonCsiHeader { - /// Resolve this packet's chip from its `chip_ver` word (best-effort; the raw - /// `chip_ver` field is always preserved). For a Raspberry Pi 5 (or 4/400/3B+) - /// capture this returns [`NexmonChip::Bcm43455c0`]. - pub fn chip(&self) -> NexmonChip { - NexmonChip::from_chip_ver(self.chip_ver) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pi5_uses_the_same_chip_as_pi4() { - assert_eq!(RaspberryPiModel::Pi5.nexmon_chip(), NexmonChip::Bcm43455c0); - assert_eq!(RaspberryPiModel::Pi4.nexmon_chip(), NexmonChip::Bcm43455c0); - assert!(RaspberryPiModel::Pi5.csi_supported()); - let p = raspberry_pi_profile(RaspberryPiModel::Pi5); - assert_eq!(p.adapter_kind, AdapterKind::Nexmon); - assert!(p.chip.as_deref().unwrap().contains("pi5")); - assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]); - assert_eq!(p.expected_subcarrier_counts, vec![64, 128, 256]); - assert!(p.accepts_channel(36)); // 5 GHz - assert!(p.accepts_channel(6)); // 2.4 GHz - assert!(p.accepts_subcarrier_count(256)); // VHT80 - assert!(!p.accepts_subcarrier_count(57)); - assert!(p.supports_monitor_mode && p.supports_injection); - } - - #[test] - fn chip_ver_resolution_best_effort() { - assert_eq!(NexmonChip::from_chip_ver(0x4345), NexmonChip::Bcm43455c0); - assert_eq!(NexmonChip::from_chip_ver(0x4339), NexmonChip::Bcm4339); - assert_eq!(NexmonChip::from_chip_ver(0x4366), NexmonChip::Bcm4366c0); - assert!(matches!(NexmonChip::from_chip_ver(0xABCD), NexmonChip::Unknown { chip_ver: 0xABCD })); - } - - #[test] - fn chip_traits() { - assert!(NexmonChip::Bcm43455c0.uses_int16_iq()); - assert!(!NexmonChip::Bcm4339.uses_int16_iq()); - assert!(NexmonChip::Bcm43455c0.dual_band()); - assert!(!NexmonChip::Bcm43436b0.dual_band()); - assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).supported_bandwidths_mhz, vec![20, 40]); - assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).expected_subcarrier_counts, vec![64, 128]); - // unknown chip -> a permissive-ish 802.11ac default - let u = nexmon_adapter_profile(NexmonChip::Unknown { chip_ver: 0 }); - assert_eq!(u.supported_bandwidths_mhz, vec![20, 40, 80]); - } - - #[test] - fn slug_parsing() { - assert_eq!(NexmonChip::from_slug("CYW43455"), Some(NexmonChip::Bcm43455c0)); - assert_eq!(NexmonChip::from_slug("bcm4366c0"), Some(NexmonChip::Bcm4366c0)); - assert_eq!(NexmonChip::from_slug("nope"), None); - assert_eq!(RaspberryPiModel::from_slug("Pi 5"), Some(RaspberryPiModel::Pi5)); - assert_eq!(RaspberryPiModel::from_slug("raspberry-pi-5"), Some(RaspberryPiModel::Pi5)); - assert_eq!(RaspberryPiModel::from_slug("pi3bplus"), Some(RaspberryPiModel::Pi3BPlus)); - assert_eq!(RaspberryPiModel::from_slug("pi42"), None); - assert_eq!(NexmonChip::Bcm43455c0.slug(), "bcm43455c0"); - assert_eq!(RaspberryPiModel::Pi5.slug(), "pi5"); - } - - #[test] - fn registries_nonempty_and_pi5_present() { - assert!(known_chips().contains(&NexmonChip::Bcm43455c0)); - assert!(known_pi_models().contains(&RaspberryPiModel::Pi5)); - } -} diff --git a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs b/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs deleted file mode 100644 index 0656da7ae4..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/src/ffi.rs +++ /dev/null @@ -1,644 +0,0 @@ -//! Raw FFI to the napi-c shim plus safe wrappers (ADR-096). -//! -//! The C side (`native/rvcsi_nexmon_shim.c`) is allocation-free and bounds-checks -//! every read against the caller-supplied lengths. The `unsafe` here is limited -//! to: calling those C functions with correct pointers/lengths, and reading back -//! the metadata struct the C side fully initialized on `RVCSI_NX_OK`. - -use std::os::raw::c_char; - -/// Bytes in a record header (the fixed prefix before the I/Q samples). -pub const RECORD_HEADER_BYTES: usize = 24; - -/// Largest subcarrier count the shim will parse (mirrors `RVCSI_NX_MAX_SUBCARRIERS`). -pub const MAX_SUBCARRIERS: usize = 2048; - -/// Sentinel the C side uses for "metadata field absent". -const ABSENT_I16: i16 = 0x7FFF; - -#[repr(C)] -#[derive(Debug, Clone, Copy)] -struct RvcsiNxMeta { - subcarrier_count: u16, - channel: u16, - bandwidth_mhz: u16, - rssi_dbm: i16, - noise_floor_dbm: i16, - timestamp_ns: u64, -} - -extern "C" { - fn rvcsi_nx_record_len(buf: *const u8, len: usize) -> usize; - fn rvcsi_nx_parse_record( - buf: *const u8, - len: usize, - meta: *mut RvcsiNxMeta, - i_out: *mut f32, - q_out: *mut f32, - cap: usize, - ) -> i32; - fn rvcsi_nx_write_record( - buf: *mut u8, - cap: usize, - meta: *const RvcsiNxMeta, - 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 chip ID / 4358 / 4366c0 export (mirrors -/// `RVCSI_NX_CSI_FMT_INT16_IQ`). The legacy packed-float export is not yet wired. -pub const NEXMON_CSI_FMT_INT16_IQ: i32 = 0; - -/// 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. - unsafe { rvcsi_nx_abi_version() } -} - -/// Errors decoding a record (a structured view of the C error codes). -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum NexmonFfiError { - /// The C shim returned a non-zero error code. - #[error("nexmon shim error {code}: {message}")] - Shim { - /// Numeric `RvcsiNxError` code. - code: i32, - /// Static description from `rvcsi_nx_strerror`. - message: String, - }, - /// The buffer didn't even contain a parseable header / record length. - #[error("not a record (bad magic, unsupported version, or too short)")] - NotARecord, -} - -fn strerror(code: i32) -> String { - // SAFETY: rvcsi_nx_strerror always returns a non-NULL pointer to a static, - // NUL-terminated C string (see the C source); we only borrow it here. - unsafe { - let p = rvcsi_nx_strerror(code); - if p.is_null() { - return format!("error {code}"); - } - std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() - } -} - -/// A record decoded from the wire: fixed metadata + the I/Q sample vectors. -#[derive(Debug, Clone, PartialEq)] -pub struct NexmonRecord { - /// Number of subcarriers (== length of `i_values`/`q_values`). - pub subcarrier_count: u16, - /// WiFi channel number. - pub channel: u16, - /// Bandwidth in MHz. - pub bandwidth_mhz: u16, - /// RSSI in dBm, if present in the record. - pub rssi_dbm: Option, - /// Noise floor in dBm, if present. - pub noise_floor_dbm: Option, - /// Source timestamp, ns. - pub timestamp_ns: u64, - /// In-phase samples. - pub i_values: Vec, - /// Quadrature samples. - pub q_values: Vec, -} - -/// Length, in bytes, of the record starting at `buf[0]`, or `None` if `buf` -/// doesn't begin with a complete, valid record. -pub fn record_len(buf: &[u8]) -> Option { - // SAFETY: passing a valid pointer + the slice's true length; the C side - // reads at most `len` bytes and returns 0 on any problem. - let n = unsafe { rvcsi_nx_record_len(buf.as_ptr(), buf.len()) }; - if n == 0 { - None - } else { - Some(n) - } -} - -/// Decode the first record in `buf`. Returns the record and the number of bytes -/// it consumed (so callers can advance a cursor over a concatenated stream). -pub fn decode_record(buf: &[u8]) -> Result<(NexmonRecord, usize), NexmonFfiError> { - let total = record_len(buf).ok_or(NexmonFfiError::NotARecord)?; - debug_assert!(total >= RECORD_HEADER_BYTES && total <= buf.len()); - let n = (total - RECORD_HEADER_BYTES) / 4; - - 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: `buf` is valid for `buf.len()` bytes; `i_out`/`q_out` are valid - // for `n` f32s each and we pass `n` as the capacity; `meta` points to a - // fully owned, properly aligned RvcsiNxMeta. The C side writes only within - // those bounds and fully initializes `meta` on RVCSI_NX_OK. - let rc = unsafe { - rvcsi_nx_parse_record( - buf.as_ptr(), - buf.len(), - &mut meta as *mut RvcsiNxMeta, - i_out.as_mut_ptr(), - q_out.as_mut_ptr(), - n, - ) - }; - if rc != 0 { - return Err(NexmonFfiError::Shim { - code: rc, - message: strerror(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((rec, total)) -} - -/// Encode a record to bytes via the C writer (used by tests and the recorder). -pub fn encode_record(rec: &NexmonRecord) -> Result, NexmonFfiError> { - let n = rec.subcarrier_count as usize; - if n == 0 || n > MAX_SUBCARRIERS || rec.i_values.len() != n || rec.q_values.len() != n { - return Err(NexmonFfiError::Shim { - code: 6, - message: "bad subcarrier count or i/q length".to_string(), - }); - } - let meta = RvcsiNxMeta { - subcarrier_count: rec.subcarrier_count, - channel: rec.channel, - bandwidth_mhz: rec.bandwidth_mhz, - rssi_dbm: rec.rssi_dbm.unwrap_or(ABSENT_I16), - noise_floor_dbm: rec.noise_floor_dbm.unwrap_or(ABSENT_I16), - timestamp_ns: rec.timestamp_ns, - }; - let cap = RECORD_HEADER_BYTES + n * 4; - let mut buf = vec![0u8; cap]; - // SAFETY: `buf` is valid for `cap` bytes; `i_in`/`q_in` are valid for `n` - // f32s each (checked above); `meta` is a fully initialized owned struct. - let written = unsafe { - rvcsi_nx_write_record( - buf.as_mut_ptr(), - cap, - &meta as *const RvcsiNxMeta, - rec.i_values.as_ptr(), - rec.q_values.as_ptr(), - ) - }; - if written == 0 { - return Err(NexmonFfiError::Shim { - code: 4, - message: "write_record failed (capacity or argument error)".to_string(), - }); - } - debug_assert_eq!(written, cap); - buf.truncate(written); - 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. `0x4345` = BCM43455c0 chip ID). - pub chip_ver: u16, - /// Channel number decoded from the chanspec. - pub channel: u16, - /// Bandwidth (MHz) — from the FFT size when known, else the chanspec bits. - pub bandwidth_mhz: u16, - /// `true` if the band bits say 5 GHz. - pub is_5ghz: bool, - /// Subcarrier (FFT) count, `(payload_len - 18) / 4`. - pub subcarrier_count: u16, -} - -impl From for NexmonCsiHeader { - fn from(h: RvcsiNxUdpHeader) -> Self { - NexmonCsiHeader { - rssi_dbm: h.rssi_dbm, - fctl: h.fctl, - src_mac: h.src_mac, - seq_cnt: h.seq_cnt, - core: h.core, - spatial_stream: h.spatial_stream, - chanspec: h.chanspec, - chip_ver: h.chip_ver, - channel: h.channel, - bandwidth_mhz: h.bandwidth_mhz, - is_5ghz: h.is_5ghz != 0, - subcarrier_count: h.subcarrier_count, - } - } -} - -impl NexmonCsiHeader { - fn to_c(&self) -> RvcsiNxUdpHeader { - RvcsiNxUdpHeader { - rssi_dbm: self.rssi_dbm, - fctl: self.fctl, - src_mac: self.src_mac, - seq_cnt: self.seq_cnt, - core: self.core, - spatial_stream: self.spatial_stream, - chanspec: self.chanspec, - chip_ver: self.chip_ver, - channel: self.channel, - bandwidth_mhz: self.bandwidth_mhz, - is_5ghz: self.is_5ghz as u8, - subcarrier_count: self.subcarrier_count, - } - } -} - -fn check(rc: i32) -> Result<(), NexmonFfiError> { - if rc == 0 { - Ok(()) - } else { - Err(NexmonFfiError::Shim { - code: rc, - message: strerror(rc), - }) - } -} - -/// Parse just the 18-byte nexmon_csi UDP header of `payload`. -pub fn parse_nexmon_udp_header(payload: &[u8]) -> Result { - let mut hdr = RvcsiNxUdpHeader::default(); - // SAFETY: `payload` valid for `payload.len()`; `hdr` is an owned struct the - // C side only writes on RVCSI_NX_OK (and zero-initialises first). - let rc = unsafe { rvcsi_nx_csi_udp_header(payload.as_ptr(), payload.len(), &mut hdr) }; - check(rc)?; - Ok(hdr.into()) -} - -/// Fully decode a nexmon_csi UDP payload (the 18-byte header + the CSI body). -/// Returns the parsed header and a [`NexmonRecord`] whose `timestamp_ns` is `0` -/// (the caller stamps it from the pcap packet time). `csi_format` is currently -/// only [`NEXMON_CSI_FMT_INT16_IQ`]. -pub fn decode_nexmon_udp( - payload: &[u8], - csi_format: i32, -) -> Result<(NexmonCsiHeader, NexmonRecord), NexmonFfiError> { - // First parse the header so we know `nsub` (and reject bad packets early). - let header = parse_nexmon_udp_header(payload)?; - let n = header.subcarrier_count as usize; - if n == 0 || n > MAX_SUBCARRIERS { - return Err(NexmonFfiError::Shim { - code: 7, - message: "subcarrier count out of range".to_string(), - }); - } - let mut hdr = RvcsiNxUdpHeader::default(); - let mut meta = RvcsiNxMeta { - subcarrier_count: 0, - channel: 0, - bandwidth_mhz: 0, - rssi_dbm: 0, - noise_floor_dbm: 0, - timestamp_ns: 0, - }; - let mut i_out = vec![0.0f32; n]; - let mut q_out = vec![0.0f32; n]; - // SAFETY: `payload` valid for its length; `i_out`/`q_out` valid for `n` - // f32s each (we pass `n` as the capacity); `hdr`/`meta` are owned structs - // the C side fully initialises on RVCSI_NX_OK and writes nothing else. - let rc = unsafe { - rvcsi_nx_csi_udp_decode( - payload.as_ptr(), - payload.len(), - csi_format, - &mut hdr, - &mut meta, - i_out.as_mut_ptr(), - q_out.as_mut_ptr(), - n, - ) - }; - check(rc)?; - debug_assert_eq!(meta.subcarrier_count as usize, n); - let rec = NexmonRecord { - subcarrier_count: meta.subcarrier_count, - channel: meta.channel, - bandwidth_mhz: meta.bandwidth_mhz, - rssi_dbm: (meta.rssi_dbm != ABSENT_I16).then_some(meta.rssi_dbm), - noise_floor_dbm: (meta.noise_floor_dbm != ABSENT_I16).then_some(meta.noise_floor_dbm), - timestamp_ns: meta.timestamp_ns, - i_values: i_out, - q_values: q_out, - }; - Ok((NexmonCsiHeader::from(hdr), rec)) -} - -/// Serialize a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q body) -/// — used by tests and the synthetic Nexmon source. `i_values`/`q_values` are the -/// raw int16-valued samples (clamped to the int16 range on write); their length -/// must equal `header.subcarrier_count`. -pub fn encode_nexmon_udp( - header: &NexmonCsiHeader, - i_values: &[f32], - q_values: &[f32], -) -> Result, NexmonFfiError> { - let n = header.subcarrier_count as usize; - if n == 0 || n > MAX_SUBCARRIERS || i_values.len() != n || q_values.len() != n { - return Err(NexmonFfiError::Shim { - code: 6, - message: "bad subcarrier count or i/q length".to_string(), - }); - } - let c_hdr = header.to_c(); - let cap = NEXMON_HEADER_BYTES + n * 4; - let mut buf = vec![0u8; cap]; - // SAFETY: `buf` valid for `cap` bytes; `i_in`/`q_in` valid for `n` f32s each - // (checked above); `c_hdr` is a fully initialised owned struct. - let written = unsafe { - rvcsi_nx_csi_udp_write( - buf.as_mut_ptr(), - cap, - &c_hdr as *const RvcsiNxUdpHeader, - header.subcarrier_count, - i_values.as_ptr(), - q_values.as_ptr(), - ) - }; - if written == 0 { - return Err(NexmonFfiError::Shim { - code: 4, - message: "csi_udp_write failed (capacity or argument error)".to_string(), - }); - } - debug_assert_eq!(written, cap); - buf.truncate(written); - Ok(buf) -} - -/// Bytes in the nexmon_csi UDP header (mirrors `RVCSI_NX_NEXMON_HDR_BYTES`). -pub const NEXMON_HEADER_BYTES: usize = 18; - -/// nexmon_csi UDP payload magic (`0x1111`, the first two LE bytes of the header). -pub const NEXMON_MAGIC: u16 = 0x1111; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_buffer_is_not_a_record() { - assert!(record_len(&[]).is_none()); - assert_eq!(decode_record(&[]).unwrap_err(), NexmonFfiError::NotARecord); - } - - #[test] - fn encode_then_decode_is_identity() { - let rec = NexmonRecord { - subcarrier_count: 4, - channel: 11, - bandwidth_mhz: 20, - rssi_dbm: Some(-70), - noise_floor_dbm: None, - timestamp_ns: 999, - i_values: vec![1.0, -2.0, 0.0, 3.5], - q_values: vec![0.5, 0.25, -1.0, 0.0], - }; - let bytes = encode_record(&rec).unwrap(); - assert_eq!(bytes.len(), RECORD_HEADER_BYTES + 16); - let (back, consumed) = decode_record(&bytes).unwrap(); - assert_eq!(consumed, bytes.len()); - assert_eq!(back, rec); - } - - #[test] - fn rejects_zero_subcarriers_on_encode() { - let rec = NexmonRecord { - subcarrier_count: 0, - channel: 1, - bandwidth_mhz: 20, - rssi_dbm: None, - noise_floor_dbm: None, - timestamp_ns: 0, - i_values: vec![], - q_values: vec![], - }; - 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: 0x4345, // BCM43455c0 chip ID - channel: 0, // filled by decode - bandwidth_mhz: 0, // filled by decode - is_5ghz: false, // filled by decode - subcarrier_count: nsub, - } - } - - #[test] - fn nexmon_udp_roundtrip_and_metadata() { - let nsub = 64u16; // 20 MHz - let chanspec = 0x1000u16 | 6; // 2.4G, ch6, 20 MHz - let hdr = synth_header(-58, chanspec, nsub); - let i: Vec = (0..nsub).map(|k| (k as i16 - 32) as f32).collect(); - let q: Vec = (0..nsub).map(|k| -(k as i16) as f32 + 5.0).collect(); - let payload = encode_nexmon_udp(&hdr, &i, &q).expect("encode"); - assert_eq!(payload.len(), NEXMON_HEADER_BYTES + (nsub as usize) * 4); - assert_eq!(u16::from_le_bytes([payload[0], payload[1]]), NEXMON_MAGIC); - - // header-only parse - let h = parse_nexmon_udp_header(&payload).expect("hdr"); - assert_eq!(h.rssi_dbm, -58); - assert_eq!(h.fctl, 0x08); - assert_eq!(h.src_mac, [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]); - assert_eq!(h.seq_cnt, 0x1234); - assert_eq!(h.core, 1); - assert_eq!(h.chanspec, chanspec); - assert_eq!(h.chip_ver, 0x4345); - assert_eq!(h.channel, 6); - assert_eq!(h.bandwidth_mhz, 20); - assert!(!h.is_5ghz); - assert_eq!(h.subcarrier_count, nsub); - - // full decode — raw int16 counts come back exactly - let (h2, rec) = decode_nexmon_udp(&payload, NEXMON_CSI_FMT_INT16_IQ).expect("decode"); - assert_eq!(h2, h); - assert_eq!(rec.subcarrier_count, nsub); - assert_eq!(rec.channel, 6); - assert_eq!(rec.bandwidth_mhz, 20); - assert_eq!(rec.rssi_dbm, Some(-58)); - assert_eq!(rec.timestamp_ns, 0); // caller stamps from pcap - assert_eq!(rec.i_values.len(), nsub as usize); - assert_eq!(rec.i_values[0], -32.0); - assert_eq!(rec.i_values[33], 1.0); - assert_eq!(rec.q_values[0], 5.0); - assert_eq!(rec.q_values[10], -5.0); - } - - #[test] - fn nexmon_udp_rejects_bad_magic_and_lengths() { - let hdr = synth_header(-60, 0x1000 | 11, 64); - let i = vec![1.0f32; 64]; - let q = vec![0.0f32; 64]; - let mut payload = encode_nexmon_udp(&hdr, &i, &q).unwrap(); - // bad magic - payload[0] = 0xFF; - assert!(parse_nexmon_udp_header(&payload).is_err()); - payload[0] = 0x11; - // too short for header - assert!(parse_nexmon_udp_header(&payload[..10]).is_err()); - // CSI body not a multiple of 4 - assert!(parse_nexmon_udp_header(&payload[..NEXMON_HEADER_BYTES + 3]).is_err()); - // zero-length CSI body - assert!(parse_nexmon_udp_header(&payload[..NEXMON_HEADER_BYTES]).is_err()); - // unknown CSI format - assert!(decode_nexmon_udp(&payload, 99).is_err()); - } - - #[test] - fn nexmon_udp_80mhz_and_160mhz_bandwidths() { - for (nsub, want_bw) in [(256u16, 80u16), (512u16, 160u16), (128u16, 40u16)] { - let hdr = synth_header(-55, 0xc000 | 0x2000 | 36, nsub); - let i = vec![0.0f32; nsub as usize]; - let q = vec![0.0f32; nsub as usize]; - let payload = encode_nexmon_udp(&hdr, &i, &q).unwrap(); - let h = parse_nexmon_udp_header(&payload).unwrap(); - assert_eq!(h.bandwidth_mhz, want_bw, "nsub={nsub}"); - assert!(h.is_5ghz); - assert_eq!(h.channel, 36); - } - } -} diff --git a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs b/v2/crates/rvcsi-adapter-nexmon/src/lib.rs deleted file mode 100644 index 3fef7ecbbd..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/src/lib.rs +++ /dev/null @@ -1,677 +0,0 @@ -//! # rvCSI Nexmon adapter (napi-c boundary) -//! -//! Wraps the isolated C shim in `native/rvcsi_nexmon_shim.{c,h}` — the only C -//! in the rvCSI runtime (ADR-095 D2, ADR-096). The shim parses a compact, -//! byte-defined "rvCSI Nexmon record" (a normalized superset of the nexmon_csi -//! UDP payload). Everything above [`ffi`] is safe Rust; all `unsafe` is -//! confined to this crate, bounds-checked on the C side, and documented. -//! -//! 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)] - -use std::path::Path; - -use rvcsi_core::{ - AdapterKind, AdapterProfile, CsiFrame, CsiSource, RvcsiError, SessionId, SourceHealth, SourceId, -}; - -pub mod chips; -pub mod ffi; -pub mod pcap; - -pub use chips::{ - known_chips, known_pi_models, nexmon_adapter_profile, raspberry_pi_profile, NexmonChip, - RaspberryPiModel, -}; -pub use ffi::{ - decode_chanspec, decode_nexmon_udp, decode_record, encode_nexmon_udp, encode_record, - parse_nexmon_udp_header, shim_abi_version, DecodedChanspec, NexmonCsiHeader, NexmonFfiError, - NexmonRecord, NEXMON_CSI_FMT_INT16_IQ, NEXMON_HEADER_BYTES, NEXMON_MAGIC, RECORD_HEADER_BYTES, -}; -pub use pcap::{ - extract_udp_payload, synthetic_udp_pcap, PcapPacket, PcapReader, LINKTYPE_ETHERNET, - LINKTYPE_IPV4, LINKTYPE_LINUX_SLL, LINKTYPE_RAW, NEXMON_DEFAULT_PORT, PCAP_MAGIC_NS, - PCAP_MAGIC_US, -}; - -/// Build a synthetic nexmon_csi `.pcap` (LE/µs/Ethernet) from -/// `(timestamp_ns, NexmonCsiHeader, i_values, q_values)` entries, sending every -/// CSI packet to UDP port `port`. Useful for tests, examples and the `rvcsi` -/// self-tests; real captures come off a Pi running patched firmware. -pub fn synthetic_nexmon_pcap( - frames: &[(u64, NexmonCsiHeader, Vec, Vec)], - port: u16, -) -> Result, NexmonFfiError> { - let payloads: Vec> = frames - .iter() - .map(|(_, h, i, q)| encode_nexmon_udp(h, i, q)) - .collect::>()?; - let refs: Vec<(u64, u16, &[u8])> = frames - .iter() - .zip(payloads.iter()) - .map(|((ts, ..), p)| (*ts, port, p.as_slice())) - .collect(); - Ok(pcap::synthetic_udp_pcap(&refs)) -} - -/// A [`CsiSource`] that replays a buffer of rvCSI Nexmon records. -/// -/// Records are decoded lazily by [`CsiSource::next_frame`]; an exhausted buffer -/// returns `Ok(None)`. Frames are produced with `validation = Pending`. -pub struct NexmonAdapter { - source_id: SourceId, - session_id: SessionId, - profile: AdapterProfile, - buf: Vec, - cursor: usize, - next_frame_id: u64, - delivered: u64, - rejected: u64, - status: Option, -} - -impl NexmonAdapter { - /// Build an adapter from a buffer of concatenated records. - pub fn from_bytes( - source_id: impl Into, - session_id: SessionId, - bytes: impl Into>, - ) -> Self { - // ABI guard — the static lib we linked must match the header we coded against. - debug_assert_eq!( - shim_abi_version() >> 16, - 1, - "rvcsi_nexmon_shim major ABI mismatch" - ); - NexmonAdapter { - source_id: source_id.into(), - session_id, - profile: AdapterProfile::nexmon_default(), - buf: bytes.into(), - cursor: 0, - next_frame_id: 0, - delivered: 0, - rejected: 0, - status: None, - } - } - - /// Build an adapter from a capture file of concatenated records. - pub fn from_file( - source_id: impl Into, - session_id: SessionId, - path: impl AsRef, - ) -> Result { - let bytes = std::fs::read(path)?; - Ok(Self::from_bytes(source_id, session_id, bytes)) - } - - /// Override the capability profile (e.g. when the firmware version is known). - pub fn with_profile(mut self, profile: AdapterProfile) -> Self { - self.profile = profile; - self - } - - /// Decode every record in `bytes` into `Pending` frames in one shot. - /// - /// Stops at the first malformed record and returns what was decoded so far - /// alongside the error (`Err` carries the partial vec via the message; use - /// [`NexmonAdapter`] iteration if you need to inspect partial progress). - pub fn frames_from_bytes( - source_id: impl Into, - session_id: SessionId, - bytes: &[u8], - ) -> Result, RvcsiError> { - let mut adapter = NexmonAdapter::from_bytes(source_id, session_id, bytes.to_vec()); - let mut out = Vec::new(); - while let Some(frame) = adapter.next_frame()? { - out.push(frame); - } - Ok(out) - } - - fn record_to_frame(&mut self, rec: NexmonRecord) -> CsiFrame { - let fid = self.next_frame_id; - self.next_frame_id += 1; - let mut frame = CsiFrame::from_iq( - fid.into(), - self.session_id, - self.source_id.clone(), - AdapterKind::Nexmon, - rec.timestamp_ns, - rec.channel, - rec.bandwidth_mhz, - rec.i_values, - rec.q_values, - ); - if let Some(r) = rec.rssi_dbm { - frame.rssi_dbm = Some(r); - } - if let Some(n) = rec.noise_floor_dbm { - frame.noise_floor_dbm = Some(n); - } - frame - } -} - -impl CsiSource for NexmonAdapter { - fn profile(&self) -> &AdapterProfile { - &self.profile - } - - fn session_id(&self) -> SessionId { - self.session_id - } - - fn source_id(&self) -> &SourceId { - &self.source_id - } - - fn next_frame(&mut self) -> Result, RvcsiError> { - if self.cursor >= self.buf.len() { - return Ok(None); - } - let remaining = &self.buf[self.cursor..]; - match decode_record(remaining) { - Ok((rec, consumed)) => { - self.cursor += consumed; - self.delivered += 1; - Ok(Some(self.record_to_frame(rec))) - } - Err(e) => { - self.rejected += 1; - self.status = Some(format!("malformed record at byte {}: {e}", self.cursor)); - // Skip the rest of the buffer — a corrupt record means we've lost - // framing; the daemon would reconnect/re-sync rather than guess. - self.cursor = self.buf.len(); - Err(RvcsiError::adapter( - "nexmon", - format!("malformed record: {e}"), - )) - } - } - } - - fn health(&self) -> SourceHealth { - SourceHealth { - connected: self.cursor < self.buf.len(), - frames_delivered: self.delivered, - frames_rejected: self.rejected, - status: self.status.clone(), - } - } -} - -/// 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, - detected_chip: NexmonChip, - frames: Vec, - headers: Vec, - link_type: u32, - cursor: usize, - skipped: u64, -} - -/// Resolve the chip when every decoded packet agrees on `chip_ver`; otherwise -/// (mixed or empty) fall back to a generic 802.11ac default. -fn detect_chip(headers: &[NexmonCsiHeader]) -> NexmonChip { - match headers.first() { - None => NexmonChip::Bcm43455c0, // a sensible default; profile stays generic-enough - Some(h0) => { - let ver = h0.chip_ver; - if headers.iter().all(|h| h.chip_ver == ver) { - NexmonChip::from_chip_ver(ver) - } else { - NexmonChip::Unknown { chip_ver: 0 } - } - } - } -} - -impl NexmonPcapAdapter { - /// Parse a libpcap byte buffer; `port` is the CSI UDP port to filter on - /// (`None` ⇒ [`NEXMON_DEFAULT_PORT`] = 5500). The chip is auto-detected from - /// the packets' `chip_ver` (e.g. a Raspberry Pi 5 capture ⇒ BCM43455c0); - /// override with [`NexmonPcapAdapter::with_chip`] / [`NexmonPcapAdapter::with_pi_model`]. - pub fn parse( - source_id: impl Into, - session_id: SessionId, - pcap_bytes: &[u8], - port: Option, - ) -> Result { - debug_assert_eq!(shim_abi_version() >> 16, 1, "rvcsi_nexmon_shim major ABI mismatch"); - let source_id = source_id.into(); - let reader = PcapReader::parse(pcap_bytes)?; - let link_type = reader.link_type(); - let want_port = port.or(Some(NEXMON_DEFAULT_PORT)); - let mut frames = Vec::new(); - let mut headers = Vec::new(); - let mut skipped = 0u64; - let mut next_fid = 0u64; - for (ts_ns, _dst_port, payload) in reader.udp_payloads(want_port) { - match decode_nexmon_udp(payload, NEXMON_CSI_FMT_INT16_IQ) { - Ok((hdr, rec)) => { - let mut frame = CsiFrame::from_iq( - next_fid.into(), - session_id, - source_id.clone(), - AdapterKind::Nexmon, - ts_ns, - rec.channel, - rec.bandwidth_mhz, - rec.i_values, - rec.q_values, - ); - next_fid += 1; - frame.rssi_dbm = rec.rssi_dbm; - frame.noise_floor_dbm = rec.noise_floor_dbm; - frames.push(frame); - headers.push(hdr); - } - Err(_) => skipped += 1, - } - } - // Count non-CSI UDP packets on other ports as "skipped" too, for health. - if let Some(p) = want_port { - skipped += reader.udp_payloads(None).filter(|(_, dp, _)| *dp != p).count() as u64; - } - let detected_chip = detect_chip(&headers); - Ok(NexmonPcapAdapter { - source_id, - session_id, - profile: nexmon_adapter_profile(detected_chip), - detected_chip, - frames, - headers, - link_type, - cursor: 0, - skipped, - }) - } - - /// Override the validation profile to the given Nexmon chip (e.g. when the - /// `chip_ver` word is unreliable). This does not change the decoded frames. - pub fn with_chip(mut self, chip: NexmonChip) -> Self { - self.detected_chip = chip; - self.profile = nexmon_adapter_profile(chip); - self - } - - /// Override the validation profile to a Raspberry Pi model's chip - /// (`RaspberryPiModel::Pi5` ⇒ BCM43455c0, 20/40/80 MHz, 64/128/256 sc). - pub fn with_pi_model(mut self, model: RaspberryPiModel) -> Self { - self.detected_chip = model.nexmon_chip(); - self.profile = raspberry_pi_profile(model); - self - } - - /// The chip resolved from the capture's `chip_ver` words (or set via - /// [`NexmonPcapAdapter::with_chip`] / [`NexmonPcapAdapter::with_pi_model`]). - pub fn detected_chip(&self) -> NexmonChip { - self.detected_chip - } - - /// Open and parse a `.pcap` file. - pub fn open( - source_id: impl Into, - session_id: SessionId, - path: impl AsRef, - port: Option, - ) -> Result { - let bytes = std::fs::read(path)?; - Self::parse(source_id, session_id, &bytes, port) - } - - /// Decode every CSI frame in a `.pcap` buffer in one shot (`Pending` frames). - pub fn frames_from_pcap_bytes( - source_id: impl Into, - session_id: SessionId, - pcap_bytes: &[u8], - port: Option, - ) -> Result, RvcsiError> { - Ok(Self::parse(source_id, session_id, pcap_bytes, port)?.frames) - } - - /// The capture's link-layer type. - pub fn link_type(&self) -> u32 { - self.link_type - } - - /// The parsed nexmon_csi UDP headers, one per decoded frame, in order. - pub fn headers(&self) -> &[NexmonCsiHeader] { - &self.headers - } - - /// Total CSI frames decoded from the capture. - pub fn frame_count(&self) -> usize { - self.frames.len() - } -} - -impl CsiSource for NexmonPcapAdapter { - fn profile(&self) -> &AdapterProfile { - &self.profile - } - - fn session_id(&self) -> SessionId { - self.session_id - } - - fn source_id(&self) -> &SourceId { - &self.source_id - } - - fn next_frame(&mut self) -> Result, RvcsiError> { - let frame = self.frames.get(self.cursor).cloned(); - if frame.is_some() { - self.cursor += 1; - } - Ok(frame) - } - - fn health(&self) -> SourceHealth { - SourceHealth { - connected: self.cursor < self.frames.len(), - frames_delivered: self.cursor as u64, - frames_rejected: self.skipped, - status: Some(format!( - "pcap link_type={}, {} CSI frame(s), {} non-CSI/skipped", - self.link_type, - self.frames.len(), - self.skipped - )), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{validate_frame, ValidationPolicy, ValidationStatus}; - - fn make_record(ts: u64, ch: u16, n: usize, rssi: Option) -> Vec { - let i: Vec = (0..n).map(|k| (k as f32) * 0.5).collect(); - let q: Vec = (0..n).map(|k| -(k as f32) * 0.25).collect(); - let rec = NexmonRecord { - subcarrier_count: n as u16, - channel: ch, - bandwidth_mhz: 80, - rssi_dbm: rssi, - noise_floor_dbm: Some(-92), - timestamp_ns: ts, - i_values: i, - q_values: q, - }; - encode_record(&rec).expect("encode") - } - - #[test] - 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] - fn roundtrip_single_record_via_c_shim() { - let bytes = make_record(123_456, 36, 64, Some(-58)); - let (rec, consumed) = decode_record(&bytes).expect("decode"); - assert_eq!(consumed, bytes.len()); - assert_eq!(rec.subcarrier_count, 64); - assert_eq!(rec.channel, 36); - assert_eq!(rec.bandwidth_mhz, 80); - assert_eq!(rec.rssi_dbm, Some(-58)); - assert_eq!(rec.noise_floor_dbm, Some(-92)); - assert_eq!(rec.timestamp_ns, 123_456); - assert_eq!(rec.i_values.len(), 64); - // Q8.8 fixed point: 0.5 and -0.25 are exactly representable. - assert_eq!(rec.i_values[1], 0.5); - assert_eq!(rec.q_values[1], -0.25); - } - - #[test] - fn adapter_streams_multiple_records_then_validates() { - let mut buf = make_record(1_000, 6, 56, Some(-60)); - buf.extend(make_record(2_000, 6, 56, Some(-61))); - buf.extend(make_record(3_000, 6, 56, None)); - - let mut adapter = NexmonAdapter::from_bytes("nexmon-test", SessionId(7), buf); - let mut frames = Vec::new(); - while let Some(f) = adapter.next_frame().unwrap() { - frames.push(f); - } - assert_eq!(frames.len(), 3); - assert_eq!(frames[0].timestamp_ns, 1_000); - assert_eq!(frames[2].rssi_dbm, None); - assert_eq!(adapter.health().frames_delivered, 3); - assert!(!adapter.health().connected); - - // 56 is not in the default Nexmon profile (64/128/256) → rejected. - let mut f = frames[0].clone(); - let err = validate_frame(&mut f, adapter.profile(), &ValidationPolicy::default(), None); - assert!(err.is_err()); - - // With a permissive profile it validates fine. - let mut f = frames[0].clone(); - validate_frame( - &mut f, - &AdapterProfile::offline(AdapterKind::Nexmon), - &ValidationPolicy::default(), - None, - ) - .unwrap(); - assert_eq!(f.validation, ValidationStatus::Accepted); - } - - #[test] - fn truncated_buffer_is_a_structured_error_not_a_panic() { - let bytes = make_record(1, 6, 64, Some(-60)); - let truncated = &bytes[..bytes.len() - 10]; - let err = decode_record(truncated).unwrap_err(); - assert!(err.to_string().to_lowercase().contains("trunc") || err.to_string().to_lowercase().contains("short")); - - let mut adapter = NexmonAdapter::from_bytes("t", SessionId(0), truncated.to_vec()); - assert!(adapter.next_frame().is_err()); - assert_eq!(adapter.health().frames_rejected, 1); - } - - #[test] - fn bad_magic_is_rejected() { - let mut bytes = make_record(1, 6, 64, Some(-60)); - bytes[0] = 0xFF; - assert!(decode_record(&bytes).is_err()); - } - - #[test] - fn frames_from_bytes_helper() { - let mut buf = make_record(10, 1, 64, Some(-50)); - buf.extend(make_record(20, 1, 64, Some(-51))); - let frames = NexmonAdapter::frames_from_bytes("t", SessionId(1), &buf).unwrap(); - assert_eq!(frames.len(), 2); - assert_eq!(frames[1].timestamp_ns, 20); - } - - // ----- NexmonPcapAdapter (real nexmon_csi UDP inside a libpcap file) ----- - - /// Build a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q). - fn synth_nexmon_payload(rssi: i16, chanspec: u16, nsub: u16, seq: u16) -> Vec { - let hdr = NexmonCsiHeader { - rssi_dbm: rssi, - fctl: 0x08, - src_mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x02], - seq_cnt: seq, - core: 0, - spatial_stream: 0, - chanspec, - chip_ver: 0x4345, - channel: 0, - bandwidth_mhz: 0, - is_5ghz: false, - subcarrier_count: nsub, - }; - let i: Vec = (0..nsub).map(|k| (k as i16 - 32) as f32).collect(); - let q: Vec = (0..nsub).map(|k| (seq as i16 + k as i16) as f32).collect(); - encode_nexmon_udp(&hdr, &i, &q).expect("encode nexmon payload") - } - - /// Wrap `payload` in an Ethernet/IPv4/UDP frame to `dst_port`. - fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec { - let mut f = vec![ - 1, 2, 3, 4, 5, 6, // dst mac - 10, 11, 12, 13, 14, 15, // src mac - ]; - f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4 - let total = (20 + 8 + payload.len()) as u16; - f.extend_from_slice(&[0x45, 0x00]); - f.extend_from_slice(&total.to_be_bytes()); - f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum - f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip - f.extend_from_slice(&54321u16.to_be_bytes()); // src port - f.extend_from_slice(&dst_port.to_be_bytes()); // dst port - f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len - f.extend_from_slice(&[0, 0]); // udp cksum - f.extend_from_slice(payload); - f - } - - /// Build a classic LE/microsecond pcap from `(ts_sec, ts_usec, frame)` records. - fn pcap_le_us(link_type: u32, recs: &[(u32, u32, Vec)]) -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(&0xa1b2_c3d4u32.to_le_bytes()); - b.extend_from_slice(&[2, 0, 4, 0]); // ver major/minor - b.extend_from_slice(&0u32.to_le_bytes()); // thiszone - b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs - b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen - b.extend_from_slice(&link_type.to_le_bytes()); - for (s, us, f) in recs { - b.extend_from_slice(&s.to_le_bytes()); - b.extend_from_slice(&us.to_le_bytes()); - b.extend_from_slice(&(f.len() as u32).to_le_bytes()); - b.extend_from_slice(&(f.len() as u32).to_le_bytes()); - b.extend_from_slice(f); - } - b - } - - #[test] - fn pcap_adapter_decodes_real_nexmon_csi_packets() { - let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz - let nsub = 256u16; - let recs = vec![ - (1_000u32, 100_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))), - (1_000u32, 600_000u32, eth_ip_udp(9999, &[0xaa; 8])), // unrelated UDP - (1_001u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-61, chanspec, nsub, 2))), - (1_001u32, 50_000u32, eth_ip_udp(5500, &[0x42; 30])), // bad nexmon magic -> skipped - ]; - let pcap = pcap_le_us(LINKTYPE_ETHERNET, &recs); - - let mut adapter = NexmonPcapAdapter::parse("nexmon-pcap", SessionId(9), &pcap, None).unwrap(); - assert_eq!(adapter.link_type(), LINKTYPE_ETHERNET); - assert_eq!(adapter.frame_count(), 2); - assert_eq!(adapter.headers().len(), 2); - assert_eq!(adapter.headers()[0].chanspec, chanspec); - assert_eq!(adapter.headers()[0].channel, 36); - assert_eq!(adapter.headers()[0].bandwidth_mhz, 80); - assert!(adapter.headers()[0].is_5ghz); - assert_eq!(adapter.headers()[1].seq_cnt, 2); - - let mut frames = Vec::new(); - while let Some(f) = adapter.next_frame().unwrap() { - frames.push(f); - } - assert_eq!(frames.len(), 2); - assert_eq!(frames[0].adapter_kind, AdapterKind::Nexmon); - assert_eq!(frames[0].channel, 36); - assert_eq!(frames[0].bandwidth_mhz, 80); - assert_eq!(frames[0].rssi_dbm, Some(-58)); - assert_eq!(frames[0].subcarrier_count, nsub); - // pcap timestamp -> frame timestamp (1000 s + 100000 us) - assert_eq!(frames[0].timestamp_ns, 1_000 * 1_000_000_000 + 100_000 * 1_000); - assert_eq!(frames[1].timestamp_ns, 1_001 * 1_000_000_000); - - let h = adapter.health(); - assert!(!h.connected); - assert_eq!(h.frames_delivered, 2); - assert!(h.frames_rejected >= 2); // the bad-magic one + the unrelated-port one - } - - #[test] - fn pcap_adapter_validates_decoded_frames() { - let pcap = pcap_le_us( - LINKTYPE_ETHERNET, - &[(1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-60, 0x1000 | 6, 64, 7)))], - ); - let frames = NexmonPcapAdapter::frames_from_pcap_bytes("p", SessionId(0), &pcap, Some(5500)).unwrap(); - assert_eq!(frames.len(), 1); - // 64 sc, channel 6 — accepted by a permissive (offline) profile - let mut f = frames[0].clone(); - validate_frame( - &mut f, - &AdapterProfile::offline(AdapterKind::Nexmon), - &ValidationPolicy::default(), - None, - ) - .unwrap(); - assert_eq!(f.validation, ValidationStatus::Accepted); - assert_eq!(f.channel, 6); - assert_eq!(f.bandwidth_mhz, 20); - } - - #[test] - fn pcap_adapter_rejects_garbage_pcap() { - assert!(NexmonPcapAdapter::parse("p", SessionId(0), &[0u8; 8], None).is_err()); - assert!(NexmonPcapAdapter::open("p", SessionId(0), "/no/such/file.pcap", None).is_err()); - } - - #[test] - fn pcap_adapter_auto_detects_raspberry_pi_5_chip() { - // synth_nexmon_payload stamps chip_ver = 0x4345 (BCM4345 family chip ID), - // which is the CYW43455 / BCM43455c0 on a Raspberry Pi 3B+ / 4 / 400 / 5. - let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz - let nsub = 256u16; - let pcap = pcap_le_us( - LINKTYPE_ETHERNET, - &[ - (1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))), - (1u32, 50_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-59, chanspec, nsub, 2))), - ], - ); - let adapter = NexmonPcapAdapter::parse("pi5-cap", SessionId(1), &pcap, None).unwrap(); - assert_eq!(adapter.detected_chip(), NexmonChip::Bcm43455c0); - assert_eq!(adapter.headers()[0].chip(), NexmonChip::Bcm43455c0); - // the adapter's validation profile is the 43455c0 one (20/40/80, 64/128/256) - let p = adapter.profile(); - assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]); - assert!(p.accepts_subcarrier_count(256)); - assert!(p.accepts_channel(36)); - // 256-sc, ch 36 frame validates fine against the Pi 5 profile - let mut f = adapter.frames[0].clone(); - validate_frame(&mut f, &raspberry_pi_profile(RaspberryPiModel::Pi5), &ValidationPolicy::default(), None).unwrap(); - assert_eq!(f.validation, ValidationStatus::Accepted); - - // explicit override to a Pi 5 also works - let a2 = NexmonPcapAdapter::parse("p", SessionId(0), &pcap, None).unwrap().with_pi_model(RaspberryPiModel::Pi5); - assert_eq!(a2.detected_chip(), NexmonChip::Bcm43455c0); - assert!(a2.profile().chip.as_deref().unwrap().contains("pi5")); - } -} diff --git a/v2/crates/rvcsi-adapter-nexmon/src/pcap.rs b/v2/crates/rvcsi-adapter-nexmon/src/pcap.rs deleted file mode 100644 index a0555e0a0f..0000000000 --- a/v2/crates/rvcsi-adapter-nexmon/src/pcap.rs +++ /dev/null @@ -1,381 +0,0 @@ -//! Minimal, dependency-free reader for the classic libpcap (`.pcap`) file -//! format — enough to pull the UDP payloads out of a nexmon_csi capture -//! (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`). -//! -//! Supports the standard byte-order / timestamp-resolution magics -//! (`0xa1b2c3d4`, `0xd4c3b2a1`, and the nanosecond variants `0xa1b23c4d` / -//! `0x4d3cb2a1`) and the link-layer types that show up for nexmon CSI captures: -//! Ethernet (`1`), raw IPv4 (`101` / `228`), and Linux SLL (`113`). pcapng is a -//! documented follow-up. No `unsafe`, no allocation beyond owning the packet -//! bytes, and every read is bounds-checked. - -use rvcsi_core::RvcsiError; - -/// Classic-pcap magic (microsecond timestamps), as the 32-bit value. -pub const PCAP_MAGIC_US: u32 = 0xa1b2_c3d4; -/// Classic-pcap magic (nanosecond timestamps), as the 32-bit value. -pub const PCAP_MAGIC_NS: u32 = 0xa1b2_3c4d; - -/// Link-layer types we know how to peel down to an IPv4 packet. -pub const LINKTYPE_ETHERNET: u32 = 1; -/// Raw IPv4 (no link header). -pub const LINKTYPE_RAW: u32 = 101; -/// Linux "cooked" capture v1 (16-byte pseudo-header). -pub const LINKTYPE_LINUX_SLL: u32 = 113; -/// Raw IPv4 (the IANA-assigned value). -pub const LINKTYPE_IPV4: u32 = 228; - -/// The default UDP port nexmon_csi sends CSI frames to. -pub const NEXMON_DEFAULT_PORT: u16 = 5500; - -/// One captured packet: its timestamp (ns since the Unix epoch) and raw bytes -/// (starting at the link layer named by [`PcapReader::link_type`]). -#[derive(Debug, Clone)] -pub struct PcapPacket { - /// Capture timestamp, nanoseconds since the Unix epoch. - pub timestamp_ns: u64, - /// The packet bytes (truncated to the capture's snaplen, as on disk). - pub data: Vec, -} - -/// A parsed classic-pcap file. -#[derive(Debug, Clone)] -pub struct PcapReader { - link_type: u32, - packets: Vec, -} - -fn parse_err(offset: usize, msg: impl Into) -> RvcsiError { - RvcsiError::parse(offset, format!("pcap: {}", msg.into())) -} - -struct Endian(bool /* big-endian writer? */); -impl Endian { - fn u32(&self, b: &[u8]) -> u32 { - if self.0 { - u32::from_be_bytes([b[0], b[1], b[2], b[3]]) - } else { - u32::from_le_bytes([b[0], b[1], b[2], b[3]]) - } - } -} - -impl PcapReader { - /// Parse a classic-pcap byte buffer. - pub fn parse(bytes: &[u8]) -> Result { - if bytes.len() < 24 { - return Err(parse_err(0, "buffer shorter than the 24-byte global header")); - } - // The 4 magic bytes on disk identify both byte order and ts resolution. - // 0xa1b2c3d4 written by a LE host -> [d4,c3,b2,a1]; by a BE host -> [a1,b2,c3,d4]. - // 0xa1b23c4d (nanosecond ts): LE -> [4d,3c,b2,a1]; BE -> [a1,b2,3c,4d]. - let m = [bytes[0], bytes[1], bytes[2], bytes[3]]; - let (endian, ts_is_ns) = match m { - [0xd4, 0xc3, 0xb2, 0xa1] => (Endian(false), false), - [0xa1, 0xb2, 0xc3, 0xd4] => (Endian(true), false), - [0x4d, 0x3c, 0xb2, 0xa1] => (Endian(false), true), - [0xa1, 0xb2, 0x3c, 0x4d] => (Endian(true), true), - _ => { - let raw = u32::from_le_bytes(m); - return Err(parse_err( - 0, - format!("unrecognised pcap magic 0x{raw:08x} (pcapng is not supported)"), - )); - } - }; - // bytes 4..6 version_major, 6..8 version_minor, 8..12 thiszone, - // 12..16 sigfigs, 16..20 snaplen, 20..24 network (link type) - let link_type = endian.u32(&bytes[20..24]); - - let mut packets = Vec::new(); - let mut off = 24usize; - while off + 16 <= bytes.len() { - let ts_sec = endian.u32(&bytes[off..off + 4]) as u64; - let ts_frac = endian.u32(&bytes[off + 4..off + 8]) as u64; - let incl_len = endian.u32(&bytes[off + 8..off + 12]) as usize; - // orig_len at off+12..off+16 is informational; ignored. - let data_start = off + 16; - if incl_len > bytes.len().saturating_sub(data_start) { - // Truncated final record — stop cleanly rather than erroring. - break; - } - let timestamp_ns = ts_sec - .saturating_mul(1_000_000_000) - .saturating_add(if ts_is_ns { ts_frac } else { ts_frac.saturating_mul(1_000) }); - packets.push(PcapPacket { - timestamp_ns, - data: bytes[data_start..data_start + incl_len].to_vec(), - }); - off = data_start + incl_len; - } - Ok(PcapReader { link_type, packets }) - } - - /// The capture's link-layer type (one of the `LINKTYPE_*` constants, or another value). - pub fn link_type(&self) -> u32 { - self.link_type - } - - /// All captured packets, in file order. - pub fn packets(&self) -> &[PcapPacket] { - &self.packets - } - - /// Iterate the UDP payloads in the capture whose destination port matches - /// `port` (or all UDP payloads if `port` is `None`), as `(timestamp_ns, - /// dst_port, payload)`. Non-IPv4 / non-UDP / non-matching packets are skipped. - pub fn udp_payloads( - &self, - port: Option, - ) -> impl Iterator + '_ { - let link_type = self.link_type; - self.packets.iter().filter_map(move |pkt| { - let (dst_port, payload) = extract_udp_payload(&pkt.data, link_type)?; - if let Some(p) = port { - if dst_port != p { - return None; - } - } - Some((pkt.timestamp_ns, dst_port, payload)) - }) - } -} - -/// Strip the link / network / transport headers from a captured frame with the -/// given link type and return `(udp_dst_port, udp_payload)`, or `None` if it -/// isn't an IPv4/UDP packet we can peel. -pub fn extract_udp_payload(frame: &[u8], link_type: u32) -> Option<(u16, &[u8])> { - let ip = match link_type { - LINKTYPE_ETHERNET => { - if frame.len() < 14 { - return None; - } - let ethertype = u16::from_be_bytes([frame[12], frame[13]]); - if ethertype != 0x0800 { - return None; // not IPv4 (ignore VLAN-tagged for now) - } - &frame[14..] - } - LINKTYPE_LINUX_SLL => { - if frame.len() < 16 { - return None; - } - let proto = u16::from_be_bytes([frame[14], frame[15]]); - if proto != 0x0800 { - return None; - } - &frame[16..] - } - LINKTYPE_RAW | LINKTYPE_IPV4 => frame, - _ => return None, - }; - - // IPv4 header - if ip.len() < 20 { - return None; - } - if (ip[0] >> 4) != 4 { - return None; // not IPv4 - } - let ihl = (ip[0] & 0x0f) as usize * 4; - if ihl < 20 || ip.len() < ihl { - return None; - } - if ip[9] != 17 { - return None; // not UDP - } - let udp = &ip[ihl..]; - if udp.len() < 8 { - return None; - } - let dst_port = u16::from_be_bytes([udp[2], udp[3]]); - let udp_len = u16::from_be_bytes([udp[4], udp[5]]) as usize; // includes the 8-byte UDP header - let payload_len = udp_len.saturating_sub(8).min(udp.len() - 8); - Some((dst_port, &udp[8..8 + payload_len])) -} - -/// Build a synthetic classic-pcap byte buffer — little-endian, microsecond -/// timestamps, [`LINKTYPE_ETHERNET`] — wrapping the given UDP payloads, one -/// Ethernet/IPv4/UDP packet each. Entries are `(timestamp_ns, dst_port, -/// payload)`. Intended for tests, examples and the `rvcsi` self-tests: real -/// captures come off a Raspberry Pi running patched firmware -/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`). -pub fn synthetic_udp_pcap(packets: &[(u64, u16, &[u8])]) -> Vec { - fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec { - let mut f = vec![ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, // dst mac - 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // src mac - ]; - f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4 - let total = (20 + 8 + payload.len()) as u16; - f.extend_from_slice(&[0x45, 0x00]); - f.extend_from_slice(&total.to_be_bytes()); - f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum - f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip - f.extend_from_slice(&54321u16.to_be_bytes()); // src port - f.extend_from_slice(&dst_port.to_be_bytes()); // dst port - f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len - f.extend_from_slice(&[0, 0]); // udp cksum - f.extend_from_slice(payload); - f - } - let mut b = Vec::new(); - b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes()); - b.extend_from_slice(&[2, 0, 4, 0]); // version major/minor - b.extend_from_slice(&0u32.to_le_bytes()); // thiszone - b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs - b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen - b.extend_from_slice(&LINKTYPE_ETHERNET.to_le_bytes()); - for (ts_ns, dst_port, payload) in packets { - let frame = eth_ip_udp(*dst_port, payload); - let ts_sec = (ts_ns / 1_000_000_000) as u32; - let ts_usec = ((ts_ns % 1_000_000_000) / 1_000) as u32; - b.extend_from_slice(&ts_sec.to_le_bytes()); - b.extend_from_slice(&ts_usec.to_le_bytes()); - b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len - b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len - b.extend_from_slice(&frame); - } - b -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build a synthetic Ethernet/IPv4/UDP frame carrying `payload` to `dst_port`. - fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec { - let mut f = Vec::new(); - // Ethernet II: dst[6] src[6] ethertype[2] - f.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); - f.extend_from_slice(&[0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]); - f.extend_from_slice(&0x0800u16.to_be_bytes()); - // IPv4: 20-byte header - let total_len = (20 + 8 + payload.len()) as u16; - let mut ip = vec![ - 0x45, 0x00, // version/IHL, DSCP/ECN - ]; - ip.extend_from_slice(&total_len.to_be_bytes()); - ip.extend_from_slice(&[0, 0, 0, 0, 64, 17]); // id, flags/frag, ttl, proto=UDP - ip.extend_from_slice(&[0, 0]); // header checksum (not checked here) - ip.extend_from_slice(&[10, 0, 0, 1]); // src ip - ip.extend_from_slice(&[10, 0, 0, 20]); // dst ip - assert_eq!(ip.len(), 20); - f.extend_from_slice(&ip); - // UDP: src_port[2] dst_port[2] length[2] checksum[2] - f.extend_from_slice(&54321u16.to_be_bytes()); - f.extend_from_slice(&dst_port.to_be_bytes()); - f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); - f.extend_from_slice(&[0, 0]); // checksum - f.extend_from_slice(payload); - f - } - - /// Build a minimal classic-pcap file (LE, microsecond) wrapping the frames. - fn pcap_le_us(link_type: u32, frames: &[(u32, u32, Vec)]) -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes()); - b.extend_from_slice(&2u16.to_le_bytes()); // version major - b.extend_from_slice(&4u16.to_le_bytes()); // version minor - b.extend_from_slice(&0i32.to_le_bytes()); // thiszone - b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs - b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen - b.extend_from_slice(&link_type.to_le_bytes()); - for (ts_sec, ts_usec, frame) in frames { - b.extend_from_slice(&ts_sec.to_le_bytes()); - b.extend_from_slice(&ts_usec.to_le_bytes()); - b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len - b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len - b.extend_from_slice(frame); - } - b - } - - #[test] - fn parses_global_header_and_iterates_udp_payloads() { - let p1 = vec![0xaa; 30]; - let p2 = vec![0xbb; 12]; - let other = vec![0xcc; 8]; - let frames = vec![ - (100u32, 250_000u32, eth_ip_udp(5500, &p1)), - (101u32, 500_000u32, eth_ip_udp(9999, &other)), // different port - (102u32, 0u32, eth_ip_udp(5500, &p2)), - ]; - let file = pcap_le_us(LINKTYPE_ETHERNET, &frames); - let r = PcapReader::parse(&file).unwrap(); - assert_eq!(r.link_type(), LINKTYPE_ETHERNET); - assert_eq!(r.packets().len(), 3); - - let csi: Vec<_> = r.udp_payloads(Some(5500)).collect(); - assert_eq!(csi.len(), 2); - assert_eq!(csi[0].0, 100 * 1_000_000_000 + 250_000 * 1_000); // ts_ns - assert_eq!(csi[0].1, 5500); - assert_eq!(csi[0].2, &p1[..]); - assert_eq!(csi[1].2, &p2[..]); - - // no filter -> all 3 UDP payloads - assert_eq!(r.udp_payloads(None).count(), 3); - } - - #[test] - fn handles_raw_ipv4_linktype() { - // raw IPv4 frame = the IPv4 packet directly (no Ethernet header) - let payload = vec![0x11; 20]; - let eth = eth_ip_udp(5500, &payload); - let raw_ip = eth[14..].to_vec(); // strip the 14-byte Ethernet header - let file = pcap_le_us(LINKTYPE_RAW, &[(5u32, 0u32, raw_ip)]); - let r = PcapReader::parse(&file).unwrap(); - let v: Vec<_> = r.udp_payloads(Some(5500)).collect(); - assert_eq!(v.len(), 1); - assert_eq!(v[0].2, &payload[..]); - } - - #[test] - fn nanosecond_magic_scales_timestamps_correctly() { - let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(7u32, 123u32, eth_ip_udp(5500, &[0u8; 8]))]); - // patch the magic to the nanosecond variant - file[0..4].copy_from_slice(&PCAP_MAGIC_NS.to_le_bytes()); - let r = PcapReader::parse(&file).unwrap(); - let v: Vec<_> = r.udp_payloads(Some(5500)).collect(); - assert_eq!(v[0].0, 7 * 1_000_000_000 + 123); // ts_frac taken as ns, not us - } - - #[test] - fn rejects_garbage_and_pcapng() { - assert!(PcapReader::parse(&[0u8; 10]).is_err()); // too short - assert!(PcapReader::parse(&[0u8; 24]).is_err()); // zero magic - // pcapng section-header-block magic (0x0a0d0d0a) — not supported - let mut ng = vec![0x0a, 0x0d, 0x0d, 0x0a]; - ng.extend_from_slice(&[0u8; 24]); - assert!(PcapReader::parse(&ng).is_err()); - } - - #[test] - fn truncated_final_record_is_tolerated() { - let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(1u32, 0u32, eth_ip_udp(5500, &[0u8; 16]))]); - // append a partial record header + claim a huge incl_len - file.extend_from_slice(&2u32.to_le_bytes()); - file.extend_from_slice(&0u32.to_le_bytes()); - file.extend_from_slice(&9999u32.to_le_bytes()); // incl_len > remaining - file.extend_from_slice(&9999u32.to_le_bytes()); - file.extend_from_slice(&[0xde, 0xad]); // only 2 bytes of "data" - let r = PcapReader::parse(&file).unwrap(); - assert_eq!(r.packets().len(), 1); // the complete one only - } - - #[test] - fn extract_udp_payload_rejects_non_udp() { - // build an Ethernet/IPv4 frame but with proto = TCP (6) - let mut eth = eth_ip_udp(5500, &[0u8; 8]); - // IPv4 proto byte is at Ethernet(14) + 9 = 23 - eth[14 + 9] = 6; // TCP - assert!(extract_udp_payload(ð, LINKTYPE_ETHERNET).is_none()); - // wrong ethertype - let mut eth = eth_ip_udp(5500, &[0u8; 8]); - eth[12] = 0x86; - eth[13] = 0xdd; // IPv6 - assert!(extract_udp_payload(ð, LINKTYPE_ETHERNET).is_none()); - // unknown link type - assert!(extract_udp_payload(ð, 9999).is_none()); - } -} diff --git a/v2/crates/rvcsi-cli/Cargo.toml b/v2/crates/rvcsi-cli/Cargo.toml deleted file mode 100644 index 2607b2b31e..0000000000 --- a/v2/crates/rvcsi-cli/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "rvcsi-cli" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI command-line tool — inspect, replay, stream, events, health, calibrate, export (ADR-095 FR7)" -repository.workspace = true -keywords = ["wifi", "csi", "cli", "rvcsi"] -categories = ["science", "command-line-utilities"] - -[[bin]] -name = "rvcsi" -path = "src/main.rs" - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -rvcsi-adapter-file = { path = "../rvcsi-adapter-file" } -rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" } -rvcsi-runtime = { path = "../rvcsi-runtime" } -clap = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } - -[dev-dependencies] -tempfile = "3.10" diff --git a/v2/crates/rvcsi-cli/src/commands.rs b/v2/crates/rvcsi-cli/src/commands.rs deleted file mode 100644 index b3039df719..0000000000 --- a/v2/crates/rvcsi-cli/src/commands.rs +++ /dev/null @@ -1,667 +0,0 @@ -//! Implementations of the `rvcsi` subcommands (ADR-095 FR7). -//! -//! Each command writes to a caller-supplied `&mut dyn Write` so the bodies can -//! be unit-tested against an in-memory buffer. - -use std::io::Write; - -use anyhow::{Context, Result}; - -use rvcsi_adapter_file::{read_all, CaptureHeader, FileRecorder, FileReplayAdapter}; -use rvcsi_adapter_nexmon::NexmonAdapter; -use rvcsi_core::{ - validate_frame, AdapterKind, AdapterProfile, CsiFrame, CsiSource, SessionId, SourceId, - ValidationPolicy, -}; -use rvcsi_runtime as runtime; - -/// `rvcsi record --in --out ` — transcode a buffer of -/// "rvCSI Nexmon records" (the napi-c shim format) into a `.rvcsi` capture file, -/// validating each frame on the way in. This gives the CLI a way to produce -/// `.rvcsi` files without a live radio (which needs the not-yet-shipped daemon). -pub fn record_from_nexmon( - out: &mut dyn Write, - nexmon_path: &str, - out_path: &str, - source_id: &str, - session_id: u64, -) -> Result<()> { - let bytes = std::fs::read(nexmon_path).with_context(|| format!("reading {nexmon_path}"))?; - let mut src = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes); - let profile = AdapterProfile::offline(AdapterKind::Nexmon); - let policy = ValidationPolicy::default(); - let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile.clone()); - let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?; - let (mut written, mut skipped, mut prev_ts) = (0u64, 0u64, None); - loop { - match src.next_frame() { - Ok(None) => break, - Ok(Some(mut f)) => { - let ts = f.timestamp_ns; - match validate_frame(&mut f, &profile, &policy, prev_ts) { - Ok(()) if f.is_exposable() => { - prev_ts = Some(ts); - rec.write_frame(&f)?; - written += 1; - } - _ => skipped += 1, - } - } - Err(e) => { - writeln!(out, "warning: stopped at a malformed Nexmon record: {e}")?; - break; - } - } - } - rec.finish()?; - writeln!(out, "recorded {written} frame(s) to {out_path} ({skipped} dropped by validation)")?; - Ok(()) -} - -/// `rvcsi record --source nexmon-pcap --in --out [--chip pi5]` — -/// transcode the real nexmon_csi UDP payloads inside a libpcap capture -/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into a `.rvcsi` capture file, -/// validating each frame. `port` is the CSI UDP port (`None` ⇒ 5500). `chip` is -/// an optional chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`, ...) — -/// when given, frames are validated against that device's profile and the -/// non-conforming ones dropped (and the profile is stamped on the capture). -pub fn record_from_nexmon_pcap( - out: &mut dyn Write, - pcap_path: &str, - out_path: &str, - source_id: &str, - session_id: u64, - port: Option, - chip: Option<&str>, -) -> Result<()> { - let bytes = std::fs::read(pcap_path).with_context(|| format!("reading {pcap_path}"))?; - let frames = runtime::decode_nexmon_pcap_for(&bytes, source_id, session_id, port, chip) - .with_context(|| format!("parsing nexmon pcap {pcap_path}"))?; - let profile = match chip { - Some(spec) => runtime::nexmon_profile_for(spec) - .ok_or_else(|| anyhow::anyhow!("unknown nexmon chip / Raspberry Pi model `{spec}`"))?, - None => AdapterProfile::nexmon_default(), - }; - let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile); - let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?; - for f in &frames { - rec.write_frame(f)?; - } - rec.finish()?; - let chip_note = chip.map(|c| format!(" (chip {c})")).unwrap_or_default(); - writeln!(out, "recorded {} frame(s) from {pcap_path} to {out_path}{chip_note}", frames.len())?; - Ok(()) -} - -/// `rvcsi nexmon-chips` — list the Broadcom/Cypress chips nexmon_csi runs on and -/// the Raspberry Pi models that carry them (incl. the Pi 5 → BCM43455c0). -pub fn nexmon_chips_cmd(out: &mut dyn Write, json: bool) -> Result<()> { - use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip}; - if json { - let chips: Vec<_> = known_chips() - .iter() - .map(|c| { - let p = nexmon_adapter_profile(*c); - serde_json::json!({ - "slug": c.slug(), "description": c.description(), - "dual_band": c.dual_band(), "int16_iq_export": c.uses_int16_iq(), - "bandwidths_mhz": p.supported_bandwidths_mhz, - "expected_subcarrier_counts": p.expected_subcarrier_counts, - }) - }) - .collect(); - let pis: Vec<_> = known_pi_models() - .iter() - .map(|m| serde_json::json!({ - "slug": m.slug(), "chip": m.nexmon_chip().slug(), "csi_supported": m.csi_supported(), - })) - .collect(); - writeln!(out, "{}", serde_json::to_string_pretty(&serde_json::json!({ "chips": chips, "raspberry_pi_models": pis }))?)?; - return Ok(()); - } - writeln!(out, "Nexmon-supported Broadcom/Cypress chips:")?; - for c in known_chips() { - let p = nexmon_adapter_profile(*c); - writeln!( - out, - " {:<12} {} [bw {:?} MHz, sc {:?}{}]", - c.slug(), - c.description(), - p.supported_bandwidths_mhz, - p.expected_subcarrier_counts, - if c.uses_int16_iq() { "" } else { ", legacy packed-float export" } - )?; - } - writeln!(out, "\nRaspberry Pi models:")?; - for m in known_pi_models() { - let chip = m.nexmon_chip(); - let chip_slug = if matches!(chip, NexmonChip::Unknown { .. }) { "(no CSI support)".to_string() } else { chip.slug() }; - writeln!(out, " {:<10} -> {}{}", m.slug(), chip_slug, if m.csi_supported() { "" } else { " [WiFi present but not CSI-capable]" })?; - } - Ok(()) -} - -/// `rvcsi inspect-nexmon ` — summarize a nexmon_csi `.pcap` (link -/// type, CSI frame count, channels, bandwidths, chip versions, RSSI range, -/// time span). `port` is the CSI UDP port (`None` ⇒ 5500). -pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option, json: bool) -> Result<()> { - let s = runtime::summarize_nexmon_pcap(pcap_path, port).with_context(|| format!("inspecting {pcap_path}"))?; - if json { - writeln!(out, "{}", serde_json::to_string_pretty(&s)?)?; - return Ok(()); - } - writeln!(out, "nexmon pcap : {pcap_path}")?; - writeln!(out, " link type : {}", s.link_type)?; - writeln!(out, " CSI frames : {}", s.csi_frame_count)?; - writeln!(out, " skipped pkts : {}", s.skipped_packets)?; - writeln!( - out, - " time span : {} .. {} ns ({} ns)", - s.first_timestamp_ns, - s.last_timestamp_ns, - s.last_timestamp_ns.saturating_sub(s.first_timestamp_ns) - )?; - writeln!(out, " channels : {:?}", s.channels)?; - writeln!(out, " bandwidths : {:?} MHz", s.bandwidths_mhz)?; - writeln!(out, " subcarriers : {:?}", s.subcarrier_counts)?; - writeln!( - out, - " chip versions: {}", - s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::>().join(", ") - )?; - writeln!(out, " chip : {} (seen: {})", s.detected_chip, s.chip_names.join(", "))?; - match s.rssi_dbm_range { - Some((lo, hi)) => writeln!(out, " rssi range : {lo} .. {hi} dBm")?, - None => writeln!(out, " rssi range : (none)")?, - } - Ok(()) -} - -/// `rvcsi decode-chanspec ` — decode a Broadcom d11ac chanspec word -/// to `{channel, bandwidth_mhz, is_5ghz}` (JSON, or a human line). -pub fn decode_chanspec_cmd(out: &mut dyn Write, chanspec_str: &str, json: bool) -> Result<()> { - let s = chanspec_str.trim(); - let value: u32 = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { - u32::from_str_radix(hex, 16).with_context(|| format!("not a hex u16: {s}"))? - } else { - s.parse::().with_context(|| format!("not a decimal u16: {s}"))? - }; - let d = rvcsi_adapter_nexmon::decode_chanspec((value & 0xFFFF) as u16); - if json { - writeln!( - out, - "{}", - serde_json::to_string(&serde_json::json!({ - "chanspec": d.chanspec, "channel": d.channel, - "bandwidth_mhz": d.bandwidth_mhz, "is_5ghz": d.is_5ghz - }))? - )?; - } else { - writeln!( - out, - "chanspec 0x{:04x}: channel {} @ {} MHz ({})", - d.chanspec, - d.channel, - d.bandwidth_mhz, - if d.is_5ghz { "5 GHz" } else { "2.4 GHz" } - )?; - } - Ok(()) -} - -/// `rvcsi inspect ` — print a summary of a `.rvcsi` capture file. -pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> { - let summary = runtime::summarize_capture(path).with_context(|| format!("inspecting {path}"))?; - if json { - writeln!(out, "{}", serde_json::to_string_pretty(&summary)?)?; - return Ok(()); - } - writeln!(out, "capture : {path}")?; - writeln!(out, " version : {}", summary.capture_version)?; - writeln!(out, " session : {}", summary.session_id)?; - writeln!(out, " source : {}", summary.source_id)?; - writeln!(out, " adapter : {}", summary.adapter_kind)?; - if let Some(chip) = &summary.chip { - writeln!(out, " chip : {chip}")?; - } - writeln!(out, " frames : {}", summary.frame_count)?; - writeln!( - out, - " time span : {} .. {} ns ({} ns)", - summary.first_timestamp_ns, - summary.last_timestamp_ns, - summary.last_timestamp_ns.saturating_sub(summary.first_timestamp_ns) - )?; - writeln!(out, " channels : {:?}", summary.channels)?; - writeln!(out, " subcarriers : {:?}", summary.subcarrier_counts)?; - writeln!(out, " mean quality : {:.3}", summary.mean_quality)?; - let b = summary.validation_breakdown; - writeln!( - out, - " validation : accepted={} degraded={} recovered={} rejected={} pending={}", - b.accepted, b.degraded, b.recovered, b.rejected, b.pending - )?; - writeln!(out, " calibration : {}", summary.calibration_version.as_deref().unwrap_or("(none)"))?; - Ok(()) -} - -/// `rvcsi replay ` / `rvcsi stream --in --format json` — emit one -/// line per frame. With `json`, the full `CsiFrame` JSON; otherwise a compact -/// `frame_id ts ch rssi quality validation` line. `limit` caps the count -/// (`None` = all). `speed` is accepted but not enforced here (the daemon paces -/// real-time replay); a non-1.0 value is noted on stderr by the caller. -pub fn replay(out: &mut dyn Write, path: &str, json: bool, limit: Option) -> Result<()> { - let mut adapter = FileReplayAdapter::open(path).with_context(|| format!("opening {path}"))?; - let mut n = 0usize; - while let Some(frame) = adapter.next_frame()? { - if json { - writeln!(out, "{}", serde_json::to_string(&frame)?)?; - } else { - writeln!( - out, - "{:>8} {:>16} ch{:<3} rssi={:>5} q={:.3} {:?}", - frame.frame_id.value(), - frame.timestamp_ns, - frame.channel, - frame.rssi_dbm.map(|r| r.to_string()).unwrap_or_else(|| "-".into()), - frame.quality_score, - frame.validation, - )?; - } - n += 1; - if let Some(lim) = limit { - if n >= lim { - break; - } - } - } - if !json { - writeln!(out, "-- {n} frame(s)")?; - } - Ok(()) -} - -/// `rvcsi events ` — replay the capture through DSP + the event pipeline -/// and print the emitted events (compact, or full JSON with `json`). -pub fn events(out: &mut dyn Write, path: &str, json: bool) -> Result<()> { - let evs = runtime::events_from_capture(path).with_context(|| format!("processing {path}"))?; - if json { - writeln!(out, "{}", serde_json::to_string_pretty(&evs)?)?; - return Ok(()); - } - for e in &evs { - writeln!( - out, - "{:>16} ns {:<22} conf={:.3} evidence={:?}{}", - e.timestamp_ns, - e.kind.slug(), - e.confidence, - e.evidence_window_ids.iter().map(|w| w.value()).collect::>(), - e.calibration_version.as_deref().map(|c| format!(" calib={c}")).unwrap_or_default(), - )?; - } - writeln!(out, "-- {} event(s)", evs.len())?; - Ok(()) -} - -/// `rvcsi health --source [--target ]` — open the source, drain it, -/// and print the final `SourceHealth` as JSON. File and Nexmon sources work -/// offline; live radios are not available in this build. -pub fn health(out: &mut dyn Write, source: &str, target: Option<&str>) -> Result<()> { - let h = match source { - "file" | "replay" => { - let path = target.context("`--target ` is required for the file source")?; - let mut a = FileReplayAdapter::open(path)?; - while a.next_frame()?.is_some() {} - a.health() - } - "nexmon" => { - let path = target.context("`--target ` is required for the nexmon source")?; - let bytes = std::fs::read(path)?; - let mut a = NexmonAdapter::from_bytes(SourceId::from("nexmon"), SessionId(0), bytes); - // pull until exhausted or a malformed record stops us - while let Ok(Some(_)) = a.next_frame() {} - a.health() - } - "esp32" | "intel" | "atheros" => { - anyhow::bail!("live capture for source `{source}` is not available in this build; use the `rvcsi-daemon` (not yet shipped) or replay a `.rvcsi` capture"); - } - other => anyhow::bail!("unknown source `{other}` (expected: file, replay, nexmon, esp32, intel, atheros)"), - }; - writeln!(out, "{}", serde_json::to_string_pretty(&h)?)?; - Ok(()) -} - -/// `rvcsi export ruvector --in --out ` — window the capture and -/// store each window's embedding into a JSONL RF-memory file. -pub fn export_ruvector(out: &mut dyn Write, capture: &str, out_jsonl: &str) -> Result<()> { - let stored = runtime::export_capture_to_rf_memory(capture, out_jsonl) - .with_context(|| format!("exporting {capture} -> {out_jsonl}"))?; - writeln!(out, "stored {stored} window embedding(s) to {out_jsonl}")?; - Ok(()) -} - -/// `rvcsi calibrate --in [--out ]` — a v0 calibration: -/// learn the per-subcarrier mean amplitude (the "baseline") over all exposable -/// frames in a capture and emit it as JSON. Real, versioned, room-scoped -/// calibration (ADR-095 D14) lands with the daemon. -pub fn calibrate(out: &mut dyn Write, capture: &str, out_path: Option<&str>) -> Result<()> { - let (header, frames) = read_all(capture).with_context(|| format!("reading {capture}"))?; - let exposable: Vec<&CsiFrame> = frames.iter().filter(|f| f.is_exposable()).collect(); - if exposable.is_empty() { - anyhow::bail!("no exposable frames in {capture} — cannot calibrate"); - } - let n = exposable[0].subcarrier_count as usize; - let mut acc = vec![0.0f64; n]; - let mut count = 0usize; - for f in &exposable { - if f.subcarrier_count as usize != n { - continue; - } - for (a, v) in acc.iter_mut().zip(f.amplitude.iter()) { - *a += *v as f64; - } - count += 1; - } - let baseline: Vec = acc.iter().map(|a| (*a / count.max(1) as f64) as f32).collect(); - #[derive(serde::Serialize)] - struct Baseline<'a> { - source_id: &'a str, - session_id: u64, - version: String, - subcarrier_count: usize, - frames_used: usize, - baseline_amplitude: Vec, - } - let payload = Baseline { - source_id: header.source_id.as_str(), - session_id: header.session_id.value(), - version: format!("{}@auto-{count}", header.source_id.as_str()), - subcarrier_count: n, - frames_used: count, - baseline_amplitude: baseline, - }; - let json = serde_json::to_string_pretty(&payload)?; - if let Some(p) = out_path { - std::fs::write(p, &json)?; - writeln!(out, "wrote baseline ({n} subcarriers, {count} frames) to {p}")?; - } else { - writeln!(out, "{json}")?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_adapter_nexmon::{encode_record, NexmonRecord}; - use rvcsi_core::{FrameId, ValidationStatus}; - - fn write_capture(path: &std::path::Path, n: usize) { - let header = CaptureHeader::new( - SessionId(2), - SourceId::from("cli-it"), - AdapterProfile::offline(AdapterKind::File), - ); - let mut rec = FileRecorder::create(path, &header).unwrap(); - for k in 0..n { - let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 }; - let i: Vec = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect(); - let q: Vec = (0..32).map(|_| 0.5).collect(); - let mut f = CsiFrame::from_iq( - FrameId(k as u64), - SessionId(2), - SourceId::from("cli-it"), - AdapterKind::File, - 1_000 + k as u64 * 50_000_000, - 6, - 20, - i, - q, - ) - .with_rssi(-55); - f.validation = ValidationStatus::Accepted; - f.quality_score = 0.9; - rec.write_frame(&f).unwrap(); - } - rec.finish().unwrap(); - } - - fn run) -> Result<()>>(f: F) -> String { - let mut buf = Vec::new(); - f(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - } - - #[test] - fn inspect_human_and_json() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 12); - let p = tmp.path().to_str().unwrap(); - let human = run(|o| inspect(o, p, false)); - assert!(human.contains("frames : 12")); - assert!(human.contains("channels : [6]")); - let json = run(|o| inspect(o, p, true)); - let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(v["frame_count"], 12); - } - - #[test] - fn replay_compact_and_json_and_limit() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 5); - let p = tmp.path().to_str().unwrap(); - let compact = run(|o| replay(o, p, false, None)); - assert!(compact.contains("-- 5 frame(s)")); - let json = run(|o| replay(o, p, true, Some(3))); - assert_eq!(json.lines().count(), 3); - for line in json.lines() { - let _: CsiFrame = serde_json::from_str(line).unwrap(); - } - } - - #[test] - fn events_command_emits_something() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 64); - let p = tmp.path().to_str().unwrap(); - let out = run(|o| events(o, p, false)); - assert!(out.contains("event(s)")); - let json = run(|o| events(o, p, true)); - let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!(v.is_array()); - } - - #[test] - fn health_file_source() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 7); - let p = tmp.path().to_str().unwrap(); - let out = run(|o| health(o, "file", Some(p))); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["frames_delivered"], 7); - assert_eq!(v["connected"], false); - // unknown / live sources error cleanly - let mut buf = Vec::new(); - assert!(health(&mut buf, "esp32", Some(p)).is_err()); - assert!(health(&mut buf, "bogus", None).is_err()); - assert!(health(&mut buf, "file", None).is_err()); // missing --target - } - - #[test] - fn export_and_calibrate() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 64); - let p = tmp.path().to_str().unwrap(); - let out_jsonl = tempfile::NamedTempFile::new().unwrap(); - let out = run(|o| export_ruvector(o, p, out_jsonl.path().to_str().unwrap())); - assert!(out.contains("stored ")); - // calibrate to stdout - let calib = run(|o| calibrate(o, p, None)); - let v: serde_json::Value = serde_json::from_str(&calib).unwrap(); - assert_eq!(v["subcarrier_count"], 32); - assert!(v["baseline_amplitude"].as_array().unwrap().len() == 32); - // calibrate to file - let baseline_file = tempfile::NamedTempFile::new().unwrap(); - let out2 = run(|o| calibrate(o, p, Some(baseline_file.path().to_str().unwrap()))); - assert!(out2.contains("wrote baseline")); - let written = std::fs::read_to_string(baseline_file.path()).unwrap(); - assert!(written.contains("baseline_amplitude")); - } - - #[test] - fn record_from_nexmon_then_inspect_and_replay() { - // build a small Nexmon record dump (64-subcarrier, the default profile) - let mut dump = Vec::new(); - for k in 0..6u64 { - let rec = NexmonRecord { - subcarrier_count: 64, - channel: 36, - bandwidth_mhz: 80, - rssi_dbm: Some(-60 - k as i16), - noise_floor_dbm: Some(-92), - timestamp_ns: 1_000 + k * 50_000_000, - i_values: (0..64).map(|s| (s as f32 % 3.0) - 1.0).collect(), - q_values: (0..64).map(|s| (s as f32 % 5.0) * 0.1).collect(), - }; - dump.extend(encode_record(&rec).unwrap()); - } - let dump_file = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(dump_file.path(), &dump).unwrap(); - let cap_file = tempfile::NamedTempFile::new().unwrap(); - - let out = run(|o| { - record_from_nexmon( - o, - dump_file.path().to_str().unwrap(), - cap_file.path().to_str().unwrap(), - "nexmon-rec", - 3, - ) - }); - assert!(out.contains("recorded 6 frame(s)"), "{out}"); - - // the produced capture is a real .rvcsi the other commands can read - let summary = run(|o| inspect(o, cap_file.path().to_str().unwrap(), false)); - assert!(summary.contains("frames : 6")); - assert!(summary.contains("source : nexmon-rec")); - let replayed = run(|o| replay(o, cap_file.path().to_str().unwrap(), false, None)); - assert!(replayed.contains("-- 6 frame(s)")); - } - - #[test] - fn nexmon_pcap_record_and_inspect_roundtrip() { - use rvcsi_adapter_nexmon::NexmonCsiHeader; - let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz - let nsub = 256u16; - let frames: Vec<(u64, NexmonCsiHeader, Vec, Vec)> = (0..8u64) - .map(|k| { - let i: Vec = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect(); - let q: Vec = (0..nsub).map(|s| (s as i16 % 5 + k as i16) as f32).collect(); - ( - 1_000_000_000 + k * 50_000_000, - NexmonCsiHeader { - rssi_dbm: -55 - k as i16, - fctl: 8, - src_mac: [0, 1, 2, 3, 4, 5], - seq_cnt: k as u16, - core: 0, - spatial_stream: 0, - chanspec, - chip_ver: 0x4345, - 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) — chip_ver 0x4345 resolves to the BCM43455c0 - // (the Raspberry Pi 3B+/4/400/5 chip) - let human = run(|o| inspect_nexmon(o, pcap_path, None, false)); - assert!(human.contains("CSI frames : 8"), "{human}"); - assert!(human.contains("channels : [36]")); - assert!(human.contains("0x4345")); - assert!(human.contains("chip : bcm43455c0"), "{human}"); - let j = run(|o| inspect_nexmon(o, pcap_path, None, true)); - let v: serde_json::Value = serde_json::from_str(&j).unwrap(); - assert_eq!(v["csi_frame_count"], 8); - assert_eq!(v["bandwidths_mhz"][0], 80); - assert_eq!(v["detected_chip"], "bcm43455c0"); - assert_eq!(v["chip_names"][0], "bcm43455c0"); - - // record --source nexmon-pcap --chip pi5 -> .rvcsi; the 256-sc VHT80 ch36 - // frames all fit a Raspberry Pi 5 (BCM43455c0) - let cap_file = tempfile::NamedTempFile::new().unwrap(); - let cap_path = cap_file.path().to_str().unwrap(); - let out = run(|o| record_from_nexmon_pcap(o, pcap_path, cap_path, "nx-pcap", 3, None, Some("pi5"))); - assert!(out.contains("recorded 8 frame(s)") && out.contains("chip pi5"), "{out}"); - let summary = run(|o| inspect(o, cap_path, false)); - assert!(summary.contains("frames : 8")); - assert!(summary.contains("source : nx-pcap")); - assert!(summary.contains("channels : [36]")); - assert!(summary.contains("pi5"), "{summary}"); // the Pi 5 profile was stamped on the capture - - // --chip pizero2w (2.4 GHz only, ≤128 sc) drops every 256-sc frame - let cap2 = tempfile::NamedTempFile::new().unwrap(); - let out2 = run(|o| record_from_nexmon_pcap(o, pcap_path, cap2.path().to_str().unwrap(), "z", 0, None, Some("pizero2w"))); - assert!(out2.contains("recorded 0 frame(s)"), "{out2}"); - // unknown --chip is an error - let mut buf = Vec::new(); - assert!(record_from_nexmon_pcap(&mut buf, pcap_path, cap_path, "x", 0, None, Some("not-a-chip")).is_err()); - } - - #[test] - fn nexmon_chips_listing_includes_pi5() { - let human = run(|o| nexmon_chips_cmd(o, false)); - assert!(human.contains("bcm43455c0"), "{human}"); - assert!(human.contains("pi5"), "{human}"); - assert!(human.to_lowercase().contains("raspberry pi"), "{human}"); - let j = run(|o| nexmon_chips_cmd(o, true)); - let v: serde_json::Value = serde_json::from_str(&j).unwrap(); - let chips = v["chips"].as_array().unwrap(); - assert!(chips.iter().any(|c| c["slug"] == "bcm43455c0")); - let pis = v["raspberry_pi_models"].as_array().unwrap(); - let pi5 = pis.iter().find(|m| m["slug"] == "pi5").expect("pi5 in listing"); - assert_eq!(pi5["chip"], "bcm43455c0"); - assert_eq!(pi5["csi_supported"], true); - } - - #[test] - 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(); - assert!(inspect(&mut buf, "/no/such/file.rvcsi", false).is_err()); - assert!(replay(&mut buf, "/no/such/file.rvcsi", false, None).is_err()); - 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, None).is_err()); - assert!(inspect_nexmon(&mut buf, "/no/such/file.pcap", None, false).is_err()); - } -} diff --git a/v2/crates/rvcsi-cli/src/main.rs b/v2/crates/rvcsi-cli/src/main.rs deleted file mode 100644 index 9743e98a0d..0000000000 --- a/v2/crates/rvcsi-cli/src/main.rs +++ /dev/null @@ -1,202 +0,0 @@ -//! `rvcsi` — the rvCSI command-line tool (ADR-095 FR7). -//! -//! Subcommands: `inspect`, `replay`, `stream`, `events`, `health`, `calibrate`, -//! `export`. Long-running capture / WebSocket streaming live in the (not-yet- -//! shipped) `rvcsi-daemon`; this CLI works against `.rvcsi` capture files and -//! Nexmon record dumps. - -mod commands; - -use std::io::{self, Write}; - -use clap::{Args, Parser, Subcommand}; - -#[derive(Parser)] -#[command(name = "rvcsi", version, about = "rvCSI — edge RF sensing runtime CLI", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Transcode a Nexmon source into a `.rvcsi` capture (validating each frame). - Record { - /// 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. - #[arg(long = "out")] - output: String, - /// Source id to stamp on the capture. - #[arg(long, default_value = "nexmon")] - source_id: String, - /// 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, - /// Validate against a specific chip / Raspberry Pi model — e.g. `pi5`, - /// `pi4`, `pi3b+`, `pizero2w`, `bcm43455c0`, `bcm4366c0` — dropping - /// frames that don't fit it. Default: permissive (any subcarrier count). - #[arg(long)] - chip: Option, - }, - /// List the Broadcom/Cypress chips nexmon_csi runs on + the Raspberry Pi models (incl. Pi 5). - NexmonChips { - /// Emit JSON instead of a human listing. - #[arg(long)] - json: bool, - }, - /// Summarize a nexmon_csi `.pcap` file (link type, CSI frames, channels, ...). - InspectNexmon { - /// Path to a nexmon_csi `.pcap` capture. - path: String, - /// CSI UDP port (defaults to 5500). - #[arg(long)] - port: Option, - /// Emit machine-readable JSON instead of a human summary. - #[arg(long)] - json: bool, - }, - /// Decode a Broadcom d11ac chanspec word (hex `0x…` or decimal). - DecodeChanspec { - /// The chanspec value, e.g. `0xe024` or `57380`. - chanspec: String, - /// Emit JSON instead of a human line. - #[arg(long)] - json: bool, - }, - /// Summarize a `.rvcsi` capture file (frame count, channels, quality, ...). - Inspect { - /// Path to a `.rvcsi` capture file. - path: String, - /// Emit machine-readable JSON instead of a human summary. - #[arg(long)] - json: bool, - }, - /// Replay a `.rvcsi` capture, emitting one line per frame. - Replay { - /// Path to a `.rvcsi` capture file. - path: String, - /// Emit each frame as a full JSON object instead of a compact line. - #[arg(long)] - json: bool, - /// Stop after this many frames. - #[arg(long)] - limit: Option, - /// Real-time pacing multiplier. Accepted for compatibility but not - /// enforced by the CLI (the `rvcsi-daemon` paces real-time replay); - /// a value other than `1.0` is noted on stderr. - #[arg(long, default_value_t = 1.0)] - speed: f32, - }, - /// Stream frames from a source to stdout as JSON lines (a v0 stand-in for - /// the daemon's WebSocket output). Currently supports `.rvcsi` files via `--in`. - Stream { - /// Path to a `.rvcsi` capture file to stream. - #[arg(long = "in")] - input: String, - /// Output format (only `json` is supported in this build). - #[arg(long, default_value = "json")] - format: String, - /// WebSocket port. Accepted but not served by the CLI — needs `rvcsi-daemon`. - #[arg(long)] - port: Option, - }, - /// Replay a capture through the DSP + event pipeline and print the events. - Events { - /// Path to a `.rvcsi` capture file. - path: String, - /// Emit events as JSON instead of compact lines. - #[arg(long)] - json: bool, - }, - /// Open a source, drain it, and print its `SourceHealth` as JSON. - Health { - /// Source slug: `file`, `replay`, `nexmon` (offline); `esp32`/`intel`/`atheros` need the daemon. - #[arg(long)] - source: String, - /// Path / interface for the source (required for `file`/`replay`/`nexmon`). - #[arg(long)] - target: Option, - }, - /// Learn a v0 baseline (per-subcarrier mean amplitude) from a capture. - Calibrate { - /// Path to a `.rvcsi` capture file. - #[arg(long = "in")] - input: String, - /// Write the baseline JSON here instead of stdout. - #[arg(long = "out")] - output: Option, - }, - /// Export data derived from a capture. - Export { - #[command(subcommand)] - target: ExportTarget, - }, -} - -#[derive(Subcommand)] -enum ExportTarget { - /// Window a capture and store each window's embedding into a JSONL RF-memory file. - Ruvector(ExportRuvector), -} - -#[derive(Args)] -struct ExportRuvector { - /// Path to a `.rvcsi` capture file. - #[arg(long = "in")] - input: String, - /// Path to the output JSONL RF-memory file. - #[arg(long = "out")] - output: String, -} - -fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - let stdout = io::stdout(); - let mut out = stdout.lock(); - match cli.command { - Command::Record { source, input, output, source_id, session, port, chip } => match source.as_str() { - "nexmon" => commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)?, - "nexmon-pcap" => commands::record_from_nexmon_pcap( - &mut out, &input, &output, &source_id, session, port, chip.as_deref(), - )?, - other => anyhow::bail!("unknown --source `{other}` (expected `nexmon` or `nexmon-pcap`)"), - }, - Command::NexmonChips { json } => commands::nexmon_chips_cmd(&mut out, json)?, - Command::InspectNexmon { path, port, json } => commands::inspect_nexmon(&mut out, &path, port, json)?, - Command::DecodeChanspec { chanspec, json } => commands::decode_chanspec_cmd(&mut out, &chanspec, json)?, - Command::Inspect { path, json } => commands::inspect(&mut out, &path, json)?, - Command::Replay { path, json, limit, speed } => { - if (speed - 1.0).abs() > f32::EPSILON { - eprintln!("note: --speed {speed} is not enforced by the CLI; replaying as fast as possible"); - } - commands::replay(&mut out, &path, json, limit)?; - } - Command::Stream { input, format, port } => { - if format != "json" { - anyhow::bail!("unsupported --format `{format}` (only `json` is available in this build)"); - } - if let Some(p) = port { - eprintln!("note: --port {p} (WebSocket) needs the rvcsi-daemon; streaming JSON lines to stdout instead"); - } - commands::replay(&mut out, &input, true, None)?; - } - Command::Events { path, json } => commands::events(&mut out, &path, json)?, - Command::Health { source, target } => commands::health(&mut out, &source, target.as_deref())?, - Command::Calibrate { input, output } => commands::calibrate(&mut out, &input, output.as_deref())?, - Command::Export { target } => match target { - ExportTarget::Ruvector(a) => commands::export_ruvector(&mut out, &a.input, &a.output)?, - }, - } - out.flush()?; - Ok(()) -} diff --git a/v2/crates/rvcsi-core/Cargo.toml b/v2/crates/rvcsi-core/Cargo.toml deleted file mode 100644 index 698f023fb8..0000000000 --- a/v2/crates/rvcsi-core/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "rvcsi-core" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI core — normalized CsiFrame/CsiWindow/CsiEvent schema, AdapterProfile, CsiSource trait, validation pipeline (ADR-095, ADR-096)" -repository.workspace = true -keywords = ["wifi", "csi", "rf-sensing", "rvcsi"] -categories = ["science"] - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/v2/crates/rvcsi-core/src/adapter.rs b/v2/crates/rvcsi-core/src/adapter.rs deleted file mode 100644 index dd6de4f58d..0000000000 --- a/v2/crates/rvcsi-core/src/adapter.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Source adapters — the [`CsiSource`] plugin trait (ADR-095 D15) plus the -//! [`AdapterProfile`] capability descriptor and [`SourceConfig`] open params. - -use serde::{Deserialize, Serialize}; - -use crate::error::RvcsiError; -use crate::frame::CsiFrame; -use crate::ids::SessionId; - -/// Which family of source produced a frame. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AdapterKind { - /// A recorded `.rvcsi` capture file. - File, - /// Deterministic replay of a capture session. - Replay, - /// Nexmon CSI (via the isolated C shim). - Nexmon, - /// ESP32 CSI over serial/UDP. - Esp32, - /// Intel `iwlwifi` CSI tool logs. - Intel, - /// Atheros CSI tool logs. - Atheros, - /// An in-memory / synthetic source (tests, simulation). - Synthetic, -} - -impl AdapterKind { - /// Stable lower-case slug (`"file"`, `"nexmon"`, ...). - pub fn slug(self) -> &'static str { - match self { - AdapterKind::File => "file", - AdapterKind::Replay => "replay", - AdapterKind::Nexmon => "nexmon", - AdapterKind::Esp32 => "esp32", - AdapterKind::Intel => "intel", - AdapterKind::Atheros => "atheros", - AdapterKind::Synthetic => "synthetic", - } - } -} - -impl core::fmt::Display for AdapterKind { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str(self.slug()) - } -} - -/// Capability descriptor for a source — used by validation to bound frames and -/// by health checks to flag unsupported firmware/driver state. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AdapterProfile { - /// Adapter family. - pub adapter_kind: AdapterKind, - /// Radio chip, if known (`"BCM43455c0"`, `"ESP32-S3"`, ...). - pub chip: Option, - /// Firmware version string, if known. - pub firmware_version: Option, - /// Driver version string, if known. - pub driver_version: Option, - /// Channels the source can capture on. - pub supported_channels: Vec, - /// Bandwidths (MHz) the source supports. - pub supported_bandwidths_mhz: Vec, - /// Subcarrier counts the source is expected to emit (e.g. `[52, 56, 114, 234]`). - pub expected_subcarrier_counts: Vec, - /// Whether live capture is possible (false for files/replay). - pub supports_live_capture: bool, - /// Whether frame injection is possible. - pub supports_injection: bool, - /// Whether monitor mode is available. - pub supports_monitor_mode: bool, -} - -impl AdapterProfile { - /// A permissive profile for file/replay/synthetic sources: any channel, - /// any bandwidth, any subcarrier count, no live capabilities. - pub fn offline(adapter_kind: AdapterKind) -> Self { - AdapterProfile { - adapter_kind, - chip: None, - firmware_version: None, - driver_version: None, - supported_channels: Vec::new(), - supported_bandwidths_mhz: Vec::new(), - expected_subcarrier_counts: Vec::new(), - supports_live_capture: false, - supports_injection: false, - supports_monitor_mode: false, - } - } - - /// A typical ESP32-S3 HT20 CSI profile (192 raw subcarriers on HT40, - /// 64 on HT20 — both listed; channels 1–13, 2.4 GHz). - pub fn esp32_default() -> Self { - AdapterProfile { - adapter_kind: AdapterKind::Esp32, - chip: Some("ESP32-S3".to_string()), - firmware_version: None, - driver_version: None, - supported_channels: (1..=13).collect(), - supported_bandwidths_mhz: vec![20, 40], - expected_subcarrier_counts: vec![64, 128, 192], - supports_live_capture: true, - supports_injection: false, - supports_monitor_mode: false, - } - } - - /// A typical Nexmon (BCM43455c0) CSI profile: 802.11ac, 20/40/80 MHz. - pub fn nexmon_default() -> Self { - AdapterProfile { - adapter_kind: AdapterKind::Nexmon, - chip: Some("BCM43455c0".to_string()), - firmware_version: None, - driver_version: None, - supported_channels: vec![1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161], - supported_bandwidths_mhz: vec![20, 40, 80], - expected_subcarrier_counts: vec![64, 128, 256], - supports_live_capture: true, - supports_injection: true, - supports_monitor_mode: true, - } - } - - /// `true` if `count` is acceptable for this profile (always true when the - /// expected list is empty, e.g. offline sources). - pub fn accepts_subcarrier_count(&self, count: u16) -> bool { - self.expected_subcarrier_counts.is_empty() - || self.expected_subcarrier_counts.contains(&count) - } - - /// `true` if `channel` is acceptable (always true when the list is empty). - pub fn accepts_channel(&self, channel: u16) -> bool { - self.supported_channels.is_empty() || self.supported_channels.contains(&channel) - } -} - -/// Health snapshot for a source (returned by [`CsiSource::health`] and the -/// `rvcsi health` CLI / `rvcsi_health_report` MCP tool). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceHealth { - /// `true` while the source is producing frames. - pub connected: bool, - /// Frames delivered since the session started. - pub frames_delivered: u64, - /// Frames rejected by validation since the session started. - pub frames_rejected: u64, - /// Optional human-readable status / last error. - pub status: Option, -} - -impl SourceHealth { - /// A "just opened, nothing yet" snapshot. - pub fn fresh(connected: bool) -> Self { - SourceHealth { - connected, - frames_delivered: 0, - frames_rejected: 0, - status: None, - } - } -} - -/// Parameters for opening a source (mirrors the TS SDK `RvCsi.open(...)` shape). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SourceConfig { - /// Source slug: `"file"`, `"replay"`, `"nexmon"`, `"esp32"`, `"intel"`, `"atheros"`. - pub source: String, - /// Network interface (`"wlan0"`), serial port (`"/dev/ttyUSB0"`), or file path. - #[serde(default)] - pub target: Option, - /// WiFi channel (live sources only). - #[serde(default)] - pub channel: Option, - /// Bandwidth in MHz (live sources only). - #[serde(default)] - pub bandwidth_mhz: Option, - /// Replay speed multiplier (`1.0` = real time); replay source only. - #[serde(default)] - pub replay_speed: Option, - /// Free-form adapter-specific options. - #[serde(default)] - pub options_json: Option, -} - -impl SourceConfig { - /// Build a config for the given source slug with no other options set. - pub fn new(source: impl Into) -> Self { - SourceConfig { - source: source.into(), - target: None, - channel: None, - bandwidth_mhz: None, - replay_speed: None, - options_json: None, - } - } - - /// Builder: set the target (iface/port/path). - pub fn target(mut self, t: impl Into) -> Self { - self.target = Some(t.into()); - self - } - - /// Builder: set the channel. - pub fn channel(mut self, c: u16) -> Self { - self.channel = Some(c); - self - } - - /// Builder: set the bandwidth. - pub fn bandwidth_mhz(mut self, b: u16) -> Self { - self.bandwidth_mhz = Some(b); - self - } -} - -/// The plugin trait every CSI source implements. -/// -/// Object-safe so the runtime can hold `Box`. Adapters produce -/// frames with `validation = Pending`; the runtime runs [`crate::validate_frame`] -/// before exposing anything. -pub trait CsiSource: Send { - /// The source's capability descriptor. - fn profile(&self) -> &AdapterProfile; - - /// The capture session id this source is bound to. - fn session_id(&self) -> SessionId; - - /// Stable source id for logs / RuVector records. - fn source_id(&self) -> &crate::ids::SourceId; - - /// Pull the next frame. `Ok(None)` signals end-of-stream (file exhausted, - /// replay finished). Live sources block until a frame is available or - /// return an [`RvcsiError::Adapter`] on disconnect. - fn next_frame(&mut self) -> Result, RvcsiError>; - - /// Current health snapshot. - fn health(&self) -> SourceHealth; - - /// Stop the source and release resources. Default: no-op. - fn stop(&mut self) -> Result<(), RvcsiError> { - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn offline_profile_accepts_anything() { - let p = AdapterProfile::offline(AdapterKind::File); - assert!(p.accepts_subcarrier_count(57)); - assert!(p.accepts_channel(999)); - assert!(!p.supports_live_capture); - } - - #[test] - fn esp32_profile_bounds() { - let p = AdapterProfile::esp32_default(); - assert!(p.accepts_subcarrier_count(64)); - assert!(!p.accepts_subcarrier_count(57)); - assert!(p.accepts_channel(6)); - assert!(!p.accepts_channel(36)); - assert!(p.supports_live_capture); - } - - #[test] - fn source_config_builder() { - let c = SourceConfig::new("nexmon").target("wlan0").channel(6).bandwidth_mhz(20); - assert_eq!(c.source, "nexmon"); - assert_eq!(c.target.as_deref(), Some("wlan0")); - assert_eq!(c.channel, Some(6)); - let json = serde_json::to_string(&c).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), c); - } - - #[test] - fn adapter_kind_slug_display() { - assert_eq!(AdapterKind::Nexmon.slug(), "nexmon"); - assert_eq!(AdapterKind::Esp32.to_string(), "esp32"); - } - - #[test] - fn health_fresh() { - let h = SourceHealth::fresh(true); - assert!(h.connected); - assert_eq!(h.frames_delivered, 0); - } -} diff --git a/v2/crates/rvcsi-core/src/error.rs b/v2/crates/rvcsi-core/src/error.rs deleted file mode 100644 index d019143586..0000000000 --- a/v2/crates/rvcsi-core/src/error.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Error type for the rvCSI runtime. - -use thiserror::Error; - -use crate::validation::ValidationError; - -/// Errors surfaced by the rvCSI core, adapters, DSP and event pipeline. -/// -/// Parser failures are structured (never panics, never raw pointers across -/// boundaries — ADR-095 D6). A `Validation` error means a frame was *rejected*; -/// a *degraded* frame is not an error and is returned normally with reduced -/// `quality_score`. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RvcsiError { - /// A source/adapter could not be opened or talked to. - #[error("adapter '{kind}' failed: {message}")] - Adapter { - /// The adapter kind (`"file"`, `"nexmon"`, `"esp32"`, ...). - kind: String, - /// Human-readable detail. - message: String, - }, - - /// A raw byte buffer could not be parsed into a frame. - #[error("parse error at offset {offset}: {message}")] - Parse { - /// Byte offset where parsing failed (best effort). - offset: usize, - /// Human-readable detail. - message: String, - }, - - /// A frame failed validation and was rejected. - #[error("frame rejected: {0}")] - Validation(#[from] ValidationError), - - /// A configuration value was out of range or inconsistent. - #[error("invalid configuration: {0}")] - Config(String), - - /// An I/O error (file capture, replay, WebSocket, ...). - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - /// Serialization / deserialization error (JSON capture sidecars, RuVector export). - #[error("serde error: {0}")] - Serde(#[from] serde_json::Error), - - /// The requested operation is not supported by this source/adapter. - #[error("unsupported: {0}")] - Unsupported(String), -} - -impl RvcsiError { - /// Convenience constructor for adapter errors. - pub fn adapter(kind: impl Into, message: impl Into) -> Self { - RvcsiError::Adapter { - kind: kind.into(), - message: message.into(), - } - } - - /// Convenience constructor for parse errors. - pub fn parse(offset: usize, message: impl Into) -> Self { - RvcsiError::Parse { - offset, - message: message.into(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn display_messages_are_useful() { - let e = RvcsiError::adapter("nexmon", "device /dev/wlan0 not in monitor mode"); - assert!(e.to_string().contains("nexmon")); - assert!(e.to_string().contains("monitor mode")); - - let e = RvcsiError::parse(12, "frame length 0"); - assert!(e.to_string().contains("offset 12")); - } -} diff --git a/v2/crates/rvcsi-core/src/event.rs b/v2/crates/rvcsi-core/src/event.rs deleted file mode 100644 index cf6e746bfd..0000000000 --- a/v2/crates/rvcsi-core/src/event.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! The [`CsiEvent`] aggregate — semantic interpretation of one or more windows. - -use serde::{Deserialize, Serialize}; - -use crate::ids::{EventId, SessionId, SourceId, WindowId}; - -/// Kinds of event the runtime emits (ADR-095 FR5). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CsiEventKind { - /// Presence appeared in the sensed space. - PresenceStarted, - /// Presence ended. - PresenceEnded, - /// Motion above threshold detected. - MotionDetected, - /// Motion fell back to baseline. - MotionSettled, - /// The learned baseline shifted (re-calibration may be warranted). - BaselineChanged, - /// Signal quality dropped below a usable threshold. - SignalQualityDropped, - /// The source disconnected. - DeviceDisconnected, - /// A candidate breathing-rate observation (when signal quality permits). - BreathingCandidate, - /// A significant unexplained deviation. - AnomalyDetected, - /// Calibration is required before detection can be trusted. - CalibrationRequired, -} - -impl CsiEventKind { - /// Stable lower-case slug used in logs and the SDK (`"presence_started"`...). - pub fn slug(self) -> &'static str { - match self { - CsiEventKind::PresenceStarted => "presence_started", - CsiEventKind::PresenceEnded => "presence_ended", - CsiEventKind::MotionDetected => "motion_detected", - CsiEventKind::MotionSettled => "motion_settled", - CsiEventKind::BaselineChanged => "baseline_changed", - CsiEventKind::SignalQualityDropped => "signal_quality_dropped", - CsiEventKind::DeviceDisconnected => "device_disconnected", - CsiEventKind::BreathingCandidate => "breathing_candidate", - CsiEventKind::AnomalyDetected => "anomaly_detected", - CsiEventKind::CalibrationRequired => "calibration_required", - } - } -} - -/// A detected event with confidence and the evidence windows that justify it. -/// -/// Invariant: `evidence_window_ids` is non-empty and `0.0 <= confidence <= 1.0`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CsiEvent { - /// Event id. - pub event_id: EventId, - /// What happened. - pub kind: CsiEventKind, - /// Owning session. - pub session_id: SessionId, - /// Source that produced the evidence. - pub source_id: SourceId, - /// When the event was detected (ns). - pub timestamp_ns: u64, - /// Confidence in `[0.0, 1.0]`. - pub confidence: f32, - /// Windows that justify this event (at least one). - pub evidence_window_ids: Vec, - /// Calibration version detection ran against, if any. - pub calibration_version: Option, - /// Free-form JSON metadata (motion energy, estimated rate, ...). - pub metadata_json: String, -} - -/// Why a [`CsiEvent`] is malformed. -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -#[non_exhaustive] -pub enum EventError { - /// No evidence window referenced. - #[error("event has no evidence window")] - NoEvidence, - /// `confidence` escaped `[0, 1]`. - #[error("confidence {0} out of [0,1]")] - ConfidenceOutOfRange(f32), -} - -impl CsiEvent { - /// Minimal constructor; sets `metadata_json` to `"{}"`. - pub fn new( - event_id: EventId, - kind: CsiEventKind, - session_id: SessionId, - source_id: SourceId, - timestamp_ns: u64, - confidence: f32, - evidence_window_ids: Vec, - ) -> Self { - CsiEvent { - event_id, - kind, - session_id, - source_id, - timestamp_ns, - confidence, - evidence_window_ids, - calibration_version: None, - metadata_json: "{}".to_string(), - } - } - - /// Attach a calibration version. - pub fn with_calibration(mut self, version: impl Into) -> Self { - self.calibration_version = Some(version.into()); - self - } - - /// Attach metadata (any serializable value). - pub fn with_metadata(mut self, meta: &T) -> Result { - self.metadata_json = serde_json::to_string(meta)?; - Ok(self) - } - - /// Check the aggregate invariant. - pub fn validate(&self) -> Result<(), EventError> { - if self.evidence_window_ids.is_empty() { - return Err(EventError::NoEvidence); - } - if !(0.0..=1.0).contains(&self.confidence) || !self.confidence.is_finite() { - return Err(EventError::ConfidenceOutOfRange(self.confidence)); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slugs_are_stable() { - assert_eq!(CsiEventKind::PresenceStarted.slug(), "presence_started"); - assert_eq!(CsiEventKind::AnomalyDetected.slug(), "anomaly_detected"); - } - - #[test] - fn requires_evidence_and_bounded_confidence() { - let mut e = CsiEvent::new( - EventId(0), - CsiEventKind::MotionDetected, - SessionId(0), - SourceId::from("t"), - 1_000, - 0.7, - vec![WindowId(3)], - ); - assert!(e.validate().is_ok()); - - e.evidence_window_ids.clear(); - assert_eq!(e.validate(), Err(EventError::NoEvidence)); - - e.evidence_window_ids.push(WindowId(3)); - e.confidence = 1.2; - assert_eq!(e.validate(), Err(EventError::ConfidenceOutOfRange(1.2))); - } - - #[test] - fn metadata_and_calibration_roundtrip() { - #[derive(Serialize)] - struct M { - motion_energy: f32, - } - let e = CsiEvent::new( - EventId(1), - CsiEventKind::PresenceStarted, - SessionId(0), - SourceId::from("t"), - 5, - 0.9, - vec![WindowId(0)], - ) - .with_calibration("livingroom@v3") - .with_metadata(&M { motion_energy: 1.25 }) - .unwrap(); - assert_eq!(e.calibration_version.as_deref(), Some("livingroom@v3")); - assert!(e.metadata_json.contains("1.25")); - let json = serde_json::to_string(&e).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), e); - } -} diff --git a/v2/crates/rvcsi-core/src/frame.rs b/v2/crates/rvcsi-core/src/frame.rs deleted file mode 100644 index 1936c33827..0000000000 --- a/v2/crates/rvcsi-core/src/frame.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! The normalized [`CsiFrame`] — the FFI-safe boundary object (ADR-095 D5/D6). - -use serde::{Deserialize, Serialize}; - -use crate::adapter::AdapterKind; -use crate::ids::{FrameId, SessionId, SourceId}; - -/// Outcome of the validation pipeline for a frame. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ValidationStatus { - /// Not yet validated — set by adapters before [`crate::validate_frame`] runs. - /// A `Pending` frame must never cross a language boundary. - Pending, - /// Passed all checks. - Accepted, - /// Usable but with reduced confidence; carries a reason in `quality_reasons`. - Degraded, - /// Failed a hard check; quarantined when quarantine is enabled, otherwise dropped. - Rejected, - /// Reconstructed during replay or gap-recovery; timestamp monotonicity is waived. - Recovered, -} - -impl ValidationStatus { - /// Whether a frame with this status may be exposed to SDK/DSP/memory/agents. - #[inline] - pub fn is_exposable(self) -> bool { - matches!( - self, - ValidationStatus::Accepted | ValidationStatus::Degraded | ValidationStatus::Recovered - ) - } -} - -/// One CSI observation at a timestamp, normalized across all sources. -/// -/// Invariants enforced by [`crate::validate_frame`]: -/// * `i_values.len() == q_values.len() == amplitude.len() == phase.len() == subcarrier_count` -/// * all of `i_values`/`q_values`/`amplitude`/`phase` are finite -/// * `subcarrier_count` is within the source's [`crate::AdapterProfile`] -/// * `rssi_dbm`, when present, is within plausible device bounds -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CsiFrame { - /// Monotonic id within the session. - pub frame_id: FrameId, - /// Owning capture session. - pub session_id: SessionId, - /// Human-readable source id. - pub source_id: SourceId, - /// Which adapter produced this frame. - pub adapter_kind: AdapterKind, - /// Source timestamp in nanoseconds. - pub timestamp_ns: u64, - /// WiFi channel number. - pub channel: u16, - /// Channel bandwidth in MHz (20, 40, 80, 160). - pub bandwidth_mhz: u16, - /// Received signal strength, dBm, if reported. - pub rssi_dbm: Option, - /// Noise floor, dBm, if reported. - pub noise_floor_dbm: Option, - /// Receive-antenna index, if reported. - pub antenna_index: Option, - /// Transmit chain index, if reported. - pub tx_chain: Option, - /// Receive chain index, if reported. - pub rx_chain: Option, - /// Number of subcarriers (== length of the four vectors below). - pub subcarrier_count: u16, - /// In-phase components, one per subcarrier. - pub i_values: Vec, - /// Quadrature components, one per subcarrier. - pub q_values: Vec, - /// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier. - pub amplitude: Vec, - /// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later). - pub phase: Vec, - /// Validation outcome. - pub validation: ValidationStatus, - /// Quality / usability confidence in `[0.0, 1.0]`. - pub quality_score: f32, - /// Reasons a frame was degraded (empty when `Accepted`). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub quality_reasons: Vec, - /// Calibration version this frame was processed against, if any. - pub calibration_version: Option, -} - -impl CsiFrame { - /// Build a raw (un-validated) frame from interleaved-free I/Q vectors. - /// - /// `amplitude` and `phase` are derived from `i_values`/`q_values`. The - /// frame is returned with `validation = Pending` and `quality_score = 0.0`; - /// run [`crate::validate_frame`] before exposing it. - #[allow(clippy::too_many_arguments)] - pub fn from_iq( - frame_id: FrameId, - session_id: SessionId, - source_id: SourceId, - adapter_kind: AdapterKind, - timestamp_ns: u64, - channel: u16, - bandwidth_mhz: u16, - i_values: Vec, - q_values: Vec, - ) -> Self { - let n = i_values.len(); - let mut amplitude = Vec::with_capacity(n); - let mut phase = Vec::with_capacity(n); - for (i, q) in i_values.iter().zip(q_values.iter()) { - amplitude.push((i * i + q * q).sqrt()); - phase.push(q.atan2(*i)); - } - CsiFrame { - frame_id, - session_id, - source_id, - adapter_kind, - timestamp_ns, - channel, - bandwidth_mhz, - rssi_dbm: None, - noise_floor_dbm: None, - antenna_index: None, - tx_chain: None, - rx_chain: None, - subcarrier_count: n as u16, - i_values, - q_values, - amplitude, - phase, - validation: ValidationStatus::Pending, - quality_score: 0.0, - quality_reasons: Vec::new(), - calibration_version: None, - } - } - - /// Builder-style setter for RSSI. - pub fn with_rssi(mut self, rssi_dbm: i16) -> Self { - self.rssi_dbm = Some(rssi_dbm); - self - } - - /// Builder-style setter for noise floor. - pub fn with_noise_floor(mut self, noise_floor_dbm: i16) -> Self { - self.noise_floor_dbm = Some(noise_floor_dbm); - self - } - - /// Builder-style setter for antenna / chain metadata. - pub fn with_chains(mut self, antenna: Option, tx: Option, rx: Option) -> Self { - self.antenna_index = antenna; - self.tx_chain = tx; - self.rx_chain = rx; - self - } - - /// Mean amplitude across subcarriers (0.0 for an empty frame). - pub fn mean_amplitude(&self) -> f32 { - if self.amplitude.is_empty() { - 0.0 - } else { - self.amplitude.iter().sum::() / self.amplitude.len() as f32 - } - } - - /// Whether this frame may be exposed across a language boundary. - pub fn is_exposable(&self) -> bool { - self.validation.is_exposable() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> CsiFrame { - CsiFrame::from_iq( - FrameId(0), - SessionId(0), - SourceId::from("test"), - AdapterKind::File, - 1_000, - 6, - 20, - vec![3.0, 0.0, -1.0], - vec![4.0, 2.0, 0.0], - ) - } - - #[test] - fn derives_amplitude_and_phase() { - let f = sample(); - assert_eq!(f.subcarrier_count, 3); - assert!((f.amplitude[0] - 5.0).abs() < 1e-6); // 3-4-5 triangle - assert!((f.amplitude[1] - 2.0).abs() < 1e-6); - assert!((f.phase[0] - (4.0f32).atan2(3.0)).abs() < 1e-6); - assert_eq!(f.validation, ValidationStatus::Pending); - assert_eq!(f.quality_score, 0.0); - } - - #[test] - fn builder_setters_and_mean() { - let f = sample().with_rssi(-55).with_noise_floor(-92).with_chains(Some(0), None, Some(1)); - assert_eq!(f.rssi_dbm, Some(-55)); - assert_eq!(f.noise_floor_dbm, Some(-92)); - assert_eq!(f.antenna_index, Some(0)); - assert_eq!(f.rx_chain, Some(1)); - assert!((f.mean_amplitude() - (5.0 + 2.0 + 1.0) / 3.0).abs() < 1e-6); - } - - #[test] - fn exposability_rules() { - assert!(!ValidationStatus::Pending.is_exposable()); - assert!(!ValidationStatus::Rejected.is_exposable()); - assert!(ValidationStatus::Accepted.is_exposable()); - assert!(ValidationStatus::Degraded.is_exposable()); - assert!(ValidationStatus::Recovered.is_exposable()); - } - - #[test] - fn frame_json_roundtrips() { - let f = sample().with_rssi(-60); - let json = serde_json::to_string(&f).unwrap(); - let back: CsiFrame = serde_json::from_str(&json).unwrap(); - assert_eq!(f, back); - } -} diff --git a/v2/crates/rvcsi-core/src/ids.rs b/v2/crates/rvcsi-core/src/ids.rs deleted file mode 100644 index ae5c8783e5..0000000000 --- a/v2/crates/rvcsi-core/src/ids.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Identifier value objects. -//! -//! `FrameId`, `WindowId` and `EventId` are monotonic `u64` newtypes minted by -//! an [`IdGenerator`]. `SessionId` is also a `u64` (one per capture session). -//! `SourceId` wraps a human-readable string (`"esp32-com7"`, `"pcap:lab.pcap"`) -//! so logs and RuVector records stay legible. - -use std::sync::atomic::{AtomicU64, Ordering}; - -use serde::{Deserialize, Serialize}; - -macro_rules! u64_newtype { - ($(#[$m:meta])* $name:ident) => { - $(#[$m])* - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] - pub struct $name(pub u64); - - impl $name { - /// The raw integer value. - #[inline] - pub const fn value(self) -> u64 { - self.0 - } - } - - impl From for $name { - #[inline] - fn from(v: u64) -> Self { - $name(v) - } - } - - impl core::fmt::Display for $name { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "{}#{}", stringify!($name), self.0) - } - } - }; -} - -u64_newtype!( - /// Identifies one CSI observation within a capture session. - FrameId -); -u64_newtype!( - /// Identifies a capture session (one source + one runtime config). - SessionId -); -u64_newtype!( - /// Identifies a bounded window of frames. - WindowId -); -u64_newtype!( - /// Identifies a semantic event. - EventId -); - -/// Human-readable identifier for a CSI source. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct SourceId(pub String); - -impl SourceId { - /// Construct from anything string-like. - pub fn new(s: impl Into) -> Self { - SourceId(s.into()) - } - - /// Borrow the underlying string. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl From<&str> for SourceId { - fn from(s: &str) -> Self { - SourceId(s.to_string()) - } -} - -impl From for SourceId { - fn from(s: String) -> Self { - SourceId(s) - } -} - -impl core::fmt::Display for SourceId { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str(&self.0) - } -} - -/// Monotonic id minter shared by a runtime instance. -/// -/// Frame, window and event id spaces are independent. The generator is -/// `Send + Sync` (atomic counters) so it can be shared across the capture, -/// signal and event tasks. -#[derive(Debug, Default)] -pub struct IdGenerator { - frame: AtomicU64, - window: AtomicU64, - event: AtomicU64, - session: AtomicU64, -} - -impl IdGenerator { - /// A fresh generator with all counters at zero. - pub const fn new() -> Self { - IdGenerator { - frame: AtomicU64::new(0), - window: AtomicU64::new(0), - event: AtomicU64::new(0), - session: AtomicU64::new(0), - } - } - - /// Next frame id. - pub fn next_frame(&self) -> FrameId { - FrameId(self.frame.fetch_add(1, Ordering::Relaxed)) - } - - /// Next window id. - pub fn next_window(&self) -> WindowId { - WindowId(self.window.fetch_add(1, Ordering::Relaxed)) - } - - /// Next event id. - pub fn next_event(&self) -> EventId { - EventId(self.event.fetch_add(1, Ordering::Relaxed)) - } - - /// Next session id. - pub fn next_session(&self) -> SessionId { - SessionId(self.session.fetch_add(1, Ordering::Relaxed)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn id_generator_is_monotonic_and_independent() { - let g = IdGenerator::new(); - assert_eq!(g.next_frame(), FrameId(0)); - assert_eq!(g.next_frame(), FrameId(1)); - assert_eq!(g.next_window(), WindowId(0)); - assert_eq!(g.next_event(), EventId(0)); - assert_eq!(g.next_frame(), FrameId(2)); - assert_eq!(g.next_session(), SessionId(0)); - } - - #[test] - fn source_id_roundtrips_and_displays() { - let s = SourceId::from("esp32-com7"); - assert_eq!(s.as_str(), "esp32-com7"); - assert_eq!(s.to_string(), "esp32-com7"); - let json = serde_json::to_string(&s).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), s); - } - - #[test] - fn u64_newtype_display_and_serde() { - let f = FrameId(42); - assert_eq!(f.value(), 42); - assert_eq!(f.to_string(), "FrameId#42"); - let json = serde_json::to_string(&f).unwrap(); - assert_eq!(json, "42"); - assert_eq!(serde_json::from_str::(&json).unwrap(), f); - } -} diff --git a/v2/crates/rvcsi-core/src/lib.rs b/v2/crates/rvcsi-core/src/lib.rs deleted file mode 100644 index e066d1bf2c..0000000000 --- a/v2/crates/rvcsi-core/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! # rvCSI core -//! -//! Foundation types for the rvCSI edge RF sensing runtime (ADR-095, ADR-096). -//! -//! Every CSI source is normalized into a [`CsiFrame`]; bounded sequences of -//! frames become a [`CsiWindow`]; semantic interpretations become a -//! [`CsiEvent`]. A [`CsiSource`] is the plugin trait every hardware/file/replay -//! adapter implements. Nothing crosses a language boundary (napi-rs / napi-c) -//! until [`validate_frame`] has run and the frame's [`ValidationStatus`] is -//! `Accepted` or `Degraded`. -//! -//! This crate is dependency-light (serde + thiserror only) and `no_std`-clean -//! in spirit so it can be reused from WASM later. - -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -mod adapter; -mod error; -mod event; -mod frame; -mod ids; -mod validation; -mod window; - -pub use adapter::{AdapterKind, AdapterProfile, CsiSource, SourceConfig, SourceHealth}; -pub use error::RvcsiError; -pub use event::{CsiEvent, CsiEventKind}; -pub use frame::{CsiFrame, ValidationStatus}; -pub use ids::{EventId, FrameId, IdGenerator, SessionId, SourceId, WindowId}; -pub use validation::{validate_frame, QualityScore, ValidationError, ValidationPolicy}; -pub use window::CsiWindow; - -/// Re-exported result type for the runtime. -pub type Result = core::result::Result; diff --git a/v2/crates/rvcsi-core/src/validation.rs b/v2/crates/rvcsi-core/src/validation.rs deleted file mode 100644 index c48171c059..0000000000 --- a/v2/crates/rvcsi-core/src/validation.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! The validation pipeline (ADR-095 D6/D13). -//! -//! [`validate_frame`] is the only door between raw adapter output and anything -//! downstream (DSP, events, the napi boundary, RuVector). It mutates a frame in -//! place: on success it sets `validation` to `Accepted` or `Degraded` and fills -//! `quality_score`; on a hard failure it returns a [`ValidationError`] and the -//! caller quarantines the frame (when quarantine is enabled) or drops it. - -use serde::{Deserialize, Serialize}; - -use crate::adapter::AdapterProfile; -use crate::frame::{CsiFrame, ValidationStatus}; - -/// Tunable bounds for the validation pipeline. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ValidationPolicy { - /// Minimum acceptable subcarrier count. - pub min_subcarriers: u16, - /// Maximum acceptable subcarrier count. - pub max_subcarriers: u16, - /// Plausible RSSI range, dBm (inclusive). - pub rssi_dbm_bounds: (i16, i16), - /// If `true`, a non-monotonic timestamp is a hard reject; if `false`, the - /// frame is marked [`ValidationStatus::Recovered`] and accepted. - pub strict_monotonic_time: bool, - /// If `true`, frames that fail a soft check become `Degraded` instead of - /// being rejected; if `false`, soft failures are rejected too. - pub degrade_instead_of_reject: bool, - /// Frames whose computed quality is below this become `Degraded` - /// (or rejected if `degrade_instead_of_reject` is false). - pub min_quality: f32, -} - -impl Default for ValidationPolicy { - fn default() -> Self { - ValidationPolicy { - min_subcarriers: 1, - max_subcarriers: 4096, - rssi_dbm_bounds: (-110, 0), - strict_monotonic_time: false, - degrade_instead_of_reject: true, - min_quality: 0.25, - } - } -} - -/// Computed usability confidence for a frame, in `[0.0, 1.0]`. -/// -/// Starts at `1.0` and accrues multiplicative penalties for: out-of-range -/// (but non-fatal) RSSI, near-zero amplitude (dead subcarriers), excessive -/// amplitude spikes, and missing optional metadata that the profile implies -/// should be present. -#[derive(Debug, Clone, PartialEq)] -pub struct QualityScore { - /// The final score. - pub value: f32, - /// Human-readable reasons it was reduced (empty when `value == 1.0`). - pub reasons: Vec, -} - -impl QualityScore { - fn full() -> Self { - QualityScore { - value: 1.0, - reasons: Vec::new(), - } - } - - fn penalize(&mut self, factor: f32, reason: impl Into) { - self.value = (self.value * factor).clamp(0.0, 1.0); - self.reasons.push(reason.into()); - } -} - -/// Why a frame was rejected (a hard failure). -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -#[non_exhaustive] -pub enum ValidationError { - /// The four parallel vectors disagree in length, or none match `subcarrier_count`. - #[error("vector length mismatch: i={i}, q={q}, amp={amp}, phase={phase}, subcarrier_count={sc}")] - LengthMismatch { - /// i_values length - i: usize, - /// q_values length - q: usize, - /// amplitude length - amp: usize, - /// phase length - phase: usize, - /// declared subcarrier_count - sc: usize, - }, - /// Subcarrier count is outside `[policy.min, policy.max]` or not in the profile. - #[error("subcarrier count {count} not allowed (policy {min}..={max}, profile-allowed: {profile_ok})")] - SubcarrierCount { - /// the count - count: u16, - /// policy minimum - min: u16, - /// policy maximum - max: u16, - /// whether the profile's expected list allowed it - profile_ok: bool, - }, - /// A non-finite (NaN / inf) value in one of the vectors. - #[error("non-finite value in '{vector}' at index {index}")] - NonFinite { - /// which vector - vector: &'static str, - /// index of the offending element - index: usize, - }, - /// RSSI is so far out of range it's implausible (hard reject). - #[error("implausible RSSI {rssi} dBm (bounds {min}..={max})")] - ImplausibleRssi { - /// reported rssi - rssi: i16, - /// lower bound - min: i16, - /// upper bound - max: i16, - }, - /// Timestamp went backwards and `strict_monotonic_time` is set. - #[error("non-monotonic timestamp: {ts} <= previous {prev}")] - NonMonotonicTime { - /// this frame's timestamp - ts: u64, - /// previous frame's timestamp - prev: u64, - }, - /// Channel is not supported by the source profile. - #[error("channel {channel} not in source profile")] - UnsupportedChannel { - /// the channel - channel: u16, - }, - /// Computed quality fell below `policy.min_quality` and degradation is disabled. - #[error("quality {quality} below minimum {min}")] - BelowMinQuality { - /// computed quality - quality: f32, - /// configured minimum - min: f32, - }, -} - -/// How implausibly far outside the bounds an RSSI must be before it's a hard -/// reject rather than a quality penalty. -const RSSI_HARD_MARGIN: i16 = 30; - -/// Validate `frame` against `profile` and `policy`, mutating it in place. -/// -/// `prev_timestamp_ns` is the timestamp of the previous accepted frame in the -/// same session (or `None` for the first frame); it is used for the -/// monotonicity check. -/// -/// On `Ok(())` the frame's `validation` is `Accepted` / `Degraded` / -/// `Recovered` and `quality_score` is set. On `Err`, the frame's `validation` -/// has been set to `Rejected` (so a caller that ignores the error still won't -/// expose it) and the error explains why. -pub fn validate_frame( - frame: &mut CsiFrame, - profile: &AdapterProfile, - policy: &ValidationPolicy, - prev_timestamp_ns: Option, -) -> Result<(), ValidationError> { - // -- hard checks --------------------------------------------------------- - let sc = frame.subcarrier_count as usize; - if frame.i_values.len() != sc - || frame.q_values.len() != sc - || frame.amplitude.len() != sc - || frame.phase.len() != sc - { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::LengthMismatch { - i: frame.i_values.len(), - q: frame.q_values.len(), - amp: frame.amplitude.len(), - phase: frame.phase.len(), - sc, - }); - } - - let profile_ok = profile.accepts_subcarrier_count(frame.subcarrier_count); - if frame.subcarrier_count < policy.min_subcarriers - || frame.subcarrier_count > policy.max_subcarriers - || !profile_ok - { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::SubcarrierCount { - count: frame.subcarrier_count, - min: policy.min_subcarriers, - max: policy.max_subcarriers, - profile_ok, - }); - } - - for (name, v) in [ - ("i_values", &frame.i_values), - ("q_values", &frame.q_values), - ("amplitude", &frame.amplitude), - ("phase", &frame.phase), - ] { - if let Some(idx) = v.iter().position(|x| !x.is_finite()) { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::NonFinite { - vector: name, - index: idx, - }); - } - } - - if !profile.accepts_channel(frame.channel) { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::UnsupportedChannel { - channel: frame.channel, - }); - } - - let (rssi_lo, rssi_hi) = policy.rssi_dbm_bounds; - if let Some(rssi) = frame.rssi_dbm { - if rssi < rssi_lo - RSSI_HARD_MARGIN || rssi > rssi_hi + RSSI_HARD_MARGIN { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::ImplausibleRssi { - rssi, - min: rssi_lo, - max: rssi_hi, - }); - } - } - - let mut recovered_time = false; - if let Some(prev) = prev_timestamp_ns { - if frame.timestamp_ns <= prev { - if policy.strict_monotonic_time { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::NonMonotonicTime { - ts: frame.timestamp_ns, - prev, - }); - } - recovered_time = true; - } - } - - // -- quality scoring (soft) --------------------------------------------- - let mut q = QualityScore::full(); - - if let Some(rssi) = frame.rssi_dbm { - if rssi < rssi_lo || rssi > rssi_hi { - q.penalize(0.6, format!("rssi {rssi} dBm outside [{rssi_lo},{rssi_hi}]")); - } - } - - // dead subcarriers (amplitude ~ 0) - let dead = frame.amplitude.iter().filter(|a| **a < 1e-6).count(); - if dead > 0 { - let frac = dead as f32 / sc.max(1) as f32; - q.penalize((1.0 - frac).max(0.05), format!("{dead}/{sc} dead subcarriers")); - } - - // amplitude spikes (a single subcarrier >> the median magnitude) - if sc >= 3 { - let mut sorted: Vec = frame.amplitude.clone(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)); - let median = sorted[sc / 2].max(1e-9); - let max = *sorted.last().unwrap(); - if max > median * 50.0 { - q.penalize(0.7, format!("amplitude spike: max {max:.3} vs median {median:.3}")); - } - } - - // implied-but-missing metadata - if frame.rssi_dbm.is_none() { - q.penalize(0.95, "missing rssi"); - } - - let status = if recovered_time { - ValidationStatus::Recovered - } else if q.value < policy.min_quality { - if policy.degrade_instead_of_reject { - ValidationStatus::Degraded - } else { - frame.validation = ValidationStatus::Rejected; - return Err(ValidationError::BelowMinQuality { - quality: q.value, - min: policy.min_quality, - }); - } - } else if q.reasons.is_empty() { - ValidationStatus::Accepted - } else if policy.degrade_instead_of_reject { - // soft penalties but above the floor → still acceptable, just note them - ValidationStatus::Accepted - } else { - ValidationStatus::Accepted - }; - - frame.validation = status; - frame.quality_score = q.value; - frame.quality_reasons = q.reasons; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::adapter::AdapterKind; - use crate::ids::{FrameId, SessionId, SourceId}; - - fn raw(sc: usize) -> CsiFrame { - CsiFrame::from_iq( - FrameId(0), - SessionId(0), - SourceId::from("t"), - AdapterKind::File, - 1_000, - 6, - 20, - vec![1.0; sc], - vec![1.0; sc], - ) - } - - #[test] - fn clean_frame_is_accepted_with_perfect_quality() { - let mut f = raw(56).with_rssi(-55); - validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap(); - assert_eq!(f.validation, ValidationStatus::Accepted); - assert_eq!(f.quality_score, 1.0); - assert!(f.quality_reasons.is_empty()); - assert!(f.is_exposable()); - } - - #[test] - fn missing_rssi_is_a_minor_penalty_not_a_reject() { - let mut f = raw(56); - validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap(); - assert_eq!(f.validation, ValidationStatus::Accepted); - assert!(f.quality_score < 1.0); - assert!(f.quality_reasons.iter().any(|r| r.contains("rssi"))); - } - - #[test] - fn length_mismatch_is_rejected() { - let mut f = raw(56); - f.q_values.pop(); - let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err(); - assert!(matches!(err, ValidationError::LengthMismatch { .. })); - assert_eq!(f.validation, ValidationStatus::Rejected); - assert!(!f.is_exposable()); - } - - #[test] - fn non_finite_is_rejected() { - let mut f = raw(4); - f.amplitude[2] = f32::NAN; - let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err(); - assert!(matches!(err, ValidationError::NonFinite { vector: "amplitude", index: 2 })); - } - - #[test] - fn subcarrier_count_must_match_profile() { - let mut f = raw(57); // ESP32 expects 64/128/192 - let err = validate_frame(&mut f, &AdapterProfile::esp32_default(), &ValidationPolicy::default(), None).unwrap_err(); - assert!(matches!(err, ValidationError::SubcarrierCount { count: 57, .. })); - } - - #[test] - fn non_monotonic_time_is_recovered_when_lenient_rejected_when_strict() { - let mut f = raw(56).with_rssi(-50); - // lenient - validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), Some(2_000)).unwrap(); - assert_eq!(f.validation, ValidationStatus::Recovered); - // strict - let mut g = raw(56).with_rssi(-50); - let policy = ValidationPolicy { strict_monotonic_time: true, ..Default::default() }; - let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, Some(2_000)).unwrap_err(); - assert!(matches!(err, ValidationError::NonMonotonicTime { .. })); - } - - #[test] - fn dead_subcarriers_degrade_quality() { - let mut f = raw(10).with_rssi(-50); - for a in f.amplitude.iter_mut().take(8) { - *a = 0.0; - } - validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap(); - assert!(f.quality_score < 0.5); - assert!(f.quality_reasons.iter().any(|r| r.contains("dead subcarriers"))); - } - - #[test] - fn very_low_quality_can_be_degraded_or_rejected() { - // 9/10 dead → quality ~0.1 < min_quality 0.25 - let mk = || { - let mut f = raw(10).with_rssi(-50); - for a in f.amplitude.iter_mut().take(9) { - *a = 0.0; - } - f - }; - let mut f = mk(); - validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap(); - assert_eq!(f.validation, ValidationStatus::Degraded); - - let mut g = mk(); - let policy = ValidationPolicy { degrade_instead_of_reject: false, ..Default::default() }; - let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, None).unwrap_err(); - assert!(matches!(err, ValidationError::BelowMinQuality { .. })); - assert_eq!(g.validation, ValidationStatus::Rejected); - } - - #[test] - fn implausible_rssi_is_hard_reject() { - let mut f = raw(56).with_rssi(50); // way above 0 + margin - let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err(); - assert!(matches!(err, ValidationError::ImplausibleRssi { .. })); - } -} diff --git a/v2/crates/rvcsi-core/src/window.rs b/v2/crates/rvcsi-core/src/window.rs deleted file mode 100644 index 1e67a680da..0000000000 --- a/v2/crates/rvcsi-core/src/window.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! The [`CsiWindow`] aggregate — a bounded sequence of frames from one source. - -use serde::{Deserialize, Serialize}; - -use crate::ids::{SessionId, SourceId, WindowId}; - -/// A bounded window of frames, summarized into per-subcarrier statistics plus -/// scalar motion / presence / quality scores. -/// -/// Invariants (enforced by the DSP windowing stage, [`CsiWindow::validate`]): -/// * all frames came from one `source_id` and one `session_id` -/// * `start_ns < end_ns` -/// * `0.0 <= presence_score <= 1.0` and `0.0 <= quality_score <= 1.0` -/// * `mean_amplitude.len() == phase_variance.len()` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CsiWindow { - /// Window id. - pub window_id: WindowId, - /// Owning session. - pub session_id: SessionId, - /// Source the frames came from. - pub source_id: SourceId, - /// Timestamp of the first frame, ns. - pub start_ns: u64, - /// Timestamp of the last frame, ns. - pub end_ns: u64, - /// Number of frames aggregated. - pub frame_count: u32, - /// Mean amplitude per subcarrier. - pub mean_amplitude: Vec, - /// Phase variance per subcarrier. - pub phase_variance: Vec, - /// Scalar motion energy (>= 0). - pub motion_energy: f32, - /// Presence score in `[0.0, 1.0]`. - pub presence_score: f32, - /// Window quality in `[0.0, 1.0]`. - pub quality_score: f32, -} - -/// Reasons a [`CsiWindow`] failed its invariants. -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -#[non_exhaustive] -pub enum WindowError { - /// `start_ns >= end_ns`. - #[error("window start {start_ns} not before end {end_ns}")] - BadTimeOrder { - /// start - start_ns: u64, - /// end - end_ns: u64, - }, - /// A score escaped `[0, 1]`. - #[error("score '{name}' = {value} out of [0,1]")] - ScoreOutOfRange { - /// which score - name: &'static str, - /// the value - value: f32, - }, - /// `mean_amplitude` and `phase_variance` disagree in length. - #[error("stat length mismatch: mean_amplitude={a}, phase_variance={b}")] - StatLengthMismatch { - /// mean_amplitude length - a: usize, - /// phase_variance length - b: usize, - }, - /// Zero frames in the window. - #[error("empty window")] - Empty, -} - -impl CsiWindow { - /// Duration covered by the window, ns. - pub fn duration_ns(&self) -> u64 { - self.end_ns.saturating_sub(self.start_ns) - } - - /// Number of subcarriers summarized. - pub fn subcarrier_count(&self) -> usize { - self.mean_amplitude.len() - } - - /// Check the aggregate invariants. - pub fn validate(&self) -> Result<(), WindowError> { - if self.frame_count == 0 { - return Err(WindowError::Empty); - } - if self.start_ns >= self.end_ns { - return Err(WindowError::BadTimeOrder { - start_ns: self.start_ns, - end_ns: self.end_ns, - }); - } - if self.mean_amplitude.len() != self.phase_variance.len() { - return Err(WindowError::StatLengthMismatch { - a: self.mean_amplitude.len(), - b: self.phase_variance.len(), - }); - } - for (name, v) in [ - ("presence_score", self.presence_score), - ("quality_score", self.quality_score), - ] { - if !(0.0..=1.0).contains(&v) || !v.is_finite() { - return Err(WindowError::ScoreOutOfRange { name, value: v }); - } - } - if !self.motion_energy.is_finite() || self.motion_energy < 0.0 { - return Err(WindowError::ScoreOutOfRange { - name: "motion_energy", - value: self.motion_energy, - }); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn good() -> CsiWindow { - CsiWindow { - window_id: WindowId(0), - session_id: SessionId(0), - source_id: SourceId::from("test"), - start_ns: 1_000, - end_ns: 2_000, - frame_count: 10, - mean_amplitude: vec![1.0, 2.0, 3.0], - phase_variance: vec![0.1, 0.1, 0.2], - motion_energy: 0.5, - presence_score: 0.8, - quality_score: 0.9, - } - } - - #[test] - fn valid_window_passes() { - let w = good(); - assert!(w.validate().is_ok()); - assert_eq!(w.duration_ns(), 1_000); - assert_eq!(w.subcarrier_count(), 3); - } - - #[test] - fn rejects_bad_time_order() { - let mut w = good(); - w.end_ns = w.start_ns; - assert!(matches!(w.validate(), Err(WindowError::BadTimeOrder { .. }))); - } - - #[test] - fn rejects_out_of_range_score() { - let mut w = good(); - w.presence_score = 1.5; - assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "presence_score", .. }))); - let mut w = good(); - w.motion_energy = -0.1; - assert!(matches!(w.validate(), Err(WindowError::ScoreOutOfRange { name: "motion_energy", .. }))); - } - - #[test] - fn rejects_stat_mismatch_and_empty() { - let mut w = good(); - w.phase_variance.push(0.3); - assert!(matches!(w.validate(), Err(WindowError::StatLengthMismatch { .. }))); - let mut w = good(); - w.frame_count = 0; - assert!(matches!(w.validate(), Err(WindowError::Empty))); - } -} diff --git a/v2/crates/rvcsi-dsp/Cargo.toml b/v2/crates/rvcsi-dsp/Cargo.toml deleted file mode 100644 index d82ff6320f..0000000000 --- a/v2/crates/rvcsi-dsp/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "rvcsi-dsp" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI DSP — reusable signal-processing stages (DC removal, phase unwrap, smoothing, Hampel, variance, baseline, motion energy, presence) (ADR-095 FR4)" -repository.workspace = true -keywords = ["wifi", "csi", "dsp", "rvcsi"] -categories = ["science"] - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -serde = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/v2/crates/rvcsi-dsp/src/features.rs b/v2/crates/rvcsi-dsp/src/features.rs deleted file mode 100644 index 52a3007d0c..0000000000 --- a/v2/crates/rvcsi-dsp/src/features.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Frame/window-level scalar features (ADR-095 FR4). -//! -//! These are deterministic, dependency-light feature extractors that turn -//! cleaned amplitude/quality series into the small scalar signals downstream -//! components (presence, breathing, confidence) expose. Anything labelled -//! "heuristic" is best-effort and is meant to be quality-gated by the caller. - -use crate::stages::{mean, moving_average, std_dev}; - -/// Per-subcarrier RMS amplitude delta between two consecutive frames. -/// -/// Defined as `||cur - prev||_2 / sqrt(n)`. Returns `0.0` if either slice is -/// empty or the lengths differ (a quiet zero rather than an error keeps the -/// streaming call sites simple). -pub fn motion_energy(prev_amplitude: &[f32], cur_amplitude: &[f32]) -> f32 { - if prev_amplitude.is_empty() - || cur_amplitude.is_empty() - || prev_amplitude.len() != cur_amplitude.len() - { - return 0.0; - } - let sum_sq: f32 = prev_amplitude - .iter() - .zip(cur_amplitude.iter()) - .map(|(p, c)| { - let d = c - p; - d * d - }) - .sum(); - (sum_sq / prev_amplitude.len() as f32).sqrt() -} - -/// Mean of [`motion_energy`] over every consecutive pair in the series. -/// -/// Returns `0.0` if fewer than two amplitude vectors are supplied. -pub fn motion_energy_series(amplitudes: &[Vec]) -> f32 { - if amplitudes.len() < 2 { - return 0.0; - } - let mut acc = 0.0f32; - for w in amplitudes.windows(2) { - acc += motion_energy(&w[0], &w[1]); - } - acc / (amplitudes.len() - 1) as f32 -} - -/// Fixed logistic steepness for [`presence_score`]. -const PRESENCE_STEEPNESS: f32 = 8.0; - -/// Logistic squash of motion energy into a `[0, 1]` presence score. -/// -/// Formula: `1 / (1 + exp(-(motion_energy - threshold) * k))` with a fixed -/// steepness `k = 8.0`. Monotone increasing in `motion_energy`, bounded to -/// `[0, 1]`, and exactly `0.5` when `motion_energy == threshold`. -pub fn presence_score(motion_energy: f32, threshold: f32) -> f32 { - let z = (motion_energy - threshold) * PRESENCE_STEEPNESS; - 1.0 / (1.0 + (-z).exp()) -} - -/// Robust aggregate of per-frame quality scores in `[0, 1]`. -/// -/// Computes `mean - 0.5 * std_dev` over the supplied per-frame quality scores -/// and clamps the result to `[0, 1]`. Returns `0.0` for an empty input. The -/// `-0.5*std` term penalizes windows whose quality is uneven. -pub fn confidence_score(quality_scores: &[f32]) -> f32 { - if quality_scores.is_empty() { - return 0.0; - } - (mean(quality_scores) - 0.5 * std_dev(quality_scores)).clamp(0.0, 1.0) -} - -/// Minimum number of full periods of data required before [`breathing_band_estimate`] -/// will attempt anything. -const MIN_PERIODS: f32 = 2.0; -/// Low edge of the respiration band, Hz (~6 bpm). -const RESP_LO_HZ: f32 = 0.1; -/// High edge of the respiration band, Hz (~30 bpm). -const RESP_HI_HZ: f32 = 0.5; -/// Minimum normalized autocorrelation peak to accept an estimate. -const PEAK_THRESHOLD: f32 = 0.3; - -/// Best-effort respiration-rate estimate, in **breaths per minute**. -/// -/// Heuristic, FFT-free pipeline: -/// 1. detrend the series by subtracting a moving average, -/// 2. compute the biased autocorrelation for lags in the 0.1–0.5 Hz band -/// (6–30 bpm), -/// 3. if there is a clear dominant peak — its normalized autocorrelation -/// (peak / zero-lag) exceeds `~0.3` and it is a local maximum — return -/// `Some(60 * sample_rate_hz / best_lag)`, otherwise `None`. -/// -/// Returns `None` unless there are at least two full periods of data at the -/// slowest band edge (so the caller need not pre-trim). This is **heuristic** -/// and is meant to be quality-gated by the caller; do not treat the result as -/// a medical-grade vital sign. -pub fn breathing_band_estimate(amplitude_series: &[f32], sample_rate_hz: f32) -> Option { - if sample_rate_hz <= 0.0 || amplitude_series.len() < 4 { - return None; - } - // Lag (in samples) bounds for the respiration band. - let min_lag = (sample_rate_hz / RESP_HI_HZ).floor() as usize; - let mut max_lag = (sample_rate_hz / RESP_LO_HZ).ceil() as usize; - if min_lag < 1 { - return None; - } - // Need at least MIN_PERIODS periods at the *fast* edge of the band before - // it is worth attempting anything (a shorter series cannot resolve even the - // quickest breathing rate). The slow edge is handled by clamping `max_lag` - // to half the series length below. - let needed = (MIN_PERIODS * sample_rate_hz / RESP_HI_HZ).ceil() as usize; - if amplitude_series.len() < needed.max(2 * min_lag) { - return None; - } - max_lag = max_lag.min(amplitude_series.len() / 2); - if max_lag <= min_lag { - return None; - } - - // 1. Detrend: subtract a moving average whose window spans roughly one slow - // period (clamped to the series length) so the trend, not the - // oscillation, is removed. - let trend_window = ((sample_rate_hz / RESP_LO_HZ).round() as usize) - .max(3) - .min(amplitude_series.len()); - let trend = moving_average(amplitude_series, trend_window); - let detrended: Vec = amplitude_series - .iter() - .zip(trend.iter()) - .map(|(x, t)| x - t) - .collect(); - - // 2. Biased autocorrelation (divide by N for every lag). - let n = detrended.len() as f32; - let autocorr = |lag: usize| -> f32 { - let mut s = 0.0f32; - for i in lag..detrended.len() { - s += detrended[i] * detrended[i - lag]; - } - s / n - }; - let zero_lag = autocorr(0); - if zero_lag <= 0.0 { - return None; - } - - // 3. Find the dominant local-max lag inside the band. - let mut best_lag = 0usize; - let mut best_val = f32::NEG_INFINITY; - for lag in min_lag..=max_lag { - let v = autocorr(lag); - if v > best_val { - best_val = v; - best_lag = lag; - } - } - if best_lag == 0 { - return None; - } - // Local maximum check (compare against immediate neighbours). - let left = autocorr(best_lag - 1); - let right = if best_lag < max_lag.min(detrended.len().saturating_sub(1)) { - autocorr(best_lag + 1) - } else { - f32::NEG_INFINITY - }; - let is_local_max = best_val >= left && best_val >= right; - let normalized = best_val / zero_lag; - if !is_local_max || normalized < PEAK_THRESHOLD { - return None; - } - Some(60.0 * sample_rate_hz / best_lag as f32) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn approx(a: f32, b: f32, eps: f32) { - assert!((a - b).abs() < eps, "{a} !~= {b} (eps {eps})"); - } - - #[test] - fn motion_energy_zero_for_identical() { - let a = vec![1.0, 2.0, 3.0]; - approx(motion_energy(&a, &a), 0.0, 1e-6); - } - - #[test] - fn motion_energy_positive_for_different() { - let a = vec![0.0, 0.0, 0.0]; - let b = vec![1.0, 1.0, 1.0]; - // diff all 1 -> sum_sq 3, /3 = 1, sqrt = 1 - approx(motion_energy(&a, &b), 1.0, 1e-6); - } - - #[test] - fn motion_energy_mismatch_or_empty_is_zero() { - approx(motion_energy(&[], &[1.0]), 0.0, 1e-6); - approx(motion_energy(&[1.0, 2.0], &[1.0]), 0.0, 1e-6); - } - - #[test] - fn motion_energy_series_averages() { - // frames: [0,0],[1,1],[1,1] -> energies: 1.0, 0.0 -> mean 0.5 - let frames = vec![vec![0.0, 0.0], vec![1.0, 1.0], vec![1.0, 1.0]]; - approx(motion_energy_series(&frames), 0.5, 1e-6); - // fewer than 2 -> 0 - approx(motion_energy_series(&[vec![1.0]]), 0.0, 1e-6); - approx(motion_energy_series(&[]), 0.0, 1e-6); - } - - #[test] - fn presence_score_bounded_monotone_half_at_threshold() { - let t = 0.5; - approx(presence_score(t, t), 0.5, 1e-6); - let lo = presence_score(0.0, t); - let mid = presence_score(0.5, t); - let hi = presence_score(2.0, t); - assert!(lo < mid && mid < hi, "{lo} {mid} {hi}"); - assert!((0.0..=1.0).contains(&lo)); - assert!((0.0..=1.0).contains(&hi)); - // very small / very large saturate - assert!(presence_score(-100.0, t) < 1e-3); - assert!(presence_score(100.0, t) > 1.0 - 1e-3); - } - - #[test] - fn confidence_score_basic() { - approx(confidence_score(&[0.9, 0.9, 0.9]), 0.9, 1e-6); // std 0 - approx(confidence_score(&[]), 0.0, 1e-6); - // uneven quality -> penalized below the mean - let c = confidence_score(&[0.2, 1.0, 0.6]); - assert!(c < 0.6, "{c}"); - assert!((0.0..=1.0).contains(&c)); - } - - #[test] - fn breathing_estimate_detects_quarter_hz_sine() { - // 0.25 Hz sine (15 bpm) sampled at 10 Hz for 12 s -> 120 samples. - let fs = 10.0f32; - let n = 120usize; - let freq = 0.25f32; - let mut series = Vec::with_capacity(n); - // tiny deterministic "noise" via a fixed sequence - for i in 0..n { - let t = i as f32 / fs; - let noise = 0.02 * ((i as f32 * 1.7).sin()); - series.push(1.0 + 0.5 * (2.0 * core::f32::consts::PI * freq * t).sin() + noise); - } - let bpm = breathing_band_estimate(&series, fs).expect("should detect a peak"); - approx(bpm, 15.0, 3.0); - } - - #[test] - fn breathing_estimate_none_for_short_or_noise() { - // too short - assert!(breathing_band_estimate(&[1.0, 2.0, 3.0], 10.0).is_none()); - // a flat constant -> zero-lag autocorr 0 after detrend -> None - assert!(breathing_band_estimate(&vec![1.0; 200], 10.0).is_none()); - // bad sample rate - assert!(breathing_band_estimate(&vec![1.0; 200], 0.0).is_none()); - } -} diff --git a/v2/crates/rvcsi-dsp/src/lib.rs b/v2/crates/rvcsi-dsp/src/lib.rs deleted file mode 100644 index 2f476ac25c..0000000000 --- a/v2/crates/rvcsi-dsp/src/lib.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! # rvCSI DSP — reusable signal-processing stages (ADR-095 FR4) -//! -//! `rvcsi-dsp` is the dependency-light DSP layer of the rvCSI edge RF sensing -//! runtime. It implements **FR4 of [ADR-095]** — *"reusable Rust -//! signal-processing stages"* — as a small library of deterministic primitives -//! plus a composable per-frame [`SignalPipeline`]. -//! -//! The crate is split into three modules: -//! -//! * [`stages`] — pure per-vector DSP primitives operating on `&[f32]` / -//! `&mut [f32]`: [`mean`](stages::mean), [`variance`](stages::variance), -//! [`std_dev`](stages::std_dev), [`median`](stages::median), -//! [`remove_dc_offset`](stages::remove_dc_offset), -//! [`unwrap_phase`](stages::unwrap_phase), -//! [`moving_average`](stages::moving_average), [`ewma`](stages::ewma), -//! [`hampel_filter`](stages::hampel_filter) / -//! [`hampel_filter_count`](stages::hampel_filter_count), -//! [`short_window_variance`](stages::short_window_variance), -//! [`subtract_baseline`](stages::subtract_baseline). Failable stages report -//! [`DspError`](stages::DspError). -//! * [`features`] — frame/window-level scalar features: -//! [`motion_energy`](features::motion_energy) / -//! [`motion_energy_series`](features::motion_energy_series), -//! [`presence_score`](features::presence_score), -//! [`confidence_score`](features::confidence_score), -//! [`breathing_band_estimate`](features::breathing_band_estimate) (heuristic, -//! FFT-free, meant to be quality-gated by the caller). -//! * [`pipeline`] — the [`SignalPipeline`](pipeline::SignalPipeline): a tiny -//! configuration bag with a non-destructive `process_frame` step that cleans a -//! [`rvcsi_core::CsiFrame`]'s `amplitude` / `phase` vectors *after* -//! `rvcsi_core::validate_frame` has run, never touching validation state. -//! -//! Everything here is deterministic: the same input always produces the same -//! output. There are no heavy dependencies — the math is hand-rolled. -//! -//! [ADR-095]: ../../../docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md - -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -pub mod features; -pub mod pipeline; -pub mod stages; - -pub use features::{ - breathing_band_estimate, confidence_score, motion_energy, motion_energy_series, presence_score, -}; -pub use pipeline::SignalPipeline; -pub use stages::{ - ewma, hampel_filter, hampel_filter_count, mean, median, moving_average, remove_dc_offset, - short_window_variance, std_dev, subtract_baseline, unwrap_phase, variance, DspError, -}; diff --git a/v2/crates/rvcsi-dsp/src/pipeline.rs b/v2/crates/rvcsi-dsp/src/pipeline.rs deleted file mode 100644 index 9edf19efe6..0000000000 --- a/v2/crates/rvcsi-dsp/src/pipeline.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! The composable [`SignalPipeline`] (ADR-095 FR4). -//! -//! A pipeline is a small bag of configuration plus a non-destructive -//! `process_frame` step that cleans a [`CsiFrame`]'s `amplitude` / `phase` -//! vectors *after* `rvcsi_core::validate_frame` has run. It deliberately never -//! mutates `validation`, `quality_score`, or `quality_reasons` — those belong to -//! the validation stage, and a DSP cleanup pass must not silently "upgrade" or -//! "downgrade" a frame's trust state. - -use rvcsi_core::CsiFrame; - -use crate::stages::{hampel_filter, moving_average, remove_dc_offset, unwrap_phase}; - -/// Configurable signal-cleaning pipeline applied per frame. -/// -/// The processing order in [`SignalPipeline::process_frame`] is fixed: -/// 1. Hampel outlier filter on `amplitude` -/// 2. centered moving-average smoothing on `amplitude` -/// 3. DC-offset removal on `amplitude` (if [`remove_dc`](Self::remove_dc)) -/// 4. baseline subtraction on `amplitude` (if a learned baseline of matching -/// length is present) -/// 5. phase unwrap on `phase` (if [`unwrap_phase`](Self::unwrap_phase)) -#[derive(Debug, Clone, PartialEq)] -pub struct SignalPipeline { - /// Window length for the moving-average smoothing of amplitude - /// (`0`/`1` disables smoothing). - pub smoothing_window: usize, - /// Half-window for the Hampel outlier filter on amplitude. - pub hampel_half_window: usize, - /// Outlier threshold (in robust sigmas) for the Hampel filter. - pub hampel_n_sigmas: f32, - /// Whether to unwrap the phase vector. - pub unwrap_phase: bool, - /// Whether to subtract the DC offset (mean) from the amplitude vector. - pub remove_dc: bool, - /// Optional learned per-subcarrier baseline amplitude; subtracted from - /// `amplitude` when its length matches the frame's subcarrier count. - pub baseline_amplitude: Option>, -} - -impl Default for SignalPipeline { - fn default() -> Self { - SignalPipeline { - smoothing_window: 3, - hampel_half_window: 3, - hampel_n_sigmas: 3.0, - unwrap_phase: true, - remove_dc: true, - baseline_amplitude: None, - } - } -} - -impl SignalPipeline { - /// Construct a pipeline with the [default](Self::default) configuration. - pub fn new() -> Self { - Self::default() - } - - /// Builder-style setter for [`smoothing_window`](Self::smoothing_window). - pub fn with_smoothing_window(mut self, window: usize) -> Self { - self.smoothing_window = window; - self - } - - /// Builder-style setter for the Hampel half-window. - pub fn with_hampel_half_window(mut self, half_window: usize) -> Self { - self.hampel_half_window = half_window; - self - } - - /// Builder-style setter for the Hampel sigma threshold. - pub fn with_hampel_n_sigmas(mut self, n_sigmas: f32) -> Self { - self.hampel_n_sigmas = n_sigmas; - self - } - - /// Builder-style setter for [`unwrap_phase`](Self::unwrap_phase). - pub fn with_unwrap_phase(mut self, on: bool) -> Self { - self.unwrap_phase = on; - self - } - - /// Builder-style setter for [`remove_dc`](Self::remove_dc). - pub fn with_remove_dc(mut self, on: bool) -> Self { - self.remove_dc = on; - self - } - - /// Builder-style setter for an explicit baseline amplitude vector. - pub fn with_baseline_amplitude(mut self, baseline: Option>) -> Self { - self.baseline_amplitude = baseline; - self - } - - /// Clean a frame's `amplitude` and `phase` vectors in place. - /// - /// See the [type docs](SignalPipeline) for the fixed processing order. This - /// method does **not** read or write `frame.validation`, - /// `frame.quality_score`, or `frame.quality_reasons`, and is a no-op for a - /// frame with `subcarrier_count == 0`. The lengths of `amplitude` and - /// `phase` are preserved. - pub fn process_frame(&self, frame: &mut CsiFrame) { - if frame.subcarrier_count == 0 || frame.amplitude.is_empty() { - return; - } - - // 1. Hampel outlier rejection on amplitude. - if self.hampel_half_window > 0 { - frame.amplitude = - hampel_filter(&frame.amplitude, self.hampel_half_window, self.hampel_n_sigmas); - } - - // 2. Moving-average smoothing on amplitude. - if self.smoothing_window > 1 { - frame.amplitude = moving_average(&frame.amplitude, self.smoothing_window); - } - - // 3. DC-offset removal on amplitude. - if self.remove_dc { - remove_dc_offset(&mut frame.amplitude); - } - - // 4. Baseline subtraction (only when lengths match). - if let Some(baseline) = &self.baseline_amplitude { - if baseline.len() == frame.amplitude.len() { - for (a, b) in frame.amplitude.iter_mut().zip(baseline.iter()) { - *a -= *b; - } - } - } - - // 5. Phase unwrap. - if self.unwrap_phase { - unwrap_phase(&mut frame.phase); - } - } - - /// Learn a per-subcarrier baseline amplitude from a batch of frames. - /// - /// Sets [`baseline_amplitude`](Self::baseline_amplitude) to the element-wise - /// mean amplitude over the supplied frames, considering only frames whose - /// `subcarrier_count` equals the first frame's and whose `amplitude` vector - /// is non-empty. A no-op when `frames` is empty (or yields no usable frame). - pub fn learn_baseline(&mut self, frames: &[CsiFrame]) { - let Some(first) = frames.iter().find(|f| !f.amplitude.is_empty()) else { - return; - }; - let n = first.amplitude.len(); - let reference_count = first.subcarrier_count; - let mut acc = vec![0.0f32; n]; - let mut used = 0usize; - for f in frames { - if f.subcarrier_count != reference_count || f.amplitude.len() != n { - continue; - } - for (a, &v) in acc.iter_mut().zip(f.amplitude.iter()) { - *a += v; - } - used += 1; - } - if used == 0 { - return; - } - let used_f = used as f32; - for a in acc.iter_mut() { - *a /= used_f; - } - self.baseline_amplitude = Some(acc); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{AdapterKind, FrameId, SessionId, SourceId, ValidationStatus}; - - fn frame_with_amplitude(amp: Vec) -> CsiFrame { - let n = amp.len(); - // Build a frame from I/Q so phase/amplitude are consistent, then - // overwrite amplitude with the test fixture. - let i: Vec = amp.clone(); - let q: Vec = vec![0.0; n]; - let mut f = CsiFrame::from_iq( - FrameId(1), - SessionId(1), - SourceId::from("pipe-test"), - AdapterKind::Synthetic, - 10_000, - 6, - 20, - i, - q, - ); - f.amplitude = amp; - f.phase = vec![0.0; n]; - // Pretend validation already ran. - f.validation = ValidationStatus::Accepted; - f.quality_score = 0.77; - f.quality_reasons = vec!["fixture".to_string()]; - f - } - - #[test] - fn process_frame_removes_spike_and_preserves_validation() { - let mut f = frame_with_amplitude(vec![5.0, 5.0, 5.0, 200.0, 5.0, 5.0, 5.0]); - let n_before = f.amplitude.len(); - let pipe = SignalPipeline::default(); - pipe.process_frame(&mut f); - assert_eq!(f.amplitude.len(), n_before); - assert_eq!(f.phase.len(), n_before); - // The huge spike must be gone: after hampel+smoothing+DC removal the - // amplitude should be near zero everywhere (constant signal -> ~0 mean). - for v in &f.amplitude { - assert!(v.abs() < 1.0, "spike not removed, residual {v}"); - } - // Validation state untouched. - assert_eq!(f.validation, ValidationStatus::Accepted); - assert!((f.quality_score - 0.77).abs() < 1e-6); - assert_eq!(f.quality_reasons, vec!["fixture".to_string()]); - } - - #[test] - fn process_frame_is_noop_on_empty_frame() { - let mut f = CsiFrame::from_iq( - FrameId(2), - SessionId(1), - SourceId::from("empty"), - AdapterKind::Synthetic, - 1, - 6, - 20, - Vec::new(), - Vec::new(), - ); - f.validation = ValidationStatus::Degraded; - let pipe = SignalPipeline::default(); - pipe.process_frame(&mut f); - assert!(f.amplitude.is_empty()); - assert!(f.phase.is_empty()); - assert_eq!(f.validation, ValidationStatus::Degraded); - } - - #[test] - fn unwrap_phase_can_be_disabled() { - let mut f = frame_with_amplitude(vec![1.0, 1.0, 1.0, 1.0]); - f.phase = vec![0.0, 3.0, -3.0, 0.0]; - let pipe = SignalPipeline::default() - .with_unwrap_phase(false) - .with_hampel_half_window(0) - .with_smoothing_window(0) - .with_remove_dc(false); - pipe.process_frame(&mut f); - // phase left exactly as-is - assert_eq!(f.phase, vec![0.0, 3.0, -3.0, 0.0]); - // amplitude untouched too - assert_eq!(f.amplitude, vec![1.0, 1.0, 1.0, 1.0]); - } - - #[test] - fn learn_baseline_then_process_subtracts_it() { - // Three frames whose mean amplitude is [2, 4, 6, 8]. - let frames = vec![ - frame_with_amplitude(vec![1.0, 3.0, 5.0, 7.0]), - frame_with_amplitude(vec![2.0, 4.0, 6.0, 8.0]), - frame_with_amplitude(vec![3.0, 5.0, 7.0, 9.0]), - ]; - let mut pipe = SignalPipeline::default() - .with_hampel_half_window(0) - .with_smoothing_window(0); - pipe.learn_baseline(&frames); - assert_eq!(pipe.baseline_amplitude, Some(vec![2.0, 4.0, 6.0, 8.0])); - - // Process a frame equal to the baseline. After DC removal (mean 5 -> - // [-3,-1,1,3]) then baseline subtraction ([-3-2,-1-4,1-6,3-8] = - // [-5,-5,-5,-5]) — the point is just that it's "small" and bounded. - let mut f = frame_with_amplitude(vec![2.0, 4.0, 6.0, 8.0]); - pipe.process_frame(&mut f); - assert_eq!(f.amplitude.len(), 4); - for v in &f.amplitude { - assert!(v.abs() < 10.0, "baseline-subtracted residual too large: {v}"); - } - // With DC removal turned off, a frame equal to the baseline goes to - // exactly zero. - let mut pipe2 = pipe.clone(); - pipe2.remove_dc = false; - let mut f2 = frame_with_amplitude(vec![2.0, 4.0, 6.0, 8.0]); - pipe2.process_frame(&mut f2); - for v in &f2.amplitude { - assert!(v.abs() < 1e-5, "expected ~0, got {v}"); - } - } - - #[test] - fn learn_baseline_ignores_mismatched_and_empty() { - let frames = vec![ - frame_with_amplitude(vec![2.0, 2.0, 2.0]), - frame_with_amplitude(vec![1.0, 2.0]), // wrong length -> ignored - frame_with_amplitude(vec![4.0, 4.0, 4.0]), - ]; - let mut pipe = SignalPipeline::default(); - pipe.learn_baseline(&frames); - assert_eq!(pipe.baseline_amplitude, Some(vec![3.0, 3.0, 3.0])); - - // empty input -> no change - let mut pipe2 = SignalPipeline::default(); - pipe2.learn_baseline(&[]); - assert_eq!(pipe2.baseline_amplitude, None); - } - - #[test] - fn pipeline_is_deterministic() { - let make = || frame_with_amplitude(vec![5.0, 6.0, 7.0, 50.0, 7.0, 6.0, 5.0]); - let pipe = SignalPipeline::default(); - let mut a = make(); - let mut b = make(); - pipe.process_frame(&mut a); - pipe.process_frame(&mut b); - assert_eq!(a.amplitude, b.amplitude); - assert_eq!(a.phase, b.phase); - } -} diff --git a/v2/crates/rvcsi-dsp/src/stages.rs b/v2/crates/rvcsi-dsp/src/stages.rs deleted file mode 100644 index 3679bfc8c9..0000000000 --- a/v2/crates/rvcsi-dsp/src/stages.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! Pure per-vector DSP primitives (ADR-095 FR4). -//! -//! Every function here is deterministic and operates on plain `&[f32]` / -//! `&mut [f32]` slices — no allocation-heavy dependencies, no hidden state. -//! Errors are reported via [`DspError`]. - -use core::f32::consts::PI; - -use thiserror::Error; - -/// Errors produced by DSP stages that can fail. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum DspError { - /// Two slices that were required to be the same length were not. - #[error("length mismatch: {a} vs {b}")] - LengthMismatch { - /// Length of the first slice. - a: usize, - /// Length of the second slice. - b: usize, - }, - /// An operation that requires at least one sample received an empty slice. - #[error("empty input")] - EmptyInput, -} - -/// Arithmetic mean of the slice. Returns `0.0` for an empty slice. -pub fn mean(xs: &[f32]) -> f32 { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f32 - } -} - -/// Population variance (divides by `n`, not `n - 1`). Returns `0.0` for an -/// empty slice. -pub fn variance(xs: &[f32]) -> f32 { - if xs.is_empty() { - return 0.0; - } - let m = mean(xs); - xs.iter().map(|x| { - let d = x - m; - d * d - }).sum::() - / xs.len() as f32 -} - -/// Population standard deviation. Returns `0.0` for an empty slice. -pub fn std_dev(xs: &[f32]) -> f32 { - variance(xs).sqrt() -} - -/// Median of the slice (clones and sorts internally). Returns `0.0` for an -/// empty slice. For an even count, returns the average of the two central -/// values. -pub fn median(xs: &[f32]) -> f32 { - if xs.is_empty() { - return 0.0; - } - let mut v = xs.to_vec(); - v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)); - let n = v.len(); - if n % 2 == 1 { - v[n / 2] - } else { - 0.5 * (v[n / 2 - 1] + v[n / 2]) - } -} - -/// Subtract the mean of the slice from every element, in place. -pub fn remove_dc_offset(xs: &mut [f32]) { - let m = mean(xs); - for x in xs.iter_mut() { - *x -= m; - } -} - -/// In-place 1-D phase unwrap. -/// -/// Walks left→right; whenever the raw step `phase[i] - phase[i-1]` exceeds -/// `+PI` we accumulate a `-2*PI` correction, and whenever it is below `-PI` -/// we accumulate a `+2*PI` correction. The running correction is added to -/// every subsequent sample, producing a continuous series with no step larger -/// than `PI` in magnitude. -pub fn unwrap_phase(phase: &mut [f32]) { - if phase.len() < 2 { - return; - } - let mut correction = 0.0f32; - let mut prev_raw = phase[0]; - // We read `phase[i]` and write `phase[i]` in the same step; an index loop - // is the clearest way to express that, hence the lint allowance. - #[allow(clippy::needless_range_loop)] - for i in 1..phase.len() { - let raw = phase[i]; - let step = raw - prev_raw; - if step > PI { - correction -= 2.0 * PI; - } else if step < -PI { - correction += 2.0 * PI; - } - prev_raw = raw; - phase[i] = raw + correction; - } -} - -/// Centered moving average with edge clamping (the window shrinks at the ends). -/// -/// `window == 0 || window == 1` returns a plain copy. The result has the same -/// length as the input. -pub fn moving_average(xs: &[f32], window: usize) -> Vec { - if window <= 1 || xs.is_empty() { - return xs.to_vec(); - } - let half = window / 2; - let n = xs.len(); - let mut out = Vec::with_capacity(n); - for i in 0..n { - let lo = i.saturating_sub(half); - let hi = (i + half + 1).min(n); - let slice = &xs[lo..hi]; - out.push(mean(slice)); - } - out -} - -/// Exponentially-weighted moving average. -/// -/// `y[0] = x[0]`, `y[i] = alpha * x[i] + (1 - alpha) * y[i-1]`. `alpha` is -/// clamped to `(0.0, 1.0]` (values `<= 0` become a tiny positive epsilon, -/// values `> 1` become `1.0`). An empty input yields an empty output. -pub fn ewma(xs: &[f32], alpha: f32) -> Vec { - if xs.is_empty() { - return Vec::new(); - } - let a = if alpha > 1.0 { - 1.0 - } else if alpha <= 0.0 { - f32::EPSILON - } else { - alpha - }; - let mut out = Vec::with_capacity(xs.len()); - let mut y = xs[0]; - out.push(y); - for &x in &xs[1..] { - y = a * x + (1.0 - a) * y; - out.push(y); - } - out -} - -/// Hampel outlier filter. -/// -/// For each index `i`, take the window `[i - half_window, i + half_window]` -/// (clamped to the slice), compute the median `m` and -/// `MAD = 1.4826 * median(|x - m|)`. If `|x[i] - m| > n_sigmas * MAD`, the -/// sample is replaced with `m`; otherwise it is kept. Returns a new `Vec` of -/// the same length. -pub fn hampel_filter(xs: &[f32], half_window: usize, n_sigmas: f32) -> Vec { - hampel_filter_count(xs, half_window, n_sigmas).0 -} - -/// Like [`hampel_filter`] but also reports how many samples were replaced. -pub fn hampel_filter_count(xs: &[f32], half_window: usize, n_sigmas: f32) -> (Vec, usize) { - if xs.is_empty() { - return (Vec::new(), 0); - } - let n = xs.len(); - let mut out = Vec::with_capacity(n); - let mut replaced = 0usize; - for i in 0..n { - let lo = i.saturating_sub(half_window); - let hi = (i + half_window + 1).min(n); - let window = &xs[lo..hi]; - let m = median(window); - let deviations: Vec = window.iter().map(|x| (x - m).abs()).collect(); - let mad = 1.4826 * median(&deviations); - // When `mad == 0` (a majority of the window is identical) the test - // `dev > n_sigmas * 0` reduces to `dev > 0`, i.e. any sample that - // differs from the window median is treated as an outlier — this is the - // standard degenerate-MAD behaviour for the Hampel identifier. - if (xs[i] - m).abs() > n_sigmas * mad { - out.push(m); - replaced += 1; - } else { - out.push(xs[i]); - } - } - (out, replaced) -} - -/// Sliding population variance over a centered window with edge clamping. -/// -/// `window <= 1` produces an all-zero series the same length as the input -/// (a single-sample window has zero variance). The result has the same length -/// as the input. -pub fn short_window_variance(xs: &[f32], window: usize) -> Vec { - let n = xs.len(); - if n == 0 { - return Vec::new(); - } - if window <= 1 { - return vec![0.0; n]; - } - let half = window / 2; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let lo = i.saturating_sub(half); - let hi = (i + half + 1).min(n); - out.push(variance(&xs[lo..hi])); - } - out -} - -/// Elementwise `current - baseline`. Errors if the lengths differ. -pub fn subtract_baseline(current: &[f32], baseline: &[f32]) -> Result, DspError> { - if current.len() != baseline.len() { - return Err(DspError::LengthMismatch { - a: current.len(), - b: baseline.len(), - }); - } - Ok(current - .iter() - .zip(baseline.iter()) - .map(|(c, b)| c - b) - .collect()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn approx(a: f32, b: f32) { - assert!((a - b).abs() < 1e-5, "{a} !~= {b}"); - } - - #[test] - fn mean_variance_median_basic() { - let xs = [1.0, 2.0, 3.0, 4.0]; - approx(mean(&xs), 2.5); - // population variance of 1..4: mean 2.5, devs^2 = 2.25,0.25,0.25,2.25 -> 5/4 = 1.25 - approx(variance(&xs), 1.25); - approx(std_dev(&xs), 1.25f32.sqrt()); - // even-count median: avg of 2 and 3 - approx(median(&xs), 2.5); - approx(median(&[3.0, 1.0, 2.0]), 2.0); - } - - #[test] - fn empty_inputs_are_zero() { - approx(mean(&[]), 0.0); - approx(variance(&[]), 0.0); - approx(std_dev(&[]), 0.0); - approx(median(&[]), 0.0); - } - - #[test] - fn remove_dc_offset_centers() { - let mut xs = [1.0, 2.0, 3.0, 4.0]; - remove_dc_offset(&mut xs); - approx(mean(&xs), 0.0); - approx(xs[0], -1.5); - approx(xs[3], 1.5); - } - - #[test] - fn unwrap_phase_is_continuous() { - // raw: 0, 3, -3, 0. step 3->-3 is -6 < -PI so +2PI; etc. - let mut p = [0.0f32, 3.0, -3.0, 0.0]; - unwrap_phase(&mut p); - for w in p.windows(2) { - assert!((w[1] - w[0]).abs() <= PI + 1e-5, "jump too big: {w:?}"); - } - // first sample untouched - approx(p[0], 0.0); - } - - #[test] - fn unwrap_phase_short_slices() { - let mut a: [f32; 0] = []; - unwrap_phase(&mut a); - let mut b = [1.23f32]; - unwrap_phase(&mut b); - approx(b[0], 1.23); - } - - #[test] - fn moving_average_window_three() { - // [1,2,3,4,5], window 3, half=1, edge clamp: - // i=0: [1,2] -> 1.5 - // i=1: [1,2,3] -> 2 - // i=2: [2,3,4] -> 3 - // i=3: [3,4,5] -> 4 - // i=4: [4,5] -> 4.5 - let out = moving_average(&[1.0, 2.0, 3.0, 4.0, 5.0], 3); - assert_eq!(out.len(), 5); - approx(out[0], 1.5); - approx(out[1], 2.0); - approx(out[2], 3.0); - approx(out[3], 4.0); - approx(out[4], 4.5); - } - - #[test] - fn moving_average_window_one_is_copy() { - let xs = [1.0, 2.0, 3.0]; - assert_eq!(moving_average(&xs, 1), xs.to_vec()); - assert_eq!(moving_average(&xs, 0), xs.to_vec()); - } - - #[test] - fn ewma_first_element_and_alpha_one() { - let xs = [2.0, 4.0, 8.0]; - let out = ewma(&xs, 0.5); - approx(out[0], 2.0); - approx(out[1], 0.5 * 4.0 + 0.5 * 2.0); // 3.0 - approx(out[2], 0.5 * 8.0 + 0.5 * 3.0); // 5.5 - // alpha = 1.0 -> copy - assert_eq!(ewma(&xs, 1.0), xs.to_vec()); - // clamped: alpha > 1 also a copy - assert_eq!(ewma(&xs, 5.0), xs.to_vec()); - // empty - assert!(ewma(&[], 0.5).is_empty()); - } - - #[test] - fn hampel_replaces_spike() { - let xs = [1.0, 1.0, 1.0, 100.0, 1.0, 1.0, 1.0]; - let (out, count) = hampel_filter_count(&xs, 3, 3.0); - approx(out[3], 1.0); - assert_eq!(count, 1); - // all other points unchanged - for i in [0, 1, 2, 4, 5, 6] { - approx(out[i], 1.0); - } - // hampel_filter agrees - assert_eq!(hampel_filter(&xs, 3, 3.0), out); - } - - #[test] - fn hampel_clean_signal_unchanged() { - let xs = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; - let (out, count) = hampel_filter_count(&xs, 2, 3.0); - assert_eq!(count, 0); - assert_eq!(out, xs.to_vec()); - } - - #[test] - fn hampel_empty() { - let (out, count) = hampel_filter_count(&[], 2, 3.0); - assert!(out.is_empty()); - assert_eq!(count, 0); - } - - #[test] - fn short_window_variance_constant_is_zero() { - let xs = [5.0; 8]; - let out = short_window_variance(&xs, 3); - assert_eq!(out.len(), 8); - for v in out { - approx(v, 0.0); - } - // window 1 -> all zeros - let out2 = short_window_variance(&xs, 1); - assert_eq!(out2, vec![0.0; 8]); - assert!(short_window_variance(&[], 3).is_empty()); - } - - #[test] - fn short_window_variance_nonconstant() { - // [0, 0, 9], window 3, half 1: - // i=0: [0,0] var 0 - // i=1: [0,0,9] mean 3, devs^2 9,9,36 -> 54/3 = 18 - // i=2: [0,9] mean 4.5, devs^2 20.25,20.25 -> 40.5/2 = 20.25 - let out = short_window_variance(&[0.0, 0.0, 9.0], 3); - approx(out[0], 0.0); - approx(out[1], 18.0); - approx(out[2], 20.25); - } - - #[test] - fn subtract_baseline_works_and_errors() { - let c = [3.0, 5.0, 7.0]; - let b = [1.0, 2.0, 3.0]; - let out = subtract_baseline(&c, &b).unwrap(); - assert_eq!(out, vec![2.0, 3.0, 4.0]); - let err = subtract_baseline(&c, &[1.0, 2.0]).unwrap_err(); - assert_eq!(err, DspError::LengthMismatch { a: 3, b: 2 }); - } -} diff --git a/v2/crates/rvcsi-events/Cargo.toml b/v2/crates/rvcsi-events/Cargo.toml deleted file mode 100644 index b374bceb8b..0000000000 --- a/v2/crates/rvcsi-events/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "rvcsi-events" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI events — window aggregation + presence/motion/anomaly state machines producing CsiEvent (ADR-095 FR5)" -repository.workspace = true -keywords = ["wifi", "csi", "events", "rvcsi"] -categories = ["science"] - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/v2/crates/rvcsi-events/src/detectors.rs b/v2/crates/rvcsi-events/src/detectors.rs deleted file mode 100644 index 4df1252161..0000000000 --- a/v2/crates/rvcsi-events/src/detectors.rs +++ /dev/null @@ -1,858 +0,0 @@ -//! Event detectors — small deterministic state machines over [`CsiWindow`]s. -//! -//! Every detector implements [`EventDetector`]; an [`crate::EventPipeline`] -//! runs each in turn on every closed window and concatenates the emitted -//! [`CsiEvent`]s. Detectors are intentionally tiny and side-effect-free: the -//! only state they keep is the bare minimum to debounce / hysteresis-gate, so -//! replaying the same window stream is fully deterministic. - -use rvcsi_core::{CsiEvent, CsiEventKind, CsiWindow, IdGenerator, WindowId}; - -/// Consumes [`CsiWindow`]s and emits [`CsiEvent`]s. -pub trait EventDetector { - /// Process one window; return any events it triggers (possibly empty). - fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec; - - /// Stable name for logging / inspection. - fn name(&self) -> &'static str; -} - -/// Build a single-window-evidence [`CsiEvent`] (validated in debug builds). -fn make_event( - ids: &IdGenerator, - kind: CsiEventKind, - window: &CsiWindow, - timestamp_ns: u64, - confidence: f32, -) -> CsiEvent { - let evidence: Vec = vec![window.window_id]; - let confidence = confidence.clamp(0.0, 1.0); - let event = CsiEvent::new( - ids.next_event(), - kind, - window.session_id, - window.source_id.clone(), - timestamp_ns, - confidence, - evidence, - ); - debug_assert!( - event.validate().is_ok(), - "detector produced an invalid CsiEvent: {:?}", - event.validate() - ); - event -} - -// --------------------------------------------------------------------------- -// PresenceDetector -// --------------------------------------------------------------------------- - -/// Tunables for [`PresenceDetector`]. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PresenceConfig { - /// Enter `Present` when `presence_score >= on_threshold` for `enter_windows` windows. - pub on_threshold: f32, - /// Exit to `Absent` when `presence_score <= off_threshold` for `exit_windows` windows. - pub off_threshold: f32, - /// Consecutive high windows required to declare presence. - pub enter_windows: u32, - /// Consecutive low windows required to declare absence. - pub exit_windows: u32, -} - -impl Default for PresenceConfig { - fn default() -> Self { - // A truly quiet window has `presence_score ≈ 0.40` (the - // `WindowBuffer` logistic floor at zero motion), so `off_threshold` - // sits just above that and `on_threshold` well above it. - PresenceConfig { - on_threshold: 0.7, - off_threshold: 0.45, - enter_windows: 2, - exit_windows: 3, - } - } -} - -impl PresenceConfig { - /// Validate the relationship `on_threshold > off_threshold` and positivity. - fn checked(self) -> Self { - assert!( - self.on_threshold > self.off_threshold, - "PresenceConfig requires on_threshold > off_threshold" - ); - assert!(self.enter_windows >= 1 && self.exit_windows >= 1); - self - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum PresenceState { - Absent, - Present, -} - -/// Hysteresis state machine over [`CsiWindow::presence_score`]. -/// -/// Emits a single [`CsiEventKind::PresenceStarted`] when the score has been -/// high for `enter_windows` consecutive windows, and a single -/// [`CsiEventKind::PresenceEnded`] when it has been low for `exit_windows` -/// consecutive windows. A window that breaks the streak resets the counter. -#[derive(Debug, Clone)] -pub struct PresenceDetector { - cfg: PresenceConfig, - state: PresenceState, - streak: u32, -} - -impl Default for PresenceDetector { - fn default() -> Self { - Self::new() - } -} - -impl PresenceDetector { - /// New detector with default thresholds. - pub fn new() -> Self { - Self::with_config(PresenceConfig::default()) - } - - /// New detector with explicit config. - /// - /// # Panics - /// Panics if `on_threshold <= off_threshold` or a window count is zero. - pub fn with_config(cfg: PresenceConfig) -> Self { - PresenceDetector { - cfg: cfg.checked(), - state: PresenceState::Absent, - streak: 0, - } - } -} - -impl EventDetector for PresenceDetector { - fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec { - let p = window.presence_score; - match self.state { - PresenceState::Absent => { - if p >= self.cfg.on_threshold { - self.streak += 1; - if self.streak >= self.cfg.enter_windows { - self.state = PresenceState::Present; - self.streak = 0; - return vec![make_event( - ids, - CsiEventKind::PresenceStarted, - window, - window.end_ns, - p, - )]; - } - } else { - self.streak = 0; - } - } - PresenceState::Present => { - if p <= self.cfg.off_threshold { - self.streak += 1; - if self.streak >= self.cfg.exit_windows { - self.state = PresenceState::Absent; - self.streak = 0; - return vec![make_event( - ids, - CsiEventKind::PresenceEnded, - window, - window.end_ns, - (1.0 - p).clamp(0.0, 1.0), - )]; - } - } else { - self.streak = 0; - } - } - } - Vec::new() - } - - fn name(&self) -> &'static str { - "presence" - } -} - -// --------------------------------------------------------------------------- -// MotionDetector -// --------------------------------------------------------------------------- - -/// Tunables for [`MotionDetector`]. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MotionConfig { - /// Rising-edge threshold on `motion_energy`. - pub on_threshold: f32, - /// Falling-edge threshold on `motion_energy` (`< on_threshold`). - pub off_threshold: f32, - /// Consecutive windows above/below the relevant threshold before firing. - pub debounce_windows: u32, -} - -impl Default for MotionConfig { - fn default() -> Self { - MotionConfig { - on_threshold: 0.05, - off_threshold: 0.02, - debounce_windows: 2, - } - } -} - -impl MotionConfig { - fn checked(self) -> Self { - assert!( - self.on_threshold > self.off_threshold, - "MotionConfig requires on_threshold > off_threshold" - ); - assert!(self.debounce_windows >= 1); - self - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum MotionState { - Settled, - Moving, -} - -/// State machine over [`CsiWindow::motion_energy`]. -/// -/// Emits [`CsiEventKind::MotionDetected`] on a debounced rising edge and -/// [`CsiEventKind::MotionSettled`] on a debounced falling edge. -#[derive(Debug, Clone)] -pub struct MotionDetector { - cfg: MotionConfig, - state: MotionState, - streak: u32, -} - -impl Default for MotionDetector { - fn default() -> Self { - Self::new() - } -} - -impl MotionDetector { - /// New detector with default thresholds. - pub fn new() -> Self { - Self::with_config(MotionConfig::default()) - } - - /// New detector with explicit config. - /// - /// # Panics - /// Panics if `on_threshold <= off_threshold` or `debounce_windows == 0`. - pub fn with_config(cfg: MotionConfig) -> Self { - MotionDetector { - cfg: cfg.checked(), - state: MotionState::Settled, - streak: 0, - } - } -} - -impl EventDetector for MotionDetector { - fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec { - let m = window.motion_energy; - match self.state { - MotionState::Settled => { - if m > self.cfg.on_threshold { - self.streak += 1; - if self.streak >= self.cfg.debounce_windows { - self.state = MotionState::Moving; - self.streak = 0; - let conf = (m / (2.0 * self.cfg.on_threshold)).clamp(0.0, 1.0); - return vec![make_event( - ids, - CsiEventKind::MotionDetected, - window, - window.end_ns, - conf, - )]; - } - } else { - self.streak = 0; - } - } - MotionState::Moving => { - if m < self.cfg.off_threshold { - self.streak += 1; - if self.streak >= self.cfg.debounce_windows { - self.state = MotionState::Settled; - self.streak = 0; - let rise = (m / (2.0 * self.cfg.on_threshold)).clamp(0.0, 1.0); - return vec![make_event( - ids, - CsiEventKind::MotionSettled, - window, - window.end_ns, - (1.0 - rise).clamp(0.0, 1.0), - )]; - } - } else { - self.streak = 0; - } - } - } - Vec::new() - } - - fn name(&self) -> &'static str { - "motion" - } -} - -// --------------------------------------------------------------------------- -// QualityDetector -// --------------------------------------------------------------------------- - -/// Tunables for [`QualityDetector`]. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct QualityConfig { - /// `quality_score` below this (debounced) raises [`CsiEventKind::SignalQualityDropped`]. - pub drop_threshold: f32, - /// Consecutive low windows before [`CsiEventKind::SignalQualityDropped`] fires. - pub debounce_windows: u32, - /// Consecutive low windows (counting from the first low one) before - /// [`CsiEventKind::CalibrationRequired`] also fires — once per low stretch. - pub calib_windows: u32, -} - -impl Default for QualityConfig { - fn default() -> Self { - QualityConfig { - drop_threshold: 0.4, - debounce_windows: 2, - calib_windows: 4, - } - } -} - -impl QualityConfig { - fn checked(self) -> Self { - assert!(self.debounce_windows >= 1 && self.calib_windows >= 1); - self - } -} - -/// State machine over [`CsiWindow::quality_score`]. -/// -/// While `quality_score` stays below `drop_threshold` it counts a low streak. -/// At `debounce_windows` it emits [`CsiEventKind::SignalQualityDropped`]; at -/// `calib_windows` it additionally emits [`CsiEventKind::CalibrationRequired`] -/// (only once until quality recovers). Any window at or above `drop_threshold` -/// resets the streak and re-arms both events. -#[derive(Debug, Clone)] -pub struct QualityDetector { - cfg: QualityConfig, - low_streak: u32, - dropped_emitted: bool, - calib_emitted: bool, -} - -impl Default for QualityDetector { - fn default() -> Self { - Self::new() - } -} - -impl QualityDetector { - /// New detector with default thresholds. - pub fn new() -> Self { - Self::with_config(QualityConfig::default()) - } - - /// New detector with explicit config. - pub fn with_config(cfg: QualityConfig) -> Self { - QualityDetector { - cfg: cfg.checked(), - low_streak: 0, - dropped_emitted: false, - calib_emitted: false, - } - } -} - -impl EventDetector for QualityDetector { - fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec { - let q = window.quality_score; - if q < self.cfg.drop_threshold { - self.low_streak += 1; - let mut out = Vec::new(); - if !self.dropped_emitted && self.low_streak >= self.cfg.debounce_windows { - self.dropped_emitted = true; - out.push(make_event( - ids, - CsiEventKind::SignalQualityDropped, - window, - window.end_ns, - (1.0 - q).clamp(0.0, 1.0), - )); - } - if !self.calib_emitted && self.low_streak >= self.cfg.calib_windows { - self.calib_emitted = true; - out.push(make_event( - ids, - CsiEventKind::CalibrationRequired, - window, - window.end_ns, - (1.0 - q).clamp(0.0, 1.0), - )); - } - out - } else { - self.low_streak = 0; - self.dropped_emitted = false; - self.calib_emitted = false; - Vec::new() - } - } - - fn name(&self) -> &'static str { - "quality" - } -} - -// --------------------------------------------------------------------------- -// BaselineDriftDetector -// --------------------------------------------------------------------------- - -/// Tunables for [`BaselineDriftDetector`]. -/// -/// `drift_threshold` and `anomaly_threshold` are **relative** — they are -/// fractions of the running baseline's RMS magnitude, not absolute amplitude -/// units. This keeps the detector source-agnostic: ESP32 emits raw `int8` I/Q -/// (amplitudes up to ~128), Nexmon emits `int16`-scaled CSI, and a -/// baseline-subtracted pipeline emits values near zero — an *absolute* threshold -/// can only ever be right for one of them, a *relative* one is right for all. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct BaselineDriftConfig { - /// Relative per-window drift `||mean_amplitude - baseline||_2 / ||baseline||_2` - /// above this for `drift_windows` windows in a row triggers - /// [`CsiEventKind::BaselineChanged`]. `0.15` ≈ "the room moved ~15 %". - pub drift_threshold: f32, - /// Consecutive drifting windows before [`CsiEventKind::BaselineChanged`] fires. - pub drift_windows: u32, - /// A single window whose relative drift exceeds this (much larger) value - /// triggers [`CsiEventKind::AnomalyDetected`]. `1.0` ≈ "this window differs - /// from the baseline by as much as the baseline's own magnitude". - pub anomaly_threshold: f32, - /// EWMA smoothing factor for the running baseline (`baseline = a*current + (1-a)*baseline`). - pub ewma_alpha: f32, -} - -impl Default for BaselineDriftConfig { - fn default() -> Self { - BaselineDriftConfig { - drift_threshold: 0.15, - drift_windows: 3, - anomaly_threshold: 1.0, - ewma_alpha: 0.1, - } - } -} - -impl BaselineDriftConfig { - fn checked(self) -> Self { - assert!(self.drift_windows >= 1); - assert!(self.anomaly_threshold > self.drift_threshold); - assert!(self.ewma_alpha > 0.0 && self.ewma_alpha <= 1.0); - self - } -} - -/// Tracks an EWMA baseline of `mean_amplitude` and flags sustained drift / -/// single-window anomalies. -#[derive(Debug, Clone)] -pub struct BaselineDriftDetector { - cfg: BaselineDriftConfig, - baseline: Option>, - drift_streak: u32, -} - -impl Default for BaselineDriftDetector { - fn default() -> Self { - Self::new() - } -} - -impl BaselineDriftDetector { - /// New detector with default thresholds. - pub fn new() -> Self { - Self::with_config(BaselineDriftConfig::default()) - } - - /// New detector with explicit config. - pub fn with_config(cfg: BaselineDriftConfig) -> Self { - BaselineDriftDetector { - cfg: cfg.checked(), - baseline: None, - drift_streak: 0, - } - } - - /// L2 distance between two equal-length vectors, normalized by `sqrt(len)`. - fn rms_distance(a: &[f32], b: &[f32]) -> f32 { - let n = a.len(); - if n == 0 { - return 0.0; - } - let mut sq = 0.0f64; - for k in 0..n { - let d = (a[k] - b[k]) as f64; - sq += d * d; - } - (sq.sqrt() / (n as f64).sqrt()) as f32 - } - - /// Root-mean-square magnitude of a vector (`0.0` for an empty one). - fn rms(v: &[f32]) -> f32 { - let n = v.len(); - if n == 0 { - return 0.0; - } - let sq: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum(); - (sq.sqrt() / (n as f64).sqrt()) as f32 - } - - /// Drift of `current` from `baseline` as a fraction of the baseline's RMS - /// magnitude. Source-agnostic (see [`BaselineDriftConfig`]). The `eps` floor - /// keeps a near-zero baseline (e.g. just after a baseline-subtraction stage) - /// from blowing the ratio up to infinity — when the baseline carries - /// essentially no energy there is nothing to drift *relative to*, so the - /// detector treats it as quiet. - fn relative_drift(current: &[f32], baseline: &[f32]) -> f32 { - let abs_drift = Self::rms_distance(current, baseline); - let baseline_rms = Self::rms(baseline); - // 1e-3 is well below any real CSI amplitude scale (ESP32 int8 ⇒ O(10), - // Nexmon int16 ⇒ O(100s)) yet above f32 noise. - const EPS: f32 = 1e-3; - if baseline_rms <= EPS { - // Degenerate baseline: fall back to an absolute reading so a sudden - // jump away from a flat-zero baseline still registers. - abs_drift - } else { - abs_drift / baseline_rms - } - } - - fn update_ewma(&mut self, current: &[f32]) { - match &mut self.baseline { - None => self.baseline = Some(current.to_vec()), - Some(b) if b.len() != current.len() => { - self.baseline = Some(current.to_vec()); - } - Some(b) => { - let a = self.cfg.ewma_alpha; - for k in 0..b.len() { - b[k] = a * current[k] + (1.0 - a) * b[k]; - } - } - } - } -} - -impl EventDetector for BaselineDriftDetector { - fn on_window(&mut self, window: &CsiWindow, ids: &IdGenerator) -> Vec { - let current = &window.mean_amplitude; - let baseline = match &self.baseline { - None => { - // First window establishes the baseline; no drift possible yet. - self.baseline = Some(current.clone()); - return Vec::new(); - } - Some(b) if b.len() != current.len() => { - // Subcarrier count changed — reset and skip this window. - self.baseline = Some(current.clone()); - self.drift_streak = 0; - return Vec::new(); - } - Some(b) => b.clone(), - }; - - let drift = Self::relative_drift(current, &baseline); - let mut out = Vec::new(); - - if drift > self.cfg.anomaly_threshold { - out.push(make_event( - ids, - CsiEventKind::AnomalyDetected, - window, - window.end_ns, - (drift / (2.0 * self.cfg.anomaly_threshold)).clamp(0.0, 1.0), - )); - } - - if drift > self.cfg.drift_threshold { - self.drift_streak += 1; - if self.drift_streak >= self.cfg.drift_windows { - out.push(make_event( - ids, - CsiEventKind::BaselineChanged, - window, - window.end_ns, - (drift / (2.0 * self.cfg.drift_threshold)).clamp(0.0, 1.0), - )); - self.drift_streak = 0; - // Hard-reset the baseline to the new operating point. - self.baseline = Some(current.clone()); - return out; - } - } else { - self.drift_streak = 0; - } - - self.update_ewma(current); - out - } - - fn name(&self) -> &'static str { - "baseline_drift" - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{SessionId, SourceId}; - - fn window(window_id: u64, end_ns: u64, motion: f32, presence: f32, quality: f32) -> CsiWindow { - let end_ns = end_ns.max(1); - CsiWindow { - window_id: WindowId(window_id), - session_id: SessionId(0), - source_id: SourceId::from("s"), - start_ns: end_ns.saturating_sub(1_000), - end_ns, - frame_count: 8, - mean_amplitude: vec![1.0; 8], - phase_variance: vec![0.0; 8], - motion_energy: motion, - presence_score: presence, - quality_score: quality, - } - } - - fn window_amp(window_id: u64, end_ns: u64, amp: Vec) -> CsiWindow { - let n = amp.len(); - CsiWindow { - window_id: WindowId(window_id), - session_id: SessionId(0), - source_id: SourceId::from("s"), - start_ns: 0, - end_ns: end_ns.max(1), - frame_count: 8, - mean_amplitude: amp, - phase_variance: vec![0.0; n], - motion_energy: 0.0, - presence_score: 0.0, - quality_score: 0.9, - } - } - - #[test] - fn presence_detector_emits_started_then_ended() { - let g = IdGenerator::new(); - let mut d = PresenceDetector::with_config(PresenceConfig { - on_threshold: 0.6, - off_threshold: 0.35, - enter_windows: 2, - exit_windows: 3, - }); - let mut events = Vec::new(); - // Low windows. - for k in 0..3u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.05, 0.9), &g)); - } - assert!(events.is_empty()); - // High run -> PresenceStarted after the 2nd one. - for k in 3..8u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.5, 0.95, 0.9), &g)); - } - // Low run -> PresenceEnded after the 3rd low one. - for k in 8..13u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.05, 0.9), &g)); - } - assert_eq!(events.len(), 2, "events = {events:?}"); - assert_eq!(events[0].kind, CsiEventKind::PresenceStarted); - assert_eq!(events[1].kind, CsiEventKind::PresenceEnded); - for e in &events { - assert!(e.validate().is_ok()); - assert!(!e.evidence_window_ids.is_empty()); - assert!((0.0..=1.0).contains(&e.confidence)); - } - } - - #[test] - fn presence_detector_streak_reset() { - let g = IdGenerator::new(); - let mut d = PresenceDetector::new(); - // 1 high, 1 low (resets), then enough highs. - assert!(d.on_window(&window(0, 1_000, 0.0, 0.95, 0.9), &g).is_empty()); - assert!(d.on_window(&window(1, 2_000, 0.0, 0.05, 0.9), &g).is_empty()); - assert!(d.on_window(&window(2, 3_000, 0.0, 0.95, 0.9), &g).is_empty()); - let e = d.on_window(&window(3, 4_000, 0.0, 0.95, 0.9), &g); - assert_eq!(e.len(), 1); - assert_eq!(e[0].kind, CsiEventKind::PresenceStarted); - } - - #[test] - fn motion_detector_emits_detected_then_settled() { - let g = IdGenerator::new(); - let mut d = MotionDetector::with_config(MotionConfig { - on_threshold: 0.05, - off_threshold: 0.02, - debounce_windows: 2, - }); - let mut events = Vec::new(); - for k in 0..2u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.001, 0.0, 0.9), &g)); - } - for k in 2..6u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.3, 0.0, 0.9), &g)); - } - for k in 6..10u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.0, 0.9), &g)); - } - assert_eq!(events.len(), 2, "events = {events:?}"); - assert_eq!(events[0].kind, CsiEventKind::MotionDetected); - assert_eq!(events[1].kind, CsiEventKind::MotionSettled); - for e in &events { - assert!(e.validate().is_ok()); - } - } - - #[test] - fn quality_detector_drop_then_calibration_once() { - let g = IdGenerator::new(); - let mut d = QualityDetector::with_config(QualityConfig { - drop_threshold: 0.4, - debounce_windows: 2, - calib_windows: 4, - }); - let mut events = Vec::new(); - // Good window first. - events.extend(d.on_window(&window(0, 1_000, 0.0, 0.0, 0.9), &g)); - // Low run. - for k in 1..8u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.0, 0.1), &g)); - } - let dropped = events - .iter() - .filter(|e| e.kind == CsiEventKind::SignalQualityDropped) - .count(); - let calib = events - .iter() - .filter(|e| e.kind == CsiEventKind::CalibrationRequired) - .count(); - assert_eq!(dropped, 1, "events = {events:?}"); - assert_eq!(calib, 1, "events = {events:?}"); - for e in &events { - assert!(e.validate().is_ok()); - } - // Recover and drop again -> re-armed. - events.clear(); - events.extend(d.on_window(&window(8, 9_000, 0.0, 0.0, 0.95), &g)); - for k in 9..14u64 { - events.extend(d.on_window(&window(k, (k + 1) * 1_000, 0.0, 0.0, 0.1), &g)); - } - assert_eq!( - events - .iter() - .filter(|e| e.kind == CsiEventKind::SignalQualityDropped) - .count(), - 1 - ); - } - - #[test] - fn baseline_drift_stable_then_shift_then_anomaly() { - let g = IdGenerator::new(); - let mut d = BaselineDriftDetector::with_config(BaselineDriftConfig { - drift_threshold: 0.15, - drift_windows: 3, - anomaly_threshold: 1.0, - ewma_alpha: 0.1, - }); - // Stable baseline -> no events. - let mut events = Vec::new(); - for k in 0..5u64 { - events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, vec![1.0; 8]), &g)); - } - assert!(events.is_empty(), "events = {events:?}"); - // Sustained shift -> BaselineChanged. - for k in 5..10u64 { - events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, vec![1.5; 8]), &g)); - } - assert!( - events.iter().any(|e| e.kind == CsiEventKind::BaselineChanged), - "events = {events:?}" - ); - // Single huge spike -> AnomalyDetected. - events.clear(); - events.extend(d.on_window(&window_amp(10, 11_000, vec![50.0; 8]), &g)); - assert!( - events.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected), - "events = {events:?}" - ); - for e in &events { - assert!(e.validate().is_ok()); - } - } - - #[test] - fn baseline_drift_is_scale_invariant_no_anomaly_storm() { - // Regression for the ESP32 live-capture finding: raw int8 CSI amplitudes - // are O(10–128), so an *absolute* anomaly_threshold of 1.0 fired on - // essentially every window. With a *relative* threshold a few-percent - // wobble around a large baseline must stay quiet. - let g = IdGenerator::new(); - let mut d = BaselineDriftDetector::new(); // defaults: drift 0.15, anomaly 1.0 - // A realistic ESP32-ish window: two big "DC/pilot" subcarriers plus a - // band of small data subcarriers; ±3 % jitter window to window. - let base: Vec = { - let mut v = vec![128.0, 110.0]; - v.extend(std::iter::repeat(15.0).take(68)); - v - }; - let mut events = Vec::new(); - for k in 0..40u64 { - // deterministic small wobble in [-0.03, +0.03] * value - let f = 1.0 + 0.03 * (((k * 2654435761) % 7) as f32 / 3.0 - 1.0); - let w: Vec = base.iter().map(|x| x * f).collect(); - events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, w), &g)); - } - assert!( - !events.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected), - "a ±3% wobble around a large baseline must not be an anomaly; got {events:?}" - ); - // A 5x jump on the data subcarriers (a person walks in) *is* an anomaly. - let spike: Vec = { - let mut v = vec![128.0, 110.0]; - v.extend(std::iter::repeat(75.0).take(68)); - v - }; - let ev = d.on_window(&window_amp(99, 100_000, spike), &g); - assert!( - ev.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected), - "a 5x jump on the data band should register; got {ev:?}" - ); - } - - #[test] - fn baseline_drift_resets_on_subcarrier_change() { - let g = IdGenerator::new(); - let mut d = BaselineDriftDetector::new(); - assert!(d.on_window(&window_amp(0, 1_000, vec![1.0; 8]), &g).is_empty()); - // Different length -> reset, no event. - assert!(d.on_window(&window_amp(1, 2_000, vec![1.0; 16]), &g).is_empty()); - assert!(d.on_window(&window_amp(2, 3_000, vec![1.0; 16]), &g).is_empty()); - } -} diff --git a/v2/crates/rvcsi-events/src/lib.rs b/v2/crates/rvcsi-events/src/lib.rs deleted file mode 100644 index bdc742047c..0000000000 --- a/v2/crates/rvcsi-events/src/lib.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! # rvCSI events — window aggregation + semantic event extraction (ADR-095 FR5) -//! -//! This crate turns a stream of validated [`rvcsi_core::CsiFrame`]s into -//! [`rvcsi_core::CsiWindow`]s and then into [`rvcsi_core::CsiEvent`]s. -//! -//! The pipeline has three layers: -//! -//! 1. [`WindowBuffer`] — buffers exposable frames from one -//! `(session_id, source_id)` and emits a [`rvcsi_core::CsiWindow`] when a -//! frame-count or duration threshold is hit. Per-subcarrier statistics -//! (`mean_amplitude`, `phase_variance`) and the scalar `motion_energy`, -//! `presence_score` and `quality_score` are computed here. -//! 2. [`EventDetector`] implementations — small, deterministic state machines -//! that consume windows and emit events: -//! [`PresenceDetector`], [`MotionDetector`], [`QualityDetector`] and -//! [`BaselineDriftDetector`]. -//! 3. [`EventPipeline`] — wires a [`WindowBuffer`] and a set of detectors -//! together and owns an [`rvcsi_core::IdGenerator`]. -//! -//! Determinism: feeding the same frame stream through an [`EventPipeline`] -//! always produces the same event list (modulo the ids, which are minted in a -//! deterministic order). All "noise" in the tests comes from a tiny LCG, never -//! from `rand`. - -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -mod detectors; -mod pipeline; -mod window_buffer; - -pub use detectors::{ - BaselineDriftConfig, BaselineDriftDetector, EventDetector, MotionConfig, MotionDetector, - PresenceConfig, PresenceDetector, QualityConfig, QualityDetector, -}; -pub use pipeline::EventPipeline; -pub use window_buffer::{WindowBuffer, WindowBufferConfig}; diff --git a/v2/crates/rvcsi-events/src/pipeline.rs b/v2/crates/rvcsi-events/src/pipeline.rs deleted file mode 100644 index f85f7bceac..0000000000 --- a/v2/crates/rvcsi-events/src/pipeline.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! [`EventPipeline`] — wires a [`WindowBuffer`] to a set of [`EventDetector`]s. -//! -//! A pipeline owns its own [`IdGenerator`] so window/event ids are minted in a -//! deterministic order. Feed it frames with [`EventPipeline::process_frame`] -//! and drain the tail with [`EventPipeline::flush`]. - -use rvcsi_core::{CsiEvent, CsiFrame, CsiWindow, IdGenerator, SessionId, SourceId}; - -use crate::detectors::{ - BaselineDriftDetector, EventDetector, MotionDetector, PresenceDetector, QualityDetector, -}; -use crate::window_buffer::{WindowBuffer, WindowBufferConfig}; - -/// How many recently-closed windows the pipeline keeps for inspection. -const RECENT_WINDOW_CAP: usize = 32; - -/// Aggregates frames into windows and runs detectors over them. -pub struct EventPipeline { - buffer: WindowBuffer, - detectors: Vec>, - ids: IdGenerator, - recent: Vec, -} - -impl core::fmt::Debug for EventPipeline { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("EventPipeline") - .field("detectors", &self.detectors.iter().map(|d| d.name()).collect::>()) - .field("pending_frame_count", &self.buffer.pending_frame_count()) - .field("recent_windows", &self.recent.len()) - .finish() - } -} - -impl EventPipeline { - /// New pipeline with the given window-buffer config and no detectors. - /// - /// Add detectors with [`EventPipeline::add_detector`]. - pub fn new(session_id: SessionId, source_id: SourceId, buffer_cfg: WindowBufferConfig) -> Self { - EventPipeline { - buffer: WindowBuffer::with_config(session_id, source_id, buffer_cfg), - detectors: Vec::new(), - ids: IdGenerator::new(), - recent: Vec::new(), - } - } - - /// New pipeline with the four default detectors and a 16-frame / 1-second - /// window buffer. - pub fn with_defaults(session_id: SessionId, source_id: SourceId) -> Self { - let mut p = Self::new( - session_id, - source_id, - WindowBufferConfig::new(16, 1_000_000_000), - ); - p.add_detector(Box::new(PresenceDetector::new())); - p.add_detector(Box::new(MotionDetector::new())); - p.add_detector(Box::new(QualityDetector::new())); - p.add_detector(Box::new(BaselineDriftDetector::new())); - p - } - - /// Append a detector. Detectors run in insertion order on every window. - pub fn add_detector(&mut self, detector: Box) { - self.detectors.push(detector); - } - - /// Names of the registered detectors, in order. - pub fn detector_names(&self) -> Vec<&'static str> { - self.detectors.iter().map(|d| d.name()).collect() - } - - /// The most-recently-closed windows (newest last), capped at 32. - pub fn recent_windows(&self) -> &[CsiWindow] { - &self.recent - } - - /// Frames buffered but not yet emitted as a window. - pub fn pending_frame_count(&self) -> usize { - self.buffer.pending_frame_count() - } - - /// Push one frame; if it closes a window, run every detector on that window - /// and return their concatenated events. Otherwise return an empty `Vec`. - pub fn process_frame(&mut self, frame: &CsiFrame) -> Vec { - match self.buffer.push(frame, &self.ids) { - Some(window) => self.run_detectors(window), - None => Vec::new(), - } - } - - /// Close whatever frames remain in the buffer into a final window and run - /// detectors on it. Returns an empty `Vec` if the buffer was empty. - pub fn flush(&mut self) -> Vec { - match self.buffer.flush(&self.ids) { - Some(window) => self.run_detectors(window), - None => Vec::new(), - } - } - - fn run_detectors(&mut self, window: CsiWindow) -> Vec { - let mut events = Vec::new(); - for d in &mut self.detectors { - events.extend(d.on_window(&window, &self.ids)); - } - debug_assert!(events.iter().all(|e| e.validate().is_ok())); - self.recent.push(window); - if self.recent.len() > RECENT_WINDOW_CAP { - let overflow = self.recent.len() - RECENT_WINDOW_CAP; - self.recent.drain(0..overflow); - } - events - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{AdapterKind, CsiEventKind, FrameId, ValidationStatus}; - - /// Deterministic LCG (Numerical Recipes constants) -> `[0.0, 1.0)`. - struct Lcg(u64); - impl Lcg { - fn new(seed: u64) -> Self { - Lcg(seed) - } - fn next_unit(&mut self) -> f32 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - // top 24 bits -> [0,1) - ((self.0 >> 40) as f32) / (1u64 << 24) as f32 - } - } - - fn accepted_frame(frame_id: u64, ts: u64, amp: &[f32], quality: f32) -> CsiFrame { - let i: Vec = amp.to_vec(); - let q: Vec = vec![0.0; amp.len()]; - let mut f = CsiFrame::from_iq( - FrameId(frame_id), - SessionId(1), - SourceId::from("dev"), - AdapterKind::Synthetic, - ts, - 6, - 20, - i, - q, - ); - f.validation = ValidationStatus::Accepted; - f.quality_score = quality; - f - } - - /// Build a quiet / active / quiet frame stream with monotonic 50 ms - /// timestamps. Long enough that the default 16-frame window buffer yields - /// enough windows for the detectors' debounce / hysteresis chains. - fn synthetic_stream() -> Vec { - let mut rng = Lcg::new(0xC0FFEE); - let mut frames = Vec::new(); - let dt = 50_000_000u64; // 50 ms - let quiet_a = 30u64; - let active = 60u64; - let quiet_b = 60u64; - let total = quiet_a + active + quiet_b; - for k in 0..total { - let ts = k * dt; - let is_active = (quiet_a..quiet_a + active).contains(&k); - let amp: Vec = (0..32) - .map(|_| { - if is_active { - // Large per-frame jitter. - 1.0 + (rng.next_unit() - 0.5) * 4.0 - } else { - // Tiny deterministic noise around 1.0. - 1.0 + (rng.next_unit() - 0.5) * 0.001 - } - }) - .collect(); - frames.push(accepted_frame(k, ts, &, 0.9)); - } - frames - } - - fn run_stream(frames: &[CsiFrame]) -> Vec { - let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev")); - let mut events = Vec::new(); - for f in frames { - events.extend(p.process_frame(f)); - } - events.extend(p.flush()); - events - } - - #[test] - fn pipeline_detects_motion_and_presence_and_settles() { - let frames = synthetic_stream(); - let events = run_stream(&frames); - assert!(!events.is_empty(), "expected some events"); - for e in &events { - assert!(e.validate().is_ok(), "invalid event: {e:?}"); - } - let kinds: Vec = events.iter().map(|e| e.kind).collect(); - assert!(kinds.contains(&CsiEventKind::MotionDetected), "kinds = {kinds:?}"); - assert!(kinds.contains(&CsiEventKind::PresenceStarted), "kinds = {kinds:?}"); - assert!(kinds.contains(&CsiEventKind::MotionSettled), "kinds = {kinds:?}"); - assert!(kinds.contains(&CsiEventKind::PresenceEnded), "kinds = {kinds:?}"); - - // MotionDetected should come before MotionSettled. - let det = events.iter().position(|e| e.kind == CsiEventKind::MotionDetected).unwrap(); - let set = events.iter().position(|e| e.kind == CsiEventKind::MotionSettled).unwrap(); - assert!(det < set); - let start = events.iter().position(|e| e.kind == CsiEventKind::PresenceStarted).unwrap(); - let end = events.iter().position(|e| e.kind == CsiEventKind::PresenceEnded).unwrap(); - assert!(start < end); - } - - #[test] - fn pipeline_is_deterministic() { - let frames = synthetic_stream(); - let a = run_stream(&frames); - let b = run_stream(&frames); - assert_eq!(a, b, "same stream must yield identical events"); - } - - #[test] - fn pipeline_recent_windows_and_pending_count() { - let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev")); - let amp = vec![1.0f32; 32]; - // Two windows worth of frames (16 each at the 16-frame cap). - for k in 0..16u64 { - p.process_frame(&accepted_frame(k, k * 10_000, &, 0.9)); - } - assert_eq!(p.recent_windows().len(), 1); - assert_eq!(p.pending_frame_count(), 0); - p.process_frame(&accepted_frame(16, 200_000, &, 0.9)); - assert_eq!(p.pending_frame_count(), 1); - let leftover = p.flush(); - let _ = leftover; - assert_eq!(p.recent_windows().len(), 2); - assert_eq!(p.pending_frame_count(), 0); - } - - #[test] - fn pipeline_skips_foreign_frames() { - let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev")); - let amp = vec![1.0f32; 8]; - let mut foreign = accepted_frame(0, 0, &, 0.9); - foreign.session_id = SessionId(99); - assert!(p.process_frame(&foreign).is_empty()); - assert_eq!(p.pending_frame_count(), 0); - } - - #[test] - fn detector_names_in_order() { - let p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev")); - assert_eq!( - p.detector_names(), - vec!["presence", "motion", "quality", "baseline_drift"] - ); - } -} diff --git a/v2/crates/rvcsi-events/src/window_buffer.rs b/v2/crates/rvcsi-events/src/window_buffer.rs deleted file mode 100644 index fdf6ca9805..0000000000 --- a/v2/crates/rvcsi-events/src/window_buffer.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! [`WindowBuffer`] — aggregates exposable [`CsiFrame`]s into [`CsiWindow`]s. - -use rvcsi_core::{CsiFrame, CsiWindow, IdGenerator, SessionId, SourceId}; - -/// Tunables for a [`WindowBuffer`]. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct WindowBufferConfig { - /// Close the window once this many frames have been buffered. Must be `>= 2`. - pub max_frames: usize, - /// Close the window once `last_ts - first_ts >= max_duration_ns`. - pub max_duration_ns: u64, - /// Centre of the logistic that maps `motion_energy` to `presence_score`. - pub presence_threshold: f32, -} - -impl WindowBufferConfig { - /// Build a config with a default `presence_threshold` of `0.05`. - /// - /// # Panics - /// Panics if `max_frames < 2`. - pub fn new(max_frames: usize, max_duration_ns: u64) -> Self { - assert!(max_frames >= 2, "WindowBuffer max_frames must be >= 2"); - WindowBufferConfig { - max_frames, - max_duration_ns, - presence_threshold: 0.05, - } - } - - /// Builder-style setter for [`WindowBufferConfig::presence_threshold`]. - pub fn with_presence_threshold(mut self, t: f32) -> Self { - self.presence_threshold = t; - self - } -} - -/// Buffers frames from one `(session_id, source_id)` and emits windows. -/// -/// Use [`WindowBuffer::push`] for each incoming frame; it returns `Some(window)` -/// on the frame that closes a window (that frame being the last in the window). -/// Call [`WindowBuffer::flush`] at end-of-stream to drain whatever is buffered. -#[derive(Debug, Clone)] -pub struct WindowBuffer { - session_id: SessionId, - source_id: SourceId, - cfg: WindowBufferConfig, - /// Subcarrier count fixed by the first buffered frame of the current window. - subcarrier_count: Option, - /// Buffered `amplitude` vectors (one per accepted frame). - amplitudes: Vec>, - /// Buffered `phase` vectors (one per accepted frame). - phases: Vec>, - /// Buffered `quality_score`s. - qualities: Vec, - /// Buffered timestamps (ns). - timestamps: Vec, -} - -impl WindowBuffer { - /// Create a buffer for `session_id` / `source_id` with the given thresholds. - /// - /// # Panics - /// Panics if `max_frames < 2`. - pub fn new( - session_id: SessionId, - source_id: SourceId, - max_frames: usize, - max_duration_ns: u64, - ) -> Self { - Self::with_config( - session_id, - source_id, - WindowBufferConfig::new(max_frames, max_duration_ns), - ) - } - - /// Create a buffer from a [`WindowBufferConfig`]. - /// - /// # Panics - /// Panics if `cfg.max_frames < 2`. - pub fn with_config(session_id: SessionId, source_id: SourceId, cfg: WindowBufferConfig) -> Self { - assert!(cfg.max_frames >= 2, "WindowBuffer max_frames must be >= 2"); - WindowBuffer { - session_id, - source_id, - cfg, - subcarrier_count: None, - amplitudes: Vec::new(), - phases: Vec::new(), - qualities: Vec::new(), - timestamps: Vec::new(), - } - } - - /// Number of frames currently buffered (not yet emitted as a window). - pub fn pending_frame_count(&self) -> usize { - self.amplitudes.len() - } - - /// Add a frame; returns `Some(window)` if this frame closed a window. - /// - /// Frames are skipped (returning `None`, not buffered) when: - /// * `!frame.is_exposable()`, - /// * the frame's `session_id` / `source_id` don't match the buffer's, or - /// * the frame's `subcarrier_count` differs from the first buffered frame's. - pub fn push(&mut self, frame: &CsiFrame, ids: &IdGenerator) -> Option { - if !frame.is_exposable() { - return None; - } - if frame.session_id != self.session_id || frame.source_id != self.source_id { - return None; - } - match self.subcarrier_count { - None => self.subcarrier_count = Some(frame.subcarrier_count), - Some(n) if n != frame.subcarrier_count => return None, - Some(_) => {} - } - - self.amplitudes.push(frame.amplitude.clone()); - self.phases.push(frame.phase.clone()); - self.qualities.push(frame.quality_score); - self.timestamps.push(frame.timestamp_ns); - - let reached_count = self.amplitudes.len() >= self.cfg.max_frames; - let reached_duration = match (self.timestamps.first(), self.timestamps.last()) { - (Some(&first), Some(&last)) => last.saturating_sub(first) >= self.cfg.max_duration_ns, - _ => false, - }; - if reached_count || reached_duration { - Some(self.close(ids)) - } else { - None - } - } - - /// Drain whatever is buffered (>= 1 frame) into a final window. - /// - /// Returns `None` when the buffer is empty. - pub fn flush(&mut self, ids: &IdGenerator) -> Option { - if self.amplitudes.is_empty() { - None - } else { - Some(self.close(ids)) - } - } - - /// Build the [`CsiWindow`] from the buffered frames and reset the buffer. - fn close(&mut self, ids: &IdGenerator) -> CsiWindow { - let frame_count = self.amplitudes.len(); - debug_assert!(frame_count >= 1, "close() called on an empty buffer"); - let n = self.subcarrier_count.unwrap_or(0) as usize; - - // Per-subcarrier mean amplitude. - let mut mean_amplitude = vec![0.0f32; n]; - for amp in &self.amplitudes { - for (slot, a) in mean_amplitude.iter_mut().zip(amp.iter()) { - *slot += *a; - } - } - for v in &mut mean_amplitude { - *v /= frame_count as f32; - } - - // Per-subcarrier population variance of the phase. - let mut phase_mean = vec![0.0f32; n]; - for ph in &self.phases { - for (slot, p) in phase_mean.iter_mut().zip(ph.iter()) { - *slot += *p; - } - } - for v in &mut phase_mean { - *v /= frame_count as f32; - } - let mut phase_variance = vec![0.0f32; n]; - for ph in &self.phases { - for k in 0..n { - let d = ph.get(k).copied().unwrap_or(0.0) - phase_mean[k]; - phase_variance[k] += d * d; - } - } - for v in &mut phase_variance { - *v /= frame_count as f32; - } - - // Motion energy: mean over consecutive pairs of ||amp_b - amp_a||_2 / sqrt(n). - let motion_energy = if frame_count < 2 || n == 0 { - 0.0 - } else { - let mut acc = 0.0f64; - for w in self.amplitudes.windows(2) { - let (a, b) = (&w[0], &w[1]); - let mut sq = 0.0f64; - for k in 0..n { - let d = (b.get(k).copied().unwrap_or(0.0) - a.get(k).copied().unwrap_or(0.0)) - as f64; - sq += d * d; - } - acc += sq.sqrt() / (n as f64).sqrt(); - } - (acc / (frame_count - 1) as f64) as f32 - }; - let motion_energy = if motion_energy.is_finite() && motion_energy >= 0.0 { - motion_energy - } else { - 0.0 - }; - - // Presence score: logistic of (motion_energy - threshold). - let z = (motion_energy - self.cfg.presence_threshold) * 8.0; - let presence_score = (1.0 / (1.0 + (-z).exp())).clamp(0.0, 1.0); - - // Quality score: mean of frame quality scores. - let quality_sum: f32 = self.qualities.iter().sum(); - let quality_score = (quality_sum / frame_count as f32).clamp(0.0, 1.0); - - let start_ns = *self.timestamps.first().unwrap(); - let raw_end = *self.timestamps.last().unwrap(); - // Edge case: a single-frame window would have start_ns == end_ns, which - // CsiWindow::validate() rejects. Bump the end by 1 ns so it stays valid. - let end_ns = if raw_end > start_ns { raw_end } else { start_ns + 1 }; - - let window = CsiWindow { - window_id: ids.next_window(), - session_id: self.session_id, - source_id: self.source_id.clone(), - start_ns, - end_ns, - frame_count: frame_count as u32, - mean_amplitude, - phase_variance, - motion_energy, - presence_score, - quality_score, - }; - debug_assert!( - window.validate().is_ok(), - "WindowBuffer produced an invalid CsiWindow: {:?}", - window.validate() - ); - - // Reset for the next window. - self.subcarrier_count = None; - self.amplitudes.clear(); - self.phases.clear(); - self.qualities.clear(); - self.timestamps.clear(); - - window - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{AdapterKind, FrameId, ValidationStatus}; - - fn frame( - session: u64, - source: &str, - frame_id: u64, - ts: u64, - amp: &[f32], - quality: f32, - ) -> CsiFrame { - // Build I/Q so that amplitude == amp and phase == 0. - let i: Vec = amp.to_vec(); - let q: Vec = vec![0.0; amp.len()]; - let mut f = CsiFrame::from_iq( - FrameId(frame_id), - SessionId(session), - SourceId::from(source), - AdapterKind::Synthetic, - ts, - 6, - 20, - i, - q, - ); - f.validation = ValidationStatus::Accepted; - f.quality_score = quality; - f - } - - #[test] - fn closes_after_exactly_max_frames() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 4, u64::MAX); - let amp = [1.0f32, 1.0, 1.0]; - assert!(buf.push(&frame(0, "s", 0, 0, &, 0.9), &g).is_none()); - assert!(buf.push(&frame(0, "s", 1, 10, &, 0.9), &g).is_none()); - assert!(buf.push(&frame(0, "s", 2, 20, &, 0.9), &g).is_none()); - assert_eq!(buf.pending_frame_count(), 3); - let w = buf.push(&frame(0, "s", 3, 30, &, 0.9), &g).expect("window"); - assert_eq!(w.frame_count, 4); - assert_eq!(buf.pending_frame_count(), 0); - assert!(w.validate().is_ok()); - } - - #[test] - fn closes_on_duration_with_fewer_frames() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 100, 1_000); - let amp = [1.0f32, 2.0]; - assert!(buf.push(&frame(0, "s", 0, 0, &, 0.8), &g).is_none()); - assert!(buf.push(&frame(0, "s", 1, 500, &, 0.8), &g).is_none()); - let w = buf - .push(&frame(0, "s", 2, 1_000, &, 0.8), &g) - .expect("window closed on duration"); - assert_eq!(w.frame_count, 3); - assert_eq!(w.start_ns, 0); - assert_eq!(w.end_ns, 1_000); - assert!(w.validate().is_ok()); - } - - #[test] - fn flush_returns_remainder_and_handles_single_frame() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 10, u64::MAX); - let amp = [1.0f32, 1.0]; - assert!(buf.push(&frame(0, "s", 0, 100, &, 0.7), &g).is_none()); - let w = buf.flush(&g).expect("flush returns the single buffered frame"); - assert_eq!(w.frame_count, 1); - assert_eq!(w.start_ns, 100); - assert_eq!(w.end_ns, 101); // bumped so validate() passes - assert_eq!(w.motion_energy, 0.0); - assert!(w.validate().is_ok()); - assert!(buf.flush(&g).is_none()); - } - - #[test] - fn skips_mismatched_session_and_source() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(7), SourceId::from("good"), 4, u64::MAX); - let amp = [1.0f32, 1.0]; - assert!(buf.push(&frame(7, "good", 0, 0, &, 0.9), &g).is_none()); - // Wrong session. - assert!(buf.push(&frame(8, "good", 1, 10, &, 0.9), &g).is_none()); - // Wrong source. - assert!(buf.push(&frame(7, "bad", 2, 20, &, 0.9), &g).is_none()); - assert_eq!(buf.pending_frame_count(), 1); - } - - #[test] - fn skips_non_exposable_and_mismatched_subcarrier_count() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 4, u64::MAX); - // Non-exposable frame is dropped. - let mut bad = frame(0, "s", 0, 0, &[1.0, 1.0], 0.9); - bad.validation = ValidationStatus::Pending; - assert!(buf.push(&bad, &g).is_none()); - assert_eq!(buf.pending_frame_count(), 0); - // First good frame fixes subcarrier count = 2. - assert!(buf.push(&frame(0, "s", 1, 10, &[1.0, 1.0], 0.9), &g).is_none()); - // Different subcarrier count is dropped. - assert!(buf - .push(&frame(0, "s", 2, 20, &[1.0, 1.0, 1.0], 0.9), &g) - .is_none()); - assert_eq!(buf.pending_frame_count(), 1); - } - - #[test] - fn identical_frames_have_zero_motion_low_presence() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 8, u64::MAX); - let amp = [1.0f32; 32]; - let mut last = None; - for k in 0..8u64 { - last = buf.push(&frame(0, "s", k, k * 10, &, 0.9), &g); - } - let w = last.expect("window"); - assert_eq!(w.motion_energy, 0.0); - assert!(w.presence_score < 0.5, "presence_score = {}", w.presence_score); - assert!(w.validate().is_ok()); - } - - #[test] - fn growing_jitter_raises_motion_and_presence() { - let g = IdGenerator::new(); - let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 16, u64::MAX); - // Large alternating jitter -> high motion energy. - let mut last = None; - for k in 0..16u64 { - let bump = if k % 2 == 0 { 0.0 } else { 1.0 }; - let amp: Vec = (0..32).map(|_| 1.0 + bump).collect(); - last = buf.push(&frame(0, "s", k, k * 10, &, 0.9), &g); - } - let w = last.expect("window"); - assert!(w.motion_energy > 0.1, "motion_energy = {}", w.motion_energy); - assert!(w.presence_score > 0.5, "presence_score = {}", w.presence_score); - assert!(w.validate().is_ok()); - } -} diff --git a/v2/crates/rvcsi-node/Cargo.toml b/v2/crates/rvcsi-node/Cargo.toml deleted file mode 100644 index 78d0fb9424..0000000000 --- a/v2/crates/rvcsi-node/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "rvcsi-node" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI Node.js bindings (napi-rs) — safe TypeScript-facing surface over the rvCSI Rust runtime (ADR-095 D3/D4, ADR-096)" -repository.workspace = true -keywords = ["wifi", "csi", "napi", "rvcsi"] -categories = ["science"] -build = "build.rs" - -[lib] -# cdylib -> the .node addon; rlib -> so `cargo test --workspace` can link/test it. -crate-type = ["cdylib", "rlib"] - -[dependencies] -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 } - -[build-dependencies] -napi-build = { workspace = true } - -[dev-dependencies] -tempfile = "3.10" diff --git a/v2/crates/rvcsi-node/README.md b/v2/crates/rvcsi-node/README.md deleted file mode 100644 index c8a8fb1b3b..0000000000 --- a/v2/crates/rvcsi-node/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# @ruv/rvcsi - -Node.js bindings (napi-rs) for **rvCSI** — the edge RF sensing runtime: ingest -WiFi CSI from files / Nexmon dumps, validate and normalize it, run reusable DSP, -emit typed presence / motion / quality / anomaly events, and export temporal -embeddings to an RF-memory store. See [ADR-095](../../../docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md) -and [ADR-096](../../../docs/adr/ADR-096-rvcsi-ffi-crate-layout.md). - -> This package wraps the Rust crates in `v2/crates/rvcsi-*`. The Rust side does -> all the work (parsing, validation, DSP, events, embeddings); this is a thin, -> safe JS surface — nothing crosses the boundary except validated/normalized -> objects (delivered as JSON the SDK parses for you). - -## Build - -The native addon is produced from the `rvcsi-node` Rust crate: - -```bash -# from v2/crates/rvcsi-node -npm install # installs @napi-rs/cli -npm run build # -> rvcsi-node..node + binding.js + binding.d.ts -``` - -(`cargo build -p rvcsi-node` also compiles the addon as a `cdylib`; `napi build` -additionally emits the platform loader and `.d.ts`.) - -## Usage - -```js -const { RvCsi, inspectCaptureFile, eventsFromCaptureFile, nexmonDecodeRecords } = require('@ruv/rvcsi'); - -// One-shot: summarize a capture -const summary = inspectCaptureFile('lab.rvcsi'); -console.log(summary.frame_count, summary.channels, summary.mean_quality); - -// One-shot: replay a capture into events -for (const e of eventsFromCaptureFile('lab.rvcsi')) { - console.log(e.kind, e.timestamp_ns, e.confidence); -} - -// Streaming -const rt = RvCsi.openCaptureFile('lab.rvcsi'); -let frame; -while ((frame = rt.nextCleanFrame()) !== null) { - // frame.validation is 'Accepted' | 'Degraded' | 'Recovered' — never 'Pending'/'Rejected' - if (frame.quality_score > 0.5) { /* ... */ } -} -const events = rt.drainEvents(); -console.log(rt.health()); - -// Decode raw Nexmon records (the napi-c shim format) straight from a Buffer -const fs = require('fs'); -const frames = nexmonDecodeRecords(fs.readFileSync('nexmon.bin'), 'wlan0', 1); -``` - -TypeScript types ship in `index.d.ts` (`CsiFrame`, `CsiWindow`, `CsiEvent`, -`SourceHealth`, `CaptureSummary`, `ValidationStatus`, `CsiEventKind`, ...). - -## What's here vs. not (yet) - -Implemented: file/replay + Nexmon sources, the validation pipeline, the DSP -stages, window aggregation + the event state machines, RuVector-style RF-memory -export. Not yet wired into this addon: live radio capture, the WebSocket daemon, -and the MCP tool server — those come with `rvcsi-daemon` / `rvcsi-mcp`. diff --git a/v2/crates/rvcsi-node/__test__/api.test.cjs b/v2/crates/rvcsi-node/__test__/api.test.cjs deleted file mode 100644 index 863087f699..0000000000 --- a/v2/crates/rvcsi-node/__test__/api.test.cjs +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; - -// Structural smoke test for the @ruv/rvcsi JS surface. -// -// Importing the package never throws (the native addon loads lazily). This test -// asserts the public API shape; if the .node addon HAS been built (e.g. CI ran -// `npm run build` first), it also checks `rvcsiVersion()` returns a string — -// otherwise it asserts the error message is the helpful "not built" one. -// -// Run with: node --test (Node >= 18) - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const rvcsi = require('../index.js'); - -test('exports the expected functions and class', () => { - for (const fn of [ - 'rvcsiVersion', - 'nexmonShimAbiVersion', - 'nexmonDecodeRecords', - 'nexmonDecodePcap', - 'inspectNexmonPcap', - 'decodeChanspec', - 'nexmonChipName', - 'nexmonProfile', - 'nexmonChips', - 'inspectCaptureFile', - 'eventsFromCaptureFile', - 'exportCaptureToRfMemory', - ]) { - assert.equal(typeof rvcsi[fn], 'function', `${fn} should be a function`); - } - 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', () => { - try { - const v = rvcsi.rvcsiVersion(); - assert.equal(typeof v, 'string'); - assert.match(v, /^\d+\.\d+\.\d+/); - assert.equal(typeof rvcsi.nexmonShimAbiVersion(), 'number'); - } catch (e) { - assert.match(e.message, /native addon is not built/i); - } -}); diff --git a/v2/crates/rvcsi-node/build.rs b/v2/crates/rvcsi-node/build.rs deleted file mode 100644 index c337d8cfb5..0000000000 --- a/v2/crates/rvcsi-node/build.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! napi-rs build glue (ADR-096): emits the platform link args the `.node` -//! addon needs and (re)generates `index.d.ts` / `index.js` via `napi build`. -fn main() { - napi_build::setup(); -} diff --git a/v2/crates/rvcsi-node/index.d.ts b/v2/crates/rvcsi-node/index.d.ts deleted file mode 100644 index 44871ce881..0000000000 --- a/v2/crates/rvcsi-node/index.d.ts +++ /dev/null @@ -1,287 +0,0 @@ -// rvCSI Node.js SDK — type declarations for the curated `index.js` surface. -// -// The shapes below mirror the Rust `rvcsi-core` schema (`CsiFrame`, `CsiWindow`, -// `CsiEvent`, `SourceHealth`) and `rvcsi-runtime` (`CaptureSummary`). They are -// what you get back after the SDK `JSON.parse`s the strings the napi-rs addon -// returns (see ADR-095 §10 / ADR-096 §2.3). - -/** Outcome of the rvCSI validation pipeline for a frame. */ -export type ValidationStatus = - | 'Pending' - | 'Accepted' - | 'Degraded' - | 'Rejected' - | 'Recovered'; - -/** Which adapter family produced a frame. */ -export type AdapterKind = - | 'File' - | 'Replay' - | 'Nexmon' - | 'Esp32' - | 'Intel' - | 'Atheros' - | 'Synthetic'; - -/** Kinds of event the runtime emits. */ -export type CsiEventKind = - | 'PresenceStarted' - | 'PresenceEnded' - | 'MotionDetected' - | 'MotionSettled' - | 'BaselineChanged' - | 'SignalQualityDropped' - | 'DeviceDisconnected' - | 'BreathingCandidate' - | 'AnomalyDetected' - | 'CalibrationRequired'; - -/** One normalized, validated CSI observation. */ -export interface CsiFrame { - frame_id: number; - session_id: number; - source_id: string; - adapter_kind: AdapterKind; - timestamp_ns: number; - channel: number; - bandwidth_mhz: number; - rssi_dbm: number | null; - noise_floor_dbm: number | null; - antenna_index: number | null; - tx_chain: number | null; - rx_chain: number | null; - subcarrier_count: number; - i_values: number[]; - q_values: number[]; - amplitude: number[]; - phase: number[]; - validation: ValidationStatus; - quality_score: number; - /** Present (non-empty) only when `validation` is `Degraded`. */ - quality_reasons?: string[]; - calibration_version: string | null; -} - -/** A bounded window of frames, summarized. */ -export interface CsiWindow { - window_id: number; - session_id: number; - source_id: string; - start_ns: number; - end_ns: number; - frame_count: number; - mean_amplitude: number[]; - phase_variance: number[]; - motion_energy: number; - presence_score: number; - quality_score: number; -} - -/** A detected event with confidence and the windows that justify it. */ -export interface CsiEvent { - event_id: number; - kind: CsiEventKind; - session_id: number; - source_id: string; - timestamp_ns: number; - confidence: number; - evidence_window_ids: number[]; - calibration_version: string | null; - /** Free-form JSON string of event metadata. */ - metadata_json: string; -} - -/** Health snapshot for a source. */ -export interface SourceHealth { - connected: boolean; - frames_delivered: number; - frames_rejected: number; - status: string | null; -} - -/** Per-`ValidationStatus` frame counts. */ -export interface ValidationBreakdown { - pending: number; - accepted: number; - degraded: number; - rejected: number; - recovered: number; -} - -/** A source's capability descriptor (channels / bandwidths / expected subcarrier counts). */ -export interface AdapterProfile { - adapter_kind: AdapterKind; - /** Chip string, e.g. `"bcm43455c0 (pi5)"`, or `null`. */ - chip: string | null; - firmware_version: string | null; - driver_version: string | null; - supported_channels: number[]; - supported_bandwidths_mhz: number[]; - expected_subcarrier_counts: number[]; - supports_live_capture: boolean; - supports_injection: boolean; - supports_monitor_mode: boolean; -} - -/** Compact summary of a `.rvcsi` capture file. */ -export interface CaptureSummary { - capture_version: number; - session_id: number; - source_id: string; - adapter_kind: string; - /** The header's adapter-profile `chip` string, if any (e.g. `"bcm43455c0 (pi5)"`). */ - chip: string | null; - frame_count: number; - first_timestamp_ns: number; - last_timestamp_ns: number; - channels: number[]; - subcarrier_counts: number[]; - mean_quality: number; - validation_breakdown: ValidationBreakdown; - 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. 0x4345 = the BCM4345 family). */ - chip_versions: number[]; - /** Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5). */ - chip_names: string[]; - /** The chip the adapter settled on (all packets agreed) — `"bcm43455c0"` for a Pi 5 capture. */ - detected_chip: string; - /** `[min, max]` RSSI in dBm, or `null` for an empty capture. */ - rssi_dbm_range: [number, number] | null; -} - -/** 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; -} - -/** One Nexmon-supported chip in the {@link nexmonChips} listing. */ -export interface NexmonChipInfo { - /** Slug, e.g. `"bcm43455c0"`. */ - slug: string; - /** Human description incl. a typical host device. */ - description: string; - /** Whether the chip supports the 5 GHz band. */ - dualBand: boolean; - /** Whether its firmware exports CSI in the modern int16 I/Q format. */ - int16IqExport: boolean; - bandwidthsMhz: number[]; - expectedSubcarrierCounts: number[]; -} - -/** One Raspberry Pi model in the {@link nexmonChips} listing. */ -export interface RaspberryPiModelInfo { - /** Slug, e.g. `"pi5"`. */ - slug: string; - /** The chip on this board (`"bcm43455c0"` for the Pi 5), or `null` if not CSI-capable. */ - chip: string | null; - csiSupported: boolean; -} - -/** The {@link nexmonChips} listing. */ -export interface NexmonChipsListing { - chips: NexmonChipInfo[]; - raspberryPiModels: RaspberryPiModelInfo[]; -} - -/** rvCSI runtime version string. */ -export function rvcsiVersion(): string; - -/** ABI version of the linked napi-c Nexmon shim (`major<<16 | minor`). */ -export function nexmonShimAbiVersion(): number; - -/** - * Decode a Buffer of "rvCSI Nexmon records" (the napi-c shim format) into - * validated frames. Throws on a malformed record. - */ -export function nexmonDecodeRecords( - buf: Buffer | Uint8Array, - sourceId: string, - sessionId: number, -): CsiFrame[]; - -/** Summarize a `.rvcsi` capture file. */ -export function inspectCaptureFile(path: string): CaptureSummary; - -/** Replay a `.rvcsi` capture through the DSP + event pipeline. */ -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. `chip` (`'pi5'`, - * `'bcm43455c0'`, ...) validates against that device's profile and drops the - * non-conforming frames. Throws on a non-pcap buffer or an unknown `chip`. - */ -export function nexmonDecodePcap( - pcap: Buffer | Uint8Array, - sourceId: string, - sessionId: number, - port?: number, - chip?: string, -): CsiFrame[]; - -/** Summarize a nexmon_csi `.pcap` file. `port` defaults to 5500. */ -export function inspectNexmonPcap(path: string, port?: number): NexmonPcapSummary; - -/** Decode a Broadcom d11ac chanspec word. */ -export function decodeChanspec(chanspec: number): DecodedChanspec; - -/** - * Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug - * (`'bcm43455c0'` for a Raspberry Pi 3B+/4/400/5; `'unknown:0xNNNN'` otherwise). - */ -export function nexmonChipName(chipVer: number): string; - -/** - * The {@link AdapterProfile} for a chip / Raspberry-Pi-model spec (`'pi5'`, - * `'bcm43455c0'`, `'raspberry pi 4'`, ...). Throws on an unknown spec. - */ -export function nexmonProfile(spec: string): AdapterProfile; - -/** Listing of the Nexmon-supported chips + Raspberry Pi models (incl. the Pi 5 → BCM43455c0). */ -export function nexmonChips(): NexmonChipsListing; - -/** Streaming capture runtime: a source + the DSP stage + the event pipeline. */ -export class RvCsi { - private constructor(rt: unknown); - /** Open a `.rvcsi` capture file. */ - 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. */ - nextCleanFrame(): CsiFrame | null; - /** Drain the rest of the stream through DSP + the event pipeline. */ - drainEvents(): CsiEvent[]; - /** Current health snapshot. */ - health(): SourceHealth; - /** Frames pulled from the source so far. */ - readonly framesSeen: number; - /** Frames dropped by validation so far. */ - readonly framesDropped: number; -} diff --git a/v2/crates/rvcsi-node/index.js b/v2/crates/rvcsi-node/index.js deleted file mode 100644 index 61e050cecd..0000000000 --- a/v2/crates/rvcsi-node/index.js +++ /dev/null @@ -1,251 +0,0 @@ -'use strict'; - -// rvCSI Node.js SDK — curated public surface over the napi-rs addon. -// -// The compiled addon (and its loader `binding.js`) are produced by -// `napi build --platform --release --js binding.js --dts binding.d.ts` -// in this directory (see package.json `build` script). Until that's run, -// `require('@ruv/rvcsi')` still succeeds — only the calls that touch the -// native code throw, with a message explaining how to build it. -// -// Everything the Rust side returns as JSON is parsed here so callers get -// plain objects (CsiFrame / CsiWindow / CsiEvent / SourceHealth / -// CaptureSummary — see index.d.ts). - -let _binding = null; -let _bindingError = null; - -function binding() { - if (_binding) return _binding; - if (_bindingError) throw _bindingError; - try { - // The @napi-rs/cli loader (resolves the right prebuilt .node for this platform). - _binding = require('./binding.js'); - } catch (e1) { - try { - // Fallback: a sibling .node placed next to this file (e.g. a debug build). - _binding = require('./rvcsi-node.node'); - } catch (e2) { - _bindingError = new Error( - 'rvcsi: the native addon is not built. Build it with ' + - '`npm run build` here, or `napi build --platform --release ' + - '--js binding.js --dts binding.d.ts` in v2/crates/rvcsi-node ' + - '(needs the Rust toolchain + @napi-rs/cli). ' + - 'Loader error: ' + e1.message + ' | fallback error: ' + e2.message, - ); - throw _bindingError; - } - } - return _binding; -} - -const u32 = (n) => Number(n) >>> 0; - -/** rvCSI runtime version string. @returns {string} */ -function rvcsiVersion() { - return binding().rvcsiVersion(); -} - -/** ABI version of the linked napi-c Nexmon shim (`major<<16 | minor`). @returns {number} */ -function nexmonShimAbiVersion() { - return binding().nexmonShimAbiVersion(); -} - -/** - * Decode a Buffer of "rvCSI Nexmon records" (the napi-c shim format) into an - * array of validated CsiFrame objects. - * @param {Buffer|Uint8Array} buf - * @param {string} sourceId - * @param {number} sessionId - * @returns {import('./index').CsiFrame[]} - */ -function nexmonDecodeRecords(buf, sourceId, sessionId) { - return JSON.parse(binding().nexmonDecodeRecords(buf, String(sourceId), u32(sessionId))); -} - -/** - * Summarize a `.rvcsi` capture file. - * @param {string} path - * @returns {import('./index').CaptureSummary} - */ -function inspectCaptureFile(path) { - return JSON.parse(binding().inspectCaptureFile(String(path))); -} - -/** - * Replay a `.rvcsi` capture through the DSP + event pipeline. - * @param {string} path - * @returns {import('./index').CsiEvent[]} - */ -function eventsFromCaptureFile(path) { - return JSON.parse(binding().eventsFromCaptureFile(String(path))); -} - -/** - * Window a capture and store each window's embedding into a JSONL RF-memory file. - * @param {string} capturePath - * @param {string} outJsonlPath - * @returns {number} windows stored - */ -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) - * @param {string} [chip] chip / Raspberry-Pi-model spec to validate against - * (e.g. `'pi5'`, `'bcm43455c0'`); non-conforming frames are dropped - * @returns {import('./index').CsiFrame[]} - */ -function nexmonDecodePcap(pcap, sourceId, sessionId, port, chip) { - return JSON.parse( - binding().nexmonDecodePcap( - pcap, - String(sourceId), - u32(sessionId), - port == null ? undefined : Number(port), - chip == null ? undefined : String(chip), - ), - ); -} - -/** - * Summarize a nexmon_csi `.pcap` file (link type, CSI frame count, channels, - * bandwidths, chip versions + resolved chip names, 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))); -} - -/** - * Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug - * (`'bcm43455c0'` for a Raspberry Pi 3B+/4/400/5; `'unknown:0xNNNN'` otherwise). - * @param {number} chipVer - * @returns {string} - */ -function nexmonChipName(chipVer) { - return binding().nexmonChipName(u32(chipVer)); -} - -/** - * The AdapterProfile (channels / bandwidths / expected subcarrier counts / - * capability flags) for a chip / Raspberry-Pi-model spec (`'pi5'`, - * `'bcm43455c0'`, ...). Throws on an unknown spec. - * @param {string} spec - * @returns {import('./index').AdapterProfile} - */ -function nexmonProfile(spec) { - return JSON.parse(binding().nexmonProfile(String(spec))); -} - -/** - * Listing of the Nexmon-supported chips + the Raspberry Pi models that carry - * them (incl. the Pi 5 → BCM43455c0). - * @returns {import('./index').NexmonChipsListing} - */ -function nexmonChips() { - return JSON.parse(binding().nexmonChips()); -} - -/** Streaming capture runtime: a source + the DSP stage + the event pipeline. */ -class RvCsi { - /** @param {*} rt the underlying napi RvcsiRuntime handle */ - constructor(rt) { - /** @private */ - this._rt = rt; - } - - /** Open a `.rvcsi` capture file. @param {string} path @returns {RvCsi} */ - static openCaptureFile(path) { - return new RvCsi(binding().RvcsiRuntime.openCaptureFile(String(path))); - } - - /** - * Open a Nexmon capture file (concatenated rvCSI Nexmon records). - * @param {string} path @param {string} sourceId @param {number} sessionId @returns {RvCsi} - */ - static openNexmonFile(path, sourceId, sessionId) { - 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(); - return s == null ? null : JSON.parse(s); - } - - /** Like {@link RvCsi#nextFrame} but with the DSP pipeline applied. @returns {import('./index').CsiFrame|null} */ - nextCleanFrame() { - const s = this._rt.nextCleanFrameJson(); - return s == null ? null : JSON.parse(s); - } - - /** Drain the rest of the stream through DSP + the event pipeline. @returns {import('./index').CsiEvent[]} */ - drainEvents() { - return JSON.parse(this._rt.drainEventsJson()); - } - - /** Current health snapshot. @returns {import('./index').SourceHealth} */ - health() { - return JSON.parse(this._rt.healthJson()); - } - - /** Frames pulled from the source so far. @returns {number} */ - get framesSeen() { - return this._rt.framesSeen; - } - - /** Frames dropped by validation so far. @returns {number} */ - get framesDropped() { - return this._rt.framesDropped; - } -} - -module.exports = { - rvcsiVersion, - nexmonShimAbiVersion, - nexmonDecodeRecords, - nexmonDecodePcap, - inspectNexmonPcap, - decodeChanspec, - nexmonChipName, - nexmonProfile, - nexmonChips, - inspectCaptureFile, - eventsFromCaptureFile, - exportCaptureToRfMemory, - RvCsi, -}; diff --git a/v2/crates/rvcsi-node/package.json b/v2/crates/rvcsi-node/package.json deleted file mode 100644 index f6da5af0c0..0000000000 --- a/v2/crates/rvcsi-node/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@ruv/rvcsi", - "version": "0.3.0", - "description": "rvCSI — edge RF sensing runtime: Node.js bindings (napi-rs) over the Rust CSI pipeline (ADR-095, ADR-096)", - "keywords": ["wifi", "csi", "rf-sensing", "presence", "napi-rs", "rvcsi"], - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/ruvnet/wifi-densepose", - "main": "index.js", - "types": "index.d.ts", - "engines": { - "node": ">=14" - }, - "files": [ - "index.js", - "index.d.ts", - "binding.js", - "binding.d.ts", - "README.md", - "*.node" - ], - "napi": { - "name": "rvcsi-node", - "triples": { - "defaults": true - } - }, - "scripts": { - "build": "napi build --platform --release --js binding.js --dts binding.d.ts", - "build:debug": "napi build --platform --js binding.js --dts binding.d.ts", - "test": "node --test" - }, - "devDependencies": { - "@napi-rs/cli": "^2.18.0" - } -} diff --git a/v2/crates/rvcsi-node/src/lib.rs b/v2/crates/rvcsi-node/src/lib.rs deleted file mode 100644 index 7f56381eb5..0000000000 --- a/v2/crates/rvcsi-node/src/lib.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! # rvCSI Node.js bindings — napi-rs (ADR-095 D3/D4, ADR-096) -//! -//! The safe TypeScript-facing surface over the rvCSI Rust runtime. Nothing here -//! exposes raw pointers; every value that crosses the boundary is either a -//! normalized rvCSI struct *serialized to JSON* or a scalar. Frames are run -//! through [`rvcsi_core::validate_frame`] inside [`rvcsi_runtime`] before they -//! reach JS (D6), so a JS caller never sees a `Pending` or `Rejected` frame. -//! -//! All real logic lives in the `rvcsi-runtime` crate (plain Rust, unit-tested -//! without a Node env); the `#[napi]` items below are one-liner wrappers. -//! -//! ## JS surface (also see the generated `index.d.ts` in the npm package) -//! -//! Free functions: -//! * `rvcsiVersion(): string` -//! * `nexmonShimAbiVersion(): number` — ABI of the linked napi-c shim -//! * `nexmonDecodeRecords(buf: Buffer, sourceId: string, sessionId: number): string` -//! — JSON array of validated `CsiFrame`s decoded from the C-shim record format -//! * `inspectCaptureFile(path: string): string` — JSON `CaptureSummary` -//! * `eventsFromCaptureFile(path: string): string` — JSON array of `CsiEvent`s -//! * `exportCaptureToRfMemory(capturePath: string, outJsonlPath: string): number` -//! — windows stored -//! -//! Class `RvcsiRuntime` (streaming): -//! * `RvcsiRuntime.openCaptureFile(path): RvcsiRuntime` -//! * `RvcsiRuntime.openNexmonFile(path, sourceId, sessionId): RvcsiRuntime` -//! * `.nextFrameJson(): string | null` / `.nextCleanFrameJson(): string | null` -//! * `.drainEventsJson(): string` — JSON array of `CsiEvent`s -//! * `.healthJson(): string` — JSON `SourceHealth` -//! * `.framesSeen` / `.framesDropped` (getters) - -#![deny(clippy::all)] - -#[macro_use] -extern crate napi_derive; - -use napi::bindgen_prelude::Buffer; - -use rvcsi_runtime::{self as runtime, CaptureRuntime}; - -fn napi_err(e: impl std::fmt::Display) -> napi::Error { - napi::Error::from_reason(e.to_string()) -} - -fn to_json(v: &T) -> napi::Result { - serde_json::to_string(v).map_err(napi_err) -} - -// --------------------------------------------------------------------------- -// Free functions -// --------------------------------------------------------------------------- - -/// rvCSI runtime version (the workspace crate version). -#[napi] -pub fn rvcsi_version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -/// ABI version of the linked napi-c Nexmon shim (`major << 16 | minor`). -#[napi] -pub fn nexmon_shim_abi_version() -> u32 { - runtime::nexmon_shim_abi_version() -} - -/// Decode a `Buffer` of "rvCSI Nexmon records" (the napi-c shim format) into a -/// JSON array of validated `CsiFrame`s. Throws on a malformed record. -#[napi] -pub fn nexmon_decode_records(buf: Buffer, source_id: String, session_id: u32) -> napi::Result { - let frames = runtime::decode_nexmon_records(buf.as_ref(), &source_id, session_id as u64).map_err(napi_err)?; - to_json(&frames) -} - -/// Summarize a `.rvcsi` capture file; returns JSON for a `CaptureSummary`. -#[napi] -pub fn inspect_capture_file(path: String) -> napi::Result { - let summary = runtime::summarize_capture(&path).map_err(napi_err)?; - to_json(&summary) -} - -/// Replay a `.rvcsi` capture through the DSP + event pipeline; returns a JSON -/// array of `CsiEvent`s. -#[napi] -pub fn events_from_capture_file(path: String) -> napi::Result { - let events = runtime::events_from_capture(&path).map_err(napi_err)?; - to_json(&events) -} - -/// Replay a `.rvcsi` capture, window it, and store each window's embedding into -/// a JSONL RF-memory file; returns the number of windows stored. -#[napi] -pub fn export_capture_to_rf_memory(capture_path: String, out_jsonl_path: String) -> napi::Result { - let n = runtime::export_capture_to_rf_memory(&capture_path, &out_jsonl_path).map_err(napi_err)?; - 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); `chip` is an optional chip / Raspberry-Pi-model spec -/// (`"pi5"`, `"bcm43455c0"`, ...) — when given, frames are validated against -/// that device's profile and the non-conforming ones dropped. Throws if the -/// buffer isn't a parseable classic pcap or `chip` is unrecognised. -#[napi] -pub fn nexmon_decode_pcap( - pcap: Buffer, - source_id: String, - session_id: u32, - port: Option, - chip: Option, -) -> napi::Result { - let frames = runtime::decode_nexmon_pcap_for(pcap.as_ref(), &source_id, session_id as u64, port, chip.as_deref()) - .map_err(napi_err)?; - to_json(&frames) -} - -/// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, -/// bandwidths, chip versions + resolved chip names, RSSI range, time span); -/// returns JSON for a `NexmonPcapSummary`. `port` defaults to 5500. -#[napi] -pub fn inspect_nexmon_pcap(path: String, port: Option) -> napi::Result { - let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?; - to_json(&summary) -} - -/// Decode a Broadcom d11ac chanspec word; returns JSON -/// `{ chanspec, channel, bandwidth_mhz, is_5ghz }`. -#[napi] -pub fn decode_chanspec(chanspec: u32) -> napi::Result { - let d = rvcsi_adapter_nexmon::decode_chanspec((chanspec & 0xFFFF) as u16); - to_json(&serde_json::json!({ - "chanspec": d.chanspec, - "channel": d.channel, - "bandwidth_mhz": d.bandwidth_mhz, - "is_5ghz": d.is_5ghz, - })) -} - -/// Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug -/// (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise). -#[napi] -pub fn nexmon_chip_name(chip_ver: u32) -> String { - rvcsi_adapter_nexmon::NexmonChip::from_chip_ver((chip_ver & 0xFFFF) as u16).slug() -} - -/// The `AdapterProfile` (channels / bandwidths / expected subcarrier counts / -/// capability flags) for a chip / Raspberry-Pi-model spec (`"pi5"`, -/// `"bcm43455c0"`, `"raspberry pi 4"`, ...); returns JSON. Throws if unknown. -#[napi] -pub fn nexmon_profile(spec: String) -> napi::Result { - let p = runtime::nexmon_profile_for(&spec) - .ok_or_else(|| napi::Error::from_reason(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?; - to_json(&p) -} - -/// JSON listing of the Nexmon-supported chips + the Raspberry Pi models that -/// carry them (incl. the Pi 5 → BCM43455c0): `{ chips: [...], raspberryPiModels: [...] }`. -#[napi] -pub fn nexmon_chips() -> napi::Result { - use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip}; - let chips: Vec<_> = known_chips() - .iter() - .map(|c| { - let p = nexmon_adapter_profile(*c); - serde_json::json!({ - "slug": c.slug(), "description": c.description(), - "dualBand": c.dual_band(), "int16IqExport": c.uses_int16_iq(), - "bandwidthsMhz": p.supported_bandwidths_mhz, - "expectedSubcarrierCounts": p.expected_subcarrier_counts, - }) - }) - .collect(); - let pis: Vec<_> = known_pi_models() - .iter() - .map(|m| { - let chip = m.nexmon_chip(); - serde_json::json!({ - "slug": m.slug(), - "chip": if matches!(chip, NexmonChip::Unknown { .. }) { serde_json::Value::Null } else { serde_json::Value::String(chip.slug()) }, - "csiSupported": m.csi_supported(), - }) - }) - .collect(); - to_json(&serde_json::json!({ "chips": chips, "raspberryPiModels": pis })) -} - -// --------------------------------------------------------------------------- -// Streaming runtime class -// --------------------------------------------------------------------------- - -/// A streaming capture runtime: a source + the DSP stage + the event pipeline. -#[napi] -pub struct RvcsiRuntime { - inner: CaptureRuntime, -} - -#[napi] -impl RvcsiRuntime { - /// Open a `.rvcsi` capture file as the source. - #[napi(factory)] - pub fn open_capture_file(path: String) -> napi::Result { - Ok(RvcsiRuntime { - inner: CaptureRuntime::open_capture_file(&path).map_err(napi_err)?, - }) - } - - /// Open a Nexmon capture file (concatenated rvCSI Nexmon records) as the source. - #[napi(factory)] - pub fn open_nexmon_file(path: String, source_id: String, session_id: u32) -> napi::Result { - Ok(RvcsiRuntime { - inner: CaptureRuntime::open_nexmon_file(&path, &source_id, session_id as u64).map_err(napi_err)?, - }) - } - - /// Open a real nexmon_csi `.pcap` capture as the source. `port` is the CSI - /// UDP port (omit / `null` ⇒ 5500). - #[napi(factory)] - pub fn open_nexmon_pcap( - path: String, - source_id: String, - session_id: u32, - port: Option, - ) -> napi::Result { - Ok(RvcsiRuntime { - inner: CaptureRuntime::open_nexmon_pcap(&path, &source_id, session_id as u64, port) - .map_err(napi_err)?, - }) - } - - /// Next exposable, validated frame as JSON, or `null` at end-of-stream. - #[napi] - pub fn next_frame_json(&mut self) -> napi::Result> { - match self.inner.next_validated_frame().map_err(napi_err)? { - Some(f) => Ok(Some(to_json(&f)?)), - None => Ok(None), - } - } - - /// Like `nextFrameJson` but with the DSP pipeline applied (cleaned amplitude/phase). - #[napi] - pub fn next_clean_frame_json(&mut self) -> napi::Result> { - match self.inner.next_clean_frame().map_err(napi_err)? { - Some(f) => Ok(Some(to_json(&f)?)), - None => Ok(None), - } - } - - /// Drain the rest of the stream through DSP + the event pipeline; JSON array of `CsiEvent`s. - #[napi] - pub fn drain_events_json(&mut self) -> napi::Result { - let events = self.inner.drain_events().map_err(napi_err)?; - to_json(&events) - } - - /// Health snapshot as JSON (`SourceHealth`). - #[napi] - pub fn health_json(&self) -> napi::Result { - to_json(&self.inner.health()) - } - - /// Frames pulled from the source so far. - #[napi(getter)] - pub fn frames_seen(&self) -> u32 { - self.inner.frames_seen() as u32 - } - - /// Frames dropped by validation so far. - #[napi(getter)] - pub fn frames_dropped(&self) -> u32 { - self.inner.frames_dropped() as u32 - } -} diff --git a/v2/crates/rvcsi-runtime/Cargo.toml b/v2/crates/rvcsi-runtime/Cargo.toml deleted file mode 100644 index 2e6bbab32f..0000000000 --- a/v2/crates/rvcsi-runtime/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "rvcsi-runtime" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI runtime composition — wires a CsiSource + DSP + the event pipeline + RuVector export; the shared layer under rvcsi-node and rvcsi-cli (ADR-096)" -repository.workspace = true -keywords = ["wifi", "csi", "rvcsi", "runtime"] -categories = ["science"] - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -rvcsi-dsp = { path = "../rvcsi-dsp" } -rvcsi-events = { path = "../rvcsi-events" } -rvcsi-adapter-file = { path = "../rvcsi-adapter-file" } -rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" } -rvcsi-ruvector = { path = "../rvcsi-ruvector" } -serde = { workspace = true } -serde_json = { workspace = true } - -[dev-dependencies] -tempfile = "3.10" diff --git a/v2/crates/rvcsi-runtime/src/capture.rs b/v2/crates/rvcsi-runtime/src/capture.rs deleted file mode 100644 index 4d887395eb..0000000000 --- a/v2/crates/rvcsi-runtime/src/capture.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! A streaming capture runtime: a [`CsiSource`](rvcsi_core::CsiSource) + the DSP -//! stage + the event pipeline, wired together. The `rvcsi-node` napi-rs -//! `RvcsiRuntime` class is a thin `#[napi]` wrapper around [`CaptureRuntime`]. - -use rvcsi_adapter_file::FileReplayAdapter; -use rvcsi_adapter_nexmon::NexmonAdapter; -use rvcsi_core::{ - validate_frame, AdapterProfile, CsiEvent, CsiFrame, CsiSource, RvcsiError, SessionId, - SourceHealth, SourceId, ValidationPolicy, ValidationStatus, -}; -use rvcsi_dsp::SignalPipeline; -use rvcsi_events::EventPipeline; - -/// Owns a source and the per-frame processing chain. -/// -/// `next_validated_frame` pulls from the source and guarantees the returned -/// frame is *exposable* (Accepted/Degraded/Recovered) — frames that arrive -/// `Pending` are validated against the source's profile, and hard-rejected -/// frames are skipped (never surfaced). `drain_events` runs the remainder of the -/// stream through `SignalPipeline` + `EventPipeline`. -pub struct CaptureRuntime { - source: Box, - profile: AdapterProfile, - policy: ValidationPolicy, - dsp: SignalPipeline, - events: EventPipeline, - prev_ts: Option, - frames_seen: u64, - frames_dropped: u64, -} - -impl CaptureRuntime { - fn new(source: Box, policy: ValidationPolicy) -> Self { - let profile = source.profile().clone(); - let session_id = source.session_id(); - let source_id = source.source_id().clone(); - CaptureRuntime { - source, - profile, - policy, - dsp: SignalPipeline::default(), - events: EventPipeline::with_defaults(session_id, source_id), - prev_ts: None, - frames_seen: 0, - frames_dropped: 0, - } - } - - /// Open a `.rvcsi` capture file as the source. - pub fn open_capture_file(path: &str) -> Result { - let source = FileReplayAdapter::open(path)?; - Ok(Self::new(Box::new(source), ValidationPolicy::default())) - } - - /// Open a buffer of "rvCSI Nexmon records" (the napi-c shim format) as the source. - pub fn open_nexmon_bytes(bytes: Vec, source_id: &str, session_id: u64) -> Self { - let source = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes); - // Permissive policy: the C-shim records may carry non-default subcarrier counts. - Self::new(Box::new(source), ValidationPolicy::default()) - } - - /// Open a Nexmon capture *file* (concatenated records) as the source. - pub fn open_nexmon_file(path: &str, source_id: &str, session_id: u64) -> Result { - let bytes = std::fs::read(path)?; - Ok(Self::open_nexmon_bytes(bytes, source_id, session_id)) - } - - /// Open a real nexmon_csi `.pcap` capture (`tcpdump -i wlan0 dst port 5500 -w …`) - /// as the source. `port` is the CSI UDP port (`None` ⇒ 5500). - pub fn open_nexmon_pcap( - path: &str, - source_id: &str, - session_id: u64, - port: Option, - ) -> Result { - let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::open( - SourceId::from(source_id), - SessionId(session_id), - path, - port, - )?; - Ok(Self::new(Box::new(source), ValidationPolicy::default())) - } - - /// Open a real nexmon_csi `.pcap` from an in-memory byte buffer. - pub fn open_nexmon_pcap_bytes( - pcap_bytes: &[u8], - source_id: &str, - session_id: u64, - port: Option, - ) -> Result { - let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse( - SourceId::from(source_id), - SessionId(session_id), - pcap_bytes, - port, - )?; - Ok(Self::new(Box::new(source), ValidationPolicy::default())) - } - - /// Validate (if needed) a freshly pulled frame; `None` if it was hard-rejected. - fn admit(&mut self, mut frame: CsiFrame) -> Option { - self.frames_seen += 1; - if frame.validation == ValidationStatus::Pending { - let ts = frame.timestamp_ns; - match validate_frame(&mut frame, &self.profile, &self.policy, self.prev_ts) { - Ok(()) if frame.is_exposable() => { - self.prev_ts = Some(ts); - Some(frame) - } - _ => { - self.frames_dropped += 1; - None - } - } - } else if frame.is_exposable() { - Some(frame) - } else { - self.frames_dropped += 1; - None - } - } - - /// Pull the next exposable frame, validating it if necessary. `Ok(None)` at - /// end-of-stream. The frame's `amplitude`/`phase` are NOT yet DSP-cleaned - /// (call [`CaptureRuntime::next_clean_frame`] for that). - pub fn next_validated_frame(&mut self) -> Result, RvcsiError> { - loop { - match self.source.next_frame()? { - None => return Ok(None), - Some(frame) => { - if let Some(f) = self.admit(frame) { - return Ok(Some(f)); - } - } - } - } - } - - /// Like [`CaptureRuntime::next_validated_frame`] but with `SignalPipeline` - /// applied (DC removal, phase unwrap, Hampel filter, smoothing). - pub fn next_clean_frame(&mut self) -> Result, RvcsiError> { - match self.next_validated_frame()? { - None => Ok(None), - Some(mut f) => { - self.dsp.process_frame(&mut f); - Ok(Some(f)) - } - } - } - - /// Drain the rest of the stream through DSP + the event pipeline and return - /// every emitted event (in order). - pub fn drain_events(&mut self) -> Result, RvcsiError> { - let mut out = Vec::new(); - while let Some(mut f) = self.next_validated_frame()? { - self.dsp.process_frame(&mut f); - out.extend(self.events.process_frame(&f)); - } - out.extend(self.events.flush()); - Ok(out) - } - - /// Health snapshot combining the source's view and the runtime's counters. - pub fn health(&self) -> SourceHealth { - let mut h = self.source.health(); - // Augment the status with the runtime's drop count. - let extra = format!("frames_seen={}, frames_dropped={}", self.frames_seen, self.frames_dropped); - h.status = Some(match h.status { - Some(s) => format!("{s}; {extra}"), - None => extra, - }); - h - } - - /// Frames pulled from the source so far. - pub fn frames_seen(&self) -> u64 { - self.frames_seen - } - - /// Frames dropped by validation so far. - pub fn frames_dropped(&self) -> u64 { - self.frames_dropped - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_adapter_file::{CaptureHeader, FileRecorder}; - use rvcsi_adapter_nexmon::{encode_record, NexmonRecord}; - use rvcsi_core::{AdapterKind, FrameId}; - - fn write_capture(path: &std::path::Path, n: usize) { - let header = CaptureHeader::new( - SessionId(1), - SourceId::from("rt"), - AdapterProfile::offline(AdapterKind::File), - ); - let mut rec = FileRecorder::create(path, &header).unwrap(); - for k in 0..n { - let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 }; - let i: Vec = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect(); - let q: Vec = (0..32).map(|_| 0.5).collect(); - let mut f = CsiFrame::from_iq( - FrameId(k as u64), - SessionId(1), - SourceId::from("rt"), - AdapterKind::File, - 1_000 + k as u64 * 50_000_000, - 6, - 20, - i, - q, - ) - .with_rssi(-55); - f.validation = ValidationStatus::Accepted; - f.quality_score = 0.9; - rec.write_frame(&f).unwrap(); - } - rec.finish().unwrap(); - } - - #[test] - fn streams_validated_frames_from_a_capture() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 5); - let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap(); - let mut count = 0; - while let Some(f) = rt.next_validated_frame().unwrap() { - assert!(f.is_exposable()); - count += 1; - } - assert_eq!(count, 5); - assert_eq!(rt.frames_seen(), 5); - assert_eq!(rt.frames_dropped(), 0); - let h = rt.health(); - assert!(h.status.unwrap().contains("frames_seen=5")); - } - - #[test] - fn clean_frame_applies_dsp_without_changing_validation() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 3); - let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap(); - let f = rt.next_clean_frame().unwrap().unwrap(); - assert_eq!(f.validation, ValidationStatus::Accepted); - assert_eq!(f.quality_score, 0.9); - assert_eq!(f.amplitude.len(), 32); - } - - #[test] - fn drains_events_from_an_alternating_stream() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 64); - let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap(); - let events = rt.drain_events().unwrap(); - assert!(!events.is_empty()); - for e in &events { - e.validate().unwrap(); - } - } - - #[test] - fn runs_a_nexmon_record_stream() { - let mk = |ts: u64| { - let rec = NexmonRecord { - subcarrier_count: 64, - channel: 36, - bandwidth_mhz: 80, - rssi_dbm: Some(-60), - noise_floor_dbm: Some(-92), - timestamp_ns: ts, - i_values: (0..64).map(|k| (k as f32 % 3.0) - 1.0).collect(), - q_values: (0..64).map(|k| (k as f32 % 5.0) * 0.1).collect(), - }; - encode_record(&rec).unwrap() - }; - let mut buf = Vec::new(); - for k in 0..40 { - buf.extend(mk(1_000 + k * 50_000_000)); - } - let mut rt = CaptureRuntime::open_nexmon_bytes(buf, "nexmon-rt", 3); - let mut n = 0; - while let Some(f) = rt.next_validated_frame().unwrap() { - assert_eq!(f.adapter_kind, AdapterKind::Nexmon); - assert!(f.is_exposable()); - n += 1; - } - assert_eq!(n, 40); - } - - #[test] - fn runs_a_real_nexmon_csi_pcap() { - use rvcsi_adapter_nexmon::NexmonCsiHeader; - let chanspec = 0x1000u16 | 6; // 2.4 GHz ch6 20 MHz - let nsub = 64u16; - let frames: Vec<(u64, NexmonCsiHeader, Vec, Vec)> = (0..12u64) - .map(|k| { - let i: Vec = (0..nsub).map(|s| (s as i16 - 32 + k as i16) as f32).collect(); - let q: Vec = (0..nsub).map(|_| 1.0f32).collect(); - ( - 1_000_000_000 + k * 50_000_000, - NexmonCsiHeader { - rssi_dbm: -55 - k as i16, - fctl: 8, - src_mac: [0, 1, 2, 3, 4, 5], - seq_cnt: k as u16, - core: 0, - spatial_stream: 0, - chanspec, - chip_ver: 0x4345, - channel: 0, - bandwidth_mhz: 0, - is_5ghz: false, - subcarrier_count: nsub, - }, - i, - q, - ) - }) - .collect(); - let pcap = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap(); - let mut rt = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "nexmon-pcap-rt", 1, None).unwrap(); - let mut got = 0; - while let Some(f) = rt.next_validated_frame().unwrap() { - assert_eq!(f.adapter_kind, AdapterKind::Nexmon); - assert_eq!(f.channel, 6); - assert_eq!(f.bandwidth_mhz, 20); - assert!(f.is_exposable()); - got += 1; - } - assert_eq!(got, 12); - let events = { - let mut rt2 = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "n", 2, None).unwrap(); - rt2.drain_events().unwrap() - }; - for e in &events { - e.validate().unwrap(); - } - } - - #[test] - fn missing_file_is_an_error() { - assert!(CaptureRuntime::open_capture_file("/nope/x.rvcsi").is_err()); - assert!(CaptureRuntime::open_nexmon_file("/nope/x.bin", "s", 0).is_err()); - assert!(CaptureRuntime::open_nexmon_pcap("/nope/x.pcap", "s", 0, None).is_err()); - assert!(CaptureRuntime::open_nexmon_pcap_bytes(&[0u8; 8], "s", 0, None).is_err()); - } -} diff --git a/v2/crates/rvcsi-runtime/src/lib.rs b/v2/crates/rvcsi-runtime/src/lib.rs deleted file mode 100644 index 857344bb3f..0000000000 --- a/v2/crates/rvcsi-runtime/src/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! # rvCSI runtime composition -//! -//! The glue layer that wires the leaf crates together — a [`rvcsi_core::CsiSource`] -//! → [`rvcsi_core::validate_frame`] → [`rvcsi_dsp::SignalPipeline`] → -//! [`rvcsi_events::EventPipeline`] → [`rvcsi_ruvector`] export — into a small set -//! of operations the `rvcsi` CLI and the `rvcsi-node` napi-rs addon both build -//! on (ADR-096). Pure Rust, no FFI, no Node — fully unit-tested here. -//! -//! Two entry points: -//! -//! * one-shot helpers in [`summary`] — [`summarize_capture`], [`decode_nexmon_records`], -//! [`events_from_capture`], [`export_capture_to_rf_memory`], [`rf_memory_self_check`]; -//! * the streaming [`CaptureRuntime`] in [`capture`] — `next_validated_frame` / -//! `next_clean_frame` / `drain_events` / `health`. - -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -pub mod capture; -pub mod summary; - -pub use capture::CaptureRuntime; -pub use summary::{ - decode_nexmon_pcap, decode_nexmon_pcap_for, decode_nexmon_records, events_from_capture, - export_capture_to_rf_memory, nexmon_profile_for, rf_memory_self_check, summarize_capture, - summarize_nexmon_pcap, CaptureSummary, NexmonPcapSummary, ValidationBreakdown, -}; - -/// ABI version of the linked napi-c Nexmon shim (re-exported for convenience). -pub fn nexmon_shim_abi_version() -> u32 { - rvcsi_adapter_nexmon::shim_abi_version() -} diff --git a/v2/crates/rvcsi-runtime/src/summary.rs b/v2/crates/rvcsi-runtime/src/summary.rs deleted file mode 100644 index 3436389938..0000000000 --- a/v2/crates/rvcsi-runtime/src/summary.rs +++ /dev/null @@ -1,594 +0,0 @@ -//! One-shot capture operations: summarize a `.rvcsi` file, decode a buffer of -//! napi-c Nexmon records, replay a capture into events, export windows to a -//! JSONL RF-memory file. Everything returns normalized/validated rvCSI types — -//! frames are always run through `validate_frame` and never returned `Pending` -//! or `Rejected` (ADR-095 D6). - -use serde::{Deserialize, Serialize}; - -use rvcsi_adapter_file::{read_all, CaptureHeader}; -use rvcsi_adapter_nexmon::NexmonAdapter; -use rvcsi_core::{ - validate_frame, AdapterProfile, CsiEvent, CsiFrame, RvcsiError, SessionId, SourceId, - ValidationPolicy, ValidationStatus, -}; -use rvcsi_dsp::SignalPipeline; -use rvcsi_events::EventPipeline; -use rvcsi_ruvector::{window_embedding, InMemoryRfMemory, JsonlRfMemory, RfMemoryStore}; - -/// A compact summary of a `.rvcsi` capture file (the `rvcsi inspect` payload / -/// the `inspectCaptureFile` napi return). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CaptureSummary { - /// The recorded capture format version. - pub capture_version: u32, - /// Session id from the header. - pub session_id: u64, - /// Source id from the header. - pub source_id: String, - /// Adapter kind slug from the header's profile. - pub adapter_kind: String, - /// The header's adapter-profile `chip` string, if any (e.g. `"bcm43455c0 (pi5)"`). - pub chip: Option, - /// Number of frames in the capture. - pub frame_count: usize, - /// First / last frame timestamp (ns); `0` for an empty capture. - pub first_timestamp_ns: u64, - /// Last frame timestamp (ns). - pub last_timestamp_ns: u64, - /// Distinct WiFi channels seen. - pub channels: Vec, - /// Distinct subcarrier counts seen. - pub subcarrier_counts: Vec, - /// Mean `quality_score` over all frames (`0.0` for an empty capture). - pub mean_quality: f32, - /// Count of frames by `ValidationStatus` (`accepted`, `degraded`, `recovered`, - /// `rejected`, `pending`). - pub validation_breakdown: ValidationBreakdown, - /// Calibration version recorded in the header, if any. - pub calibration_version: Option, -} - -/// Per-`ValidationStatus` frame counts. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ValidationBreakdown { - /// `ValidationStatus::Pending` - pub pending: usize, - /// `ValidationStatus::Accepted` - pub accepted: usize, - /// `ValidationStatus::Degraded` - pub degraded: usize, - /// `ValidationStatus::Rejected` - pub rejected: usize, - /// `ValidationStatus::Recovered` - pub recovered: usize, -} - -impl ValidationBreakdown { - fn tally(&mut self, s: ValidationStatus) { - match s { - ValidationStatus::Pending => self.pending += 1, - ValidationStatus::Accepted => self.accepted += 1, - ValidationStatus::Degraded => self.degraded += 1, - ValidationStatus::Rejected => self.rejected += 1, - ValidationStatus::Recovered => self.recovered += 1, - } - } -} - -fn sorted_unique(mut v: Vec) -> Vec { - v.sort_unstable(); - v.dedup(); - v -} - -/// Summarize a `.rvcsi` capture file. -pub fn summarize_capture(path: &str) -> Result { - let (header, frames): (CaptureHeader, Vec) = read_all(path)?; - let mut channels = Vec::new(); - let mut subcarrier_counts = Vec::new(); - let mut breakdown = ValidationBreakdown::default(); - let mut quality_sum = 0.0f32; - let (mut first_ts, mut last_ts) = (u64::MAX, 0u64); - for f in &frames { - channels.push(f.channel); - subcarrier_counts.push(f.subcarrier_count); - breakdown.tally(f.validation); - quality_sum += f.quality_score; - first_ts = first_ts.min(f.timestamp_ns); - last_ts = last_ts.max(f.timestamp_ns); - } - if frames.is_empty() { - first_ts = 0; - } - Ok(CaptureSummary { - capture_version: header.rvcsi_capture_version, - session_id: header.session_id.value(), - source_id: header.source_id.0, - adapter_kind: header.adapter_profile.adapter_kind.slug().to_string(), - chip: header.adapter_profile.chip.clone(), - frame_count: frames.len(), - first_timestamp_ns: first_ts, - last_timestamp_ns: last_ts, - channels: sorted_unique(channels), - subcarrier_counts: sorted_unique(subcarrier_counts), - mean_quality: if frames.is_empty() { - 0.0 - } else { - quality_sum / frames.len() as f32 - }, - validation_breakdown: breakdown, - calibration_version: header.calibration_version, - }) -} - -/// Validate a batch of raw (`Pending`) frames against `profile`, in timestamp -/// order; drop the hard-rejected ones and return the survivors. -fn validate_frames_against(raw: Vec, profile: &AdapterProfile) -> Vec { - let policy = ValidationPolicy::default(); - let mut out = Vec::with_capacity(raw.len()); - let mut prev_ts: Option = None; - for mut f in raw { - let ts = f.timestamp_ns; - if f.validation == ValidationStatus::Pending { - match validate_frame(&mut f, profile, &policy, prev_ts) { - Ok(()) if f.is_exposable() => { - prev_ts = Some(ts); - out.push(f); - } - _ => { /* hard-rejected — dropped */ } - } - } else if f.is_exposable() { - out.push(f); - } - } - out -} - -/// Validate against a permissive (offline-Nexmon) profile — accepts any -/// subcarrier count / channel. Used when no specific chip was requested. -fn validate_frames_permissive(raw: Vec) -> Vec { - validate_frames_against(raw, &AdapterProfile::offline(rvcsi_core::AdapterKind::Nexmon)) -} - -/// Resolve a chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`, -/// `"raspberry pi 4"`, `"4366c0"`, ...) to an [`AdapterProfile`], for the -/// `--chip` flag and SDK callers. Returns `None` for an unknown spec. -pub fn nexmon_profile_for(spec: &str) -> Option { - if let Some(model) = rvcsi_adapter_nexmon::RaspberryPiModel::from_slug(spec) { - return Some(rvcsi_adapter_nexmon::raspberry_pi_profile(model)); - } - rvcsi_adapter_nexmon::NexmonChip::from_slug(spec) - .map(rvcsi_adapter_nexmon::nexmon_adapter_profile) -} - -/// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into -/// validated [`CsiFrame`]s. Frames that hard-fail validation are dropped (never -/// returned to JS). -pub fn decode_nexmon_records( - bytes: &[u8], - source_id: &str, - session_id: u64, -) -> Result, RvcsiError> { - let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?; - Ok(validate_frames_permissive(raw)) -} - -/// Decode the *real* nexmon_csi UDP payloads inside a libpcap (`.pcap`) buffer -/// into validated [`CsiFrame`]s. `port` is the CSI UDP port (`None` ⇒ 5500). -/// Validation is permissive (any subcarrier count / channel survives); pass a -/// chip spec to [`decode_nexmon_pcap_for`] to bound against a specific device. -pub fn decode_nexmon_pcap( - pcap_bytes: &[u8], - source_id: &str, - session_id: u64, - port: Option, -) -> Result, RvcsiError> { - decode_nexmon_pcap_for(pcap_bytes, source_id, session_id, port, None) -} - -/// Like [`decode_nexmon_pcap`] but, when `chip_spec` is `Some` (`"pi5"`, -/// `"bcm43455c0"`, ...), validates each frame against that device's profile and -/// drops the non-conforming ones (e.g. a 256-subcarrier VHT80 frame against a -/// 2.4 GHz-only `bcm43436b0` profile). An unrecognised spec is a `Config` error. -pub fn decode_nexmon_pcap_for( - pcap_bytes: &[u8], - source_id: &str, - session_id: u64, - port: Option, - chip_spec: Option<&str>, -) -> Result, RvcsiError> { - let raw = rvcsi_adapter_nexmon::NexmonPcapAdapter::frames_from_pcap_bytes( - SourceId::from(source_id), - SessionId(session_id), - pcap_bytes, - port, - )?; - match chip_spec { - None => Ok(validate_frames_permissive(raw)), - Some(spec) => { - let profile = nexmon_profile_for(spec) - .ok_or_else(|| RvcsiError::Config(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?; - Ok(validate_frames_against(raw, &profile)) - } - } -} - -/// A compact summary of a nexmon_csi `.pcap` capture (the `rvcsi inspect-nexmon` -/// payload). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct NexmonPcapSummary { - /// libpcap link-layer type of the capture. - pub link_type: u32, - /// CSI frames decoded from the capture. - pub csi_frame_count: usize, - /// Non-CSI / skipped UDP packets (wrong port, not IPv4/UDP, bad nexmon magic). - pub skipped_packets: u64, - /// First / last CSI packet timestamp (ns since the Unix epoch); `0` if empty. - pub first_timestamp_ns: u64, - /// Last CSI packet timestamp (ns). - pub last_timestamp_ns: u64, - /// Distinct WiFi channels seen (decoded from the chanspec). - pub channels: Vec, - /// Distinct bandwidths (MHz) seen. - pub bandwidths_mhz: Vec, - /// Distinct subcarrier (FFT) counts seen. - pub subcarrier_counts: Vec, - /// Distinct chip-version words seen (e.g. `0x4345` = the BCM4345 family). - pub chip_versions: Vec, - /// Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise). - pub chip_names: Vec, - /// The chip the adapter settled on (all packets agreed) — `"bcm43455c0"` for a Pi 5 capture. - pub detected_chip: String, - /// Min / max RSSI (dBm) over the CSI packets; `None` if empty. - pub rssi_dbm_range: Option<(i16, i16)>, -} - -/// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, etc.). -pub fn summarize_nexmon_pcap(path: &str, port: Option) -> Result { - let bytes = std::fs::read(path)?; - let adapter = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse( - SourceId::from(format!("pcap:{path}")), - SessionId(0), - &bytes, - port, - )?; - let health = adapter.health(); - let detected_chip = adapter.detected_chip().slug(); - let headers = adapter.headers(); - let mut channels = Vec::new(); - let mut bandwidths = Vec::new(); - let mut subs = Vec::new(); - let mut chips = Vec::new(); - let mut chip_names = Vec::new(); - let (mut rssi_lo, mut rssi_hi) = (i16::MAX, i16::MIN); - for h in headers { - channels.push(h.channel); - bandwidths.push(h.bandwidth_mhz); - subs.push(h.subcarrier_count); - chips.push(h.chip_ver); - chip_names.push(h.chip().slug()); - rssi_lo = rssi_lo.min(h.rssi_dbm); - rssi_hi = rssi_hi.max(h.rssi_dbm); - } - chip_names.sort(); - chip_names.dedup(); - let (mut first_ts, mut last_ts) = (u64::MAX, 0u64); - // re-iterate frames for timestamps (headers don't carry the pcap time) - let mut a2 = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse( - 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), - chip_names, - detected_chip, - rssi_dbm_range: (!headers.is_empty()).then_some((rssi_lo, rssi_hi)), - }) -} - -/// Replay a `.rvcsi` capture through the DSP + event pipeline and collect every -/// emitted [`CsiEvent`]. Frames that arrive `Pending` are validated first; -/// already-validated frames are trusted (replay fidelity). -pub fn events_from_capture(path: &str) -> Result, RvcsiError> { - let (header, frames) = read_all(path)?; - let dsp = SignalPipeline::default(); - let mut pipeline = EventPipeline::with_defaults(header.session_id, header.source_id.clone()); - let profile = header.adapter_profile.clone(); - let policy = header.validation_policy.clone(); - let mut prev_ts: Option = None; - let mut events = Vec::new(); - for mut f in frames { - if f.validation == ValidationStatus::Pending { - let ts = f.timestamp_ns; - if validate_frame(&mut f, &profile, &policy, prev_ts).is_err() || !f.is_exposable() { - continue; - } - prev_ts = Some(ts); - } - dsp.process_frame(&mut f); - events.extend(pipeline.process_frame(&f)); - } - events.extend(pipeline.flush()); - Ok(events) -} - -/// Replay a `.rvcsi` capture, window it, and store every window's embedding into -/// a JSONL RF-memory file (the `rvcsi export ruvector` payload). Returns the -/// number of windows stored. -pub fn export_capture_to_rf_memory(capture_path: &str, out_jsonl_path: &str) -> Result { - let (header, frames) = read_all(capture_path)?; - let mut pipeline = EventPipeline::with_defaults(header.session_id, header.source_id.clone()); - let dsp = SignalPipeline::default(); - let mut store = JsonlRfMemory::create(out_jsonl_path)?; - let mut stored = 0usize; - for mut f in frames { - if !f.is_exposable() { - continue; - } - dsp.process_frame(&mut f); - let _ = pipeline.process_frame(&f); - } - let _ = pipeline.flush(); - for w in pipeline.recent_windows() { - store.store_window(w)?; - stored += 1; - } - Ok(stored) -} - -/// Convenience used by tests / examples: window a capture in memory and return -/// `(window_count, top_self_similarity)` — storing each window then querying -/// with the first window's embedding should yield itself with score ≈ 1.0. -pub fn rf_memory_self_check(capture_path: &str) -> Result<(usize, f32), RvcsiError> { - let (header, frames) = read_all(capture_path)?; - let mut pipeline = EventPipeline::with_defaults(header.session_id, header.source_id.clone()); - for f in &frames { - if f.is_exposable() { - let _ = pipeline.process_frame(f); - } - } - let _ = pipeline.flush(); - let windows: Vec<_> = pipeline.recent_windows().to_vec(); - let mut store = InMemoryRfMemory::new(); - for w in &windows { - store.store_window(w)?; - } - if windows.is_empty() { - return Ok((0, 0.0)); - } - let q = window_embedding(&windows[0]); - let hits = store.query_similar(&q, 1)?; - Ok((windows.len(), hits.first().map(|h| h.score).unwrap_or(0.0))) -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_adapter_file::FileRecorder; - use rvcsi_adapter_nexmon::{encode_record, NexmonCsiHeader, NexmonRecord}; - use rvcsi_core::{AdapterKind, FrameId}; - - fn write_capture(path: &std::path::Path, n: usize) { - let header = CaptureHeader::new( - SessionId(1), - SourceId::from("it"), - AdapterProfile::offline(AdapterKind::File), - ); - let mut rec = FileRecorder::create(path, &header).unwrap(); - for k in 0..n { - // alternate "quiet" and "active" amplitudes so the event pipeline has something to do - let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 }; - let i: Vec = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect(); - let q: Vec = (0..32).map(|s| 0.5 + amp_scale * (((k * 3 + s) % 7) as f32 - 3.0) * 0.1).collect(); - let mut f = CsiFrame::from_iq( - FrameId(k as u64), - SessionId(1), - SourceId::from("it"), - AdapterKind::File, - 1_000 + k as u64 * 50_000_000, // 50 ms apart - 6, - 20, - i, - q, - ) - .with_rssi(-55); - f.validation = ValidationStatus::Accepted; - f.quality_score = 0.9; - rec.write_frame(&f).unwrap(); - } - rec.finish().unwrap(); - } - - #[test] - fn summarize_a_recorded_capture() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 10); - let s = summarize_capture(tmp.path().to_str().unwrap()).unwrap(); - assert_eq!(s.capture_version, 1); - assert_eq!(s.session_id, 1); - assert_eq!(s.frame_count, 10); - assert_eq!(s.channels, vec![6]); - assert_eq!(s.subcarrier_counts, vec![32]); - assert_eq!(s.validation_breakdown.accepted, 10); - assert!((s.mean_quality - 0.9).abs() < 1e-5); - assert_eq!(s.first_timestamp_ns, 1_000); - assert!(s.last_timestamp_ns > s.first_timestamp_ns); - } - - #[test] - fn summarize_empty_capture() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - let header = CaptureHeader::new(SessionId(9), SourceId::from("e"), AdapterProfile::offline(AdapterKind::File)); - FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap(); - let s = summarize_capture(tmp.path().to_str().unwrap()).unwrap(); - assert_eq!(s.frame_count, 0); - assert_eq!(s.mean_quality, 0.0); - assert_eq!(s.first_timestamp_ns, 0); - } - - #[test] - fn decode_nexmon_records_validates_and_returns_frames() { - // two 64-subcarrier records - let mk = |ts: u64, rssi: i16| { - let rec = NexmonRecord { - subcarrier_count: 64, - channel: 36, - bandwidth_mhz: 80, - rssi_dbm: Some(rssi), - noise_floor_dbm: Some(-92), - timestamp_ns: ts, - i_values: (0..64).map(|k| (k as f32) * 0.25).collect(), - q_values: (0..64).map(|k| -(k as f32) * 0.1).collect(), - }; - encode_record(&rec).unwrap() - }; - let mut buf = mk(1_000, -58); - buf.extend(mk(2_000, -59)); - let frames = decode_nexmon_records(&buf, "nexmon-test", 7).unwrap(); - assert_eq!(frames.len(), 2); - for f in &frames { - assert!(f.is_exposable()); - assert_eq!(f.subcarrier_count, 64); - assert_eq!(f.adapter_kind, AdapterKind::Nexmon); - } - assert_eq!(frames[1].timestamp_ns, 2_000); - } - - #[test] - fn events_and_export_from_capture() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - write_capture(tmp.path(), 64); - let events = events_from_capture(tmp.path().to_str().unwrap()).unwrap(); - // the alternating quiet/active stream should produce at least one event, - // and every event must be well-formed. - assert!(!events.is_empty(), "expected the event pipeline to emit something"); - for e in &events { - e.validate().unwrap(); - assert!((0.0..=1.0).contains(&e.confidence)); - assert!(!e.evidence_window_ids.is_empty()); - } - - let out = tempfile::NamedTempFile::new().unwrap(); - let stored = export_capture_to_rf_memory( - tmp.path().to_str().unwrap(), - out.path().to_str().unwrap(), - ) - .unwrap(); - assert!(stored > 0); - // re-open the JSONL store and confirm the records round-tripped - let reopened = JsonlRfMemory::open(out.path().to_str().unwrap()).unwrap(); - assert_eq!(reopened.len(), stored); - - let (wc, score) = rf_memory_self_check(tmp.path().to_str().unwrap()).unwrap(); - assert!(wc > 0); - assert!((score - 1.0).abs() < 1e-4, "self-similarity should be ~1.0, got {score}"); - } - - #[test] - 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: 0x4345, - channel: 0, - bandwidth_mhz: 0, - is_5ghz: false, - subcarrier_count: nsub, - } - } - - fn synth_nexmon_pcap_bytes() -> Vec { - let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz - let nsub = 256u16; - let frames: Vec<(u64, NexmonCsiHeader, Vec, Vec)> = (0..4u64) - .map(|k| { - let i: Vec = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect(); - let q: Vec = (0..nsub).map(|s| (s as i16 % 7 + k as i16) as f32).collect(); - (1_000_000_000 + k * 50_000_000, synth_nexmon_header(-58 - k as i16, chanspec, nsub, k as u16 + 1), i, q) - }) - .collect(); - rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).expect("build pcap") - } - - #[test] - fn decode_nexmon_pcap_yields_validated_frames() { - let pcap = synth_nexmon_pcap_bytes(); - let frames = decode_nexmon_pcap(&pcap, "nexmon-pcap", 7, None).unwrap(); - assert_eq!(frames.len(), 4); - for f in &frames { - assert!(f.is_exposable()); - assert_eq!(f.adapter_kind, AdapterKind::Nexmon); - assert_eq!(f.channel, 36); - assert_eq!(f.bandwidth_mhz, 80); - assert_eq!(f.subcarrier_count, 256); - } - assert_eq!(frames[0].timestamp_ns, 1_000_000_000); - assert_eq!(frames[3].timestamp_ns, 1_000_000_000 + 3 * 50_000_000); - // explicit-port form works too - assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(5500)).unwrap().len(), 4); - assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(9999)).unwrap().len(), 0); - - // --chip pi5 / bcm43455c0: the 256-sc VHT80 ch36 frames all conform - assert_eq!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("pi5")).unwrap().len(), 4); - assert_eq!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("bcm43455c0")).unwrap().len(), 4); - // --chip pizero2w (bcm43436b0): 2.4 GHz only, max 128 sc -> all dropped - assert_eq!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("pizero2w")).unwrap().len(), 0); - // unknown spec -> Config error - assert!(decode_nexmon_pcap_for(&pcap, "s", 0, None, Some("not-a-chip")).is_err()); - // nexmon_profile_for resolves both chip slugs and Pi model slugs - assert!(nexmon_profile_for("pi5").is_some()); - assert!(nexmon_profile_for("bcm4366c0").is_some()); - assert!(nexmon_profile_for("nope").is_none()); - } - - #[test] - fn summarize_nexmon_pcap_reports_metadata_and_pi5_chip() { - 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![0x4345]); - // 0x4345 resolves to the BCM43455c0 — the chip on a Raspberry Pi 3B+/4/400/5 - assert_eq!(s.chip_names, vec!["bcm43455c0".to_string()]); - assert_eq!(s.detected_chip, "bcm43455c0"); - assert_eq!(s.rssi_dbm_range, Some((-61, -58))); - assert_eq!(s.first_timestamp_ns, 1_000_000_000); - assert!(s.last_timestamp_ns > s.first_timestamp_ns); - } -} diff --git a/v2/crates/rvcsi-ruvector/Cargo.toml b/v2/crates/rvcsi-ruvector/Cargo.toml deleted file mode 100644 index c794b05734..0000000000 --- a/v2/crates/rvcsi-ruvector/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "rvcsi-ruvector" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "rvCSI RuVector bridge — exports temporal RF embeddings + event metadata as a queryable RF-memory store (ADR-095 FR8, D8)" -repository.workspace = true -keywords = ["wifi", "csi", "ruvector", "rvcsi"] -categories = ["science"] - -[dependencies] -rvcsi-core = { path = "../rvcsi-core" } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } -tempfile = "3.10" diff --git a/v2/crates/rvcsi-ruvector/src/embedding.rs b/v2/crates/rvcsi-ruvector/src/embedding.rs deleted file mode 100644 index baa625058f..0000000000 --- a/v2/crates/rvcsi-ruvector/src/embedding.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Deterministic, dependency-free embedding functions for RF memory records. -//! -//! [`window_embedding`] turns a [`CsiWindow`] into a fixed-length -//! [`WINDOW_EMBEDDING_DIM`]-vector regardless of subcarrier count; -//! [`event_embedding`] turns a [`CsiEvent`] into a fixed-length -//! [`EVENT_EMBEDDING_DIM`]-vector. [`cosine_similarity`] is the comparison -//! metric used by the [`crate::RfMemoryStore`] implementations. -//! -//! All functions are pure and deterministic — the same input always yields the -//! same bytes, with no clocks, randomness, threads or floating-point -//! reductions whose order could vary. - -use rvcsi_core::{CsiEvent, CsiEventKind, CsiWindow}; - -/// Length of a [`window_embedding`] vector. -/// -/// Layout (all indices into the returned `Vec`): -/// * `0..32` — `mean_amplitude` linearly resampled to 32 bins -/// * `32..64` — `phase_variance` linearly resampled to 32 bins -/// * `64` — `motion_energy` -/// * `65` — `presence_score` -/// * `66` — `quality_score` -/// * `67` — `ln(1 + frame_count)` -/// -/// The whole vector is then L2-normalized (left all-zero if its norm is 0, -/// e.g. for an empty window). -pub const WINDOW_EMBEDDING_DIM: usize = 68; - -/// Length of an [`event_embedding`] vector. -/// -/// Layout: -/// * `0..10` — one-hot of [`CsiEventKind`] in declaration order (see -/// [`kind_index`]) -/// * `10` — `confidence` -/// * `11` — `ln(1 + evidence_window_ids.len())` -/// -/// Event embeddings are **not** normalized (the one-hot block already gives -/// them a stable scale). -pub const EVENT_EMBEDDING_DIM: usize = 12; - -/// Number of bins each per-subcarrier vector is resampled to. -const SUBCARRIER_BINS: usize = 32; - -/// Linearly resample `src` (length `n`) to length `m`. -/// -/// * `n == 0` → `vec![0.0; m]` -/// * `n == 1` → `vec![src[0]; m]` -/// * otherwise, for each output index `j`: `pos = j * (n-1) / (m-1)`, -/// `lo = floor(pos)`, `frac = pos - lo`, value `src[lo] * (1 - frac) + -/// src[min(lo+1, n-1)] * frac`. -fn resample_linear(src: &[f32], m: usize) -> Vec { - let n = src.len(); - if n == 0 { - return vec![0.0; m]; - } - if n == 1 { - return vec![src[0]; m]; - } - if m == 0 { - return Vec::new(); - } - if m == 1 { - // Degenerate target: just take the first sample (avoids /0 below). - return vec![src[0]]; - } - let mut out = Vec::with_capacity(m); - let denom = (m - 1) as f32; - let span = (n - 1) as f32; - for j in 0..m { - let pos = j as f32 * span / denom; - let lo = pos.floor() as usize; - let frac = pos - lo as f32; - let hi = (lo + 1).min(n - 1); - out.push(src[lo] * (1.0 - frac) + src[hi] * frac); - } - out -} - -/// L2 norm of a slice (`0.0` for an empty slice). -fn l2_norm(v: &[f32]) -> f32 { - v.iter().map(|x| x * x).sum::().sqrt() -} - -/// In-place L2 normalization; leaves `v` unchanged if its norm is `0` or -/// non-finite. -fn l2_normalize(v: &mut [f32]) { - let norm = l2_norm(v); - if norm.is_finite() && norm > 0.0 { - for x in v.iter_mut() { - *x /= norm; - } - } -} - -/// Build the deterministic embedding for a [`CsiWindow`]. -/// -/// The returned vector has length [`WINDOW_EMBEDDING_DIM`]; see that constant's -/// docs for the exact bin layout. The result is L2-normalized (or all-zero for -/// an empty window — i.e. `subcarrier_count == 0` and `frame_count == 0`). -pub fn window_embedding(w: &CsiWindow) -> Vec { - let mut out = Vec::with_capacity(WINDOW_EMBEDDING_DIM); - out.extend(resample_linear(&w.mean_amplitude, SUBCARRIER_BINS)); - out.extend(resample_linear(&w.phase_variance, SUBCARRIER_BINS)); - out.push(w.motion_energy); - out.push(w.presence_score); - out.push(w.quality_score); - out.push((w.frame_count as f32).ln_1p()); - debug_assert_eq!(out.len(), WINDOW_EMBEDDING_DIM); - l2_normalize(&mut out); - out -} - -/// Fixed index of a [`CsiEventKind`] in the one-hot block of an event -/// embedding — the variant declaration order in `rvcsi_core`. -fn kind_index(k: CsiEventKind) -> usize { - match k { - CsiEventKind::PresenceStarted => 0, - CsiEventKind::PresenceEnded => 1, - CsiEventKind::MotionDetected => 2, - CsiEventKind::MotionSettled => 3, - CsiEventKind::BaselineChanged => 4, - CsiEventKind::SignalQualityDropped => 5, - CsiEventKind::DeviceDisconnected => 6, - CsiEventKind::BreathingCandidate => 7, - CsiEventKind::AnomalyDetected => 8, - CsiEventKind::CalibrationRequired => 9, - } -} - -/// Build the deterministic embedding for a [`CsiEvent`]. -/// -/// The returned vector has length [`EVENT_EMBEDDING_DIM`]; see that constant's -/// docs for the exact layout. Not normalized. -pub fn event_embedding(e: &CsiEvent) -> Vec { - let mut out = vec![0.0_f32; EVENT_EMBEDDING_DIM]; - out[kind_index(e.kind)] = 1.0; - out[10] = e.confidence; - out[11] = (e.evidence_window_ids.len() as f32).ln_1p(); - out -} - -/// Cosine similarity of two equal-length vectors. -/// -/// Returns `0.0` if the lengths differ or either vector is all-zero (or has a -/// non-finite norm); otherwise `dot(a, b) / (||a|| * ||b||)` clamped to -/// `[-1.0, 1.0]`. -pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { - if a.len() != b.len() || a.is_empty() { - return 0.0; - } - let na = l2_norm(a); - let nb = l2_norm(b); - if !(na.is_finite() && nb.is_finite()) || na == 0.0 || nb == 0.0 { - return 0.0; - } - let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); - (dot / (na * nb)).clamp(-1.0, 1.0) -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{EventId, SessionId, SourceId, WindowId}; - - fn window() -> CsiWindow { - CsiWindow { - window_id: WindowId(7), - session_id: SessionId(1), - source_id: SourceId::from("emb-test"), - start_ns: 1_000, - end_ns: 2_000, - frame_count: 12, - mean_amplitude: vec![1.0, 2.0, 3.0, 4.0, 5.0], - phase_variance: vec![0.1, 0.2, 0.1, 0.3, 0.2], - motion_energy: 0.42, - presence_score: 0.8, - quality_score: 0.9, - } - } - - fn event(kind: CsiEventKind) -> CsiEvent { - CsiEvent::new( - EventId(3), - kind, - SessionId(1), - SourceId::from("emb-test"), - 5_000, - 0.75, - vec![WindowId(1), WindowId(2)], - ) - } - - #[test] - fn resample_edge_cases() { - assert_eq!(resample_linear(&[], 4), vec![0.0; 4]); - assert_eq!(resample_linear(&[2.5], 3), vec![2.5, 2.5, 2.5]); - // identity-ish: 3 -> 3 keeps endpoints - let r = resample_linear(&[0.0, 1.0, 2.0], 3); - assert!((r[0] - 0.0).abs() < 1e-6); - assert!((r[1] - 1.0).abs() < 1e-6); - assert!((r[2] - 2.0).abs() < 1e-6); - // upsample 2 -> 5 is a straight line - let r = resample_linear(&[0.0, 4.0], 5); - assert!((r[2] - 2.0).abs() < 1e-6); - } - - #[test] - fn window_embedding_is_deterministic_and_unit_length() { - let w = window(); - let a = window_embedding(&w); - let b = window_embedding(&w); - assert_eq!(a, b); - assert_eq!(a.len(), WINDOW_EMBEDDING_DIM); - let norm = l2_norm(&a); - assert!((norm - 1.0).abs() < 1e-5, "norm was {norm}"); - } - - #[test] - fn empty_window_embeds_to_zero() { - let mut w = window(); - w.mean_amplitude.clear(); - w.phase_variance.clear(); - w.motion_energy = 0.0; - w.presence_score = 0.0; - w.quality_score = 0.0; - w.frame_count = 0; - let e = window_embedding(&w); - assert_eq!(e.len(), WINDOW_EMBEDDING_DIM); - assert!(e.iter().all(|x| *x == 0.0)); - } - - #[test] - fn window_embedding_length_independent_of_subcarrier_count() { - let mut a = window(); - a.mean_amplitude = vec![1.0; 56]; - a.phase_variance = vec![0.1; 56]; - let mut b = window(); - b.mean_amplitude = vec![1.0; 234]; - b.phase_variance = vec![0.1; 234]; - assert_eq!(window_embedding(&a).len(), window_embedding(&b).len()); - } - - #[test] - fn event_embedding_layout() { - let e = event(CsiEventKind::MotionDetected); - let v = event_embedding(&e); - assert_eq!(v.len(), EVENT_EMBEDDING_DIM); - assert_eq!(v[kind_index(CsiEventKind::MotionDetected)], 1.0); - // exactly one hot in the first 10 - assert_eq!(v[..10].iter().filter(|x| **x == 1.0).count(), 1); - assert!((v[10] - 0.75).abs() < 1e-6); - assert!((v[11] - (2.0_f32).ln_1p()).abs() < 1e-6); - - // a different kind lights a different bin - let v2 = event_embedding(&event(CsiEventKind::AnomalyDetected)); - assert_eq!(v2[kind_index(CsiEventKind::AnomalyDetected)], 1.0); - assert_ne!(v, v2); - } - - #[test] - fn cosine_basic_identities() { - let v = window_embedding(&window()); - assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-5); - let neg: Vec = v.iter().map(|x| -x).collect(); - assert!((cosine_similarity(&v, &neg) + 1.0).abs() < 1e-5); - // mismatched lengths -> 0 - assert_eq!(cosine_similarity(&v, &v[..3]), 0.0); - // all-zero -> 0 - assert_eq!(cosine_similarity(&[0.0; 4], &[1.0; 4]), 0.0); - assert_eq!(cosine_similarity(&[], &[]), 0.0); - } -} diff --git a/v2/crates/rvcsi-ruvector/src/jsonl.rs b/v2/crates/rvcsi-ruvector/src/jsonl.rs deleted file mode 100644 index 0bb12e079e..0000000000 --- a/v2/crates/rvcsi-ruvector/src/jsonl.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! [`JsonlRfMemory`] — a file-backed [`RfMemoryStore`]. -//! -//! The store is a [JSONL] file: each line is one JSON object that is *either* a -//! stored record: -//! -//! ```json -//! {"record":{"id":3,"kind":"Window","source_id":"esp32","timestamp_ns":1700,"embedding":[0.1,0.2]}} -//! ``` -//! -//! or a baseline write: -//! -//! ```json -//! {"baseline":{"room":"livingroom","version":"v3","embedding":[0.1,0.2]}} -//! ``` -//! -//! Opening replays every line into an in-memory index identical to -//! [`crate::InMemoryRfMemory`], so queries are all in-memory; `store_*` / -//! `set_baseline` append a line (and `flush`) so a crash loses at most the -//! line currently being written. The **last** baseline line for a room wins. -//! -//! [JSONL]: https://jsonlines.org/ - -use std::fs::{File, OpenOptions}; -use std::io::{BufRead, BufReader, BufWriter, Write}; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use rvcsi_core::{CsiEvent, CsiWindow, RvcsiError, SourceId}; - -use crate::embedding::{event_embedding, window_embedding}; -use crate::memory::{IndexRecord, RfIndex}; -use crate::store::{DriftReport, EmbeddingId, RecordKind, RfMemoryStore, SimilarHit}; - -/// On-disk shape of a stored record line. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RecordLine { - id: u64, - kind: RecordKind, - source_id: SourceId, - timestamp_ns: u64, - embedding: Vec, -} - -/// On-disk shape of a baseline line. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct BaselineLine { - room: String, - version: String, - embedding: Vec, -} - -/// One line in the JSONL store — exactly one field is present. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoreLine { - #[serde(skip_serializing_if = "Option::is_none", default)] - record: Option, - #[serde(skip_serializing_if = "Option::is_none", default)] - baseline: Option, -} - -impl StoreLine { - fn record(r: RecordLine) -> Self { - StoreLine { - record: Some(r), - baseline: None, - } - } - fn baseline(b: BaselineLine) -> Self { - StoreLine { - record: None, - baseline: Some(b), - } - } -} - -/// A file-backed [`RfMemoryStore`]. See the module docs for the on-disk format. -#[derive(Debug)] -pub struct JsonlRfMemory { - path: PathBuf, - writer: BufWriter, - index: RfIndex, -} - -impl JsonlRfMemory { - /// Create a new, empty store at `path`, truncating any existing file. - pub fn create(path: impl AsRef) -> Result { - let path = path.as_ref().to_path_buf(); - let file = File::create(&path)?; - Ok(JsonlRfMemory { - path, - writer: BufWriter::new(file), - index: RfIndex::new(), - }) - } - - /// Open an existing store at `path`, replaying every line into the - /// in-memory index, then positioning for appends. The file must exist (use - /// [`JsonlRfMemory::create`] otherwise). - pub fn open(path: impl AsRef) -> Result { - let path = path.as_ref().to_path_buf(); - let mut index = RfIndex::new(); - { - let file = File::open(&path)?; - let reader = BufReader::new(file); - for (i, line) in reader.lines().enumerate() { - let line = line?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let parsed: StoreLine = serde_json::from_str(trimmed).map_err(|e| { - RvcsiError::parse(i + 1, format!("invalid RF-memory line {}: {e}", i + 1)) - })?; - match (parsed.record, parsed.baseline) { - (Some(r), None) => index.insert(IndexRecord { - id: EmbeddingId(r.id), - kind: r.kind, - source_id: r.source_id, - timestamp_ns: r.timestamp_ns, - embedding: r.embedding, - }), - (None, Some(b)) => index.set_baseline(&b.room, &b.version, b.embedding), - _ => { - return Err(RvcsiError::parse( - i + 1, - format!("RF-memory line {} must have exactly one of 'record'/'baseline'", i + 1), - )) - } - } - } - } - let file = OpenOptions::new().append(true).open(&path)?; - Ok(JsonlRfMemory { - path, - writer: BufWriter::new(file), - index, - }) - } - - /// Path the store is backed by. - pub fn path(&self) -> &Path { - &self.path - } - - /// Flush buffered writes to disk. - pub fn flush(&mut self) -> Result<(), RvcsiError> { - self.writer.flush()?; - Ok(()) - } - - fn append_line(&mut self, line: &StoreLine) -> Result<(), RvcsiError> { - serde_json::to_writer(&mut self.writer, line)?; - self.writer.write_all(b"\n")?; - self.writer.flush()?; - Ok(()) - } - - fn append_record( - &mut self, - kind: RecordKind, - source_id: SourceId, - timestamp_ns: u64, - embedding: Vec, - ) -> Result { - let id = self.index.mint_id(); - self.append_line(&StoreLine::record(RecordLine { - id: id.0, - kind, - source_id: source_id.clone(), - timestamp_ns, - embedding: embedding.clone(), - }))?; - self.index.insert(IndexRecord { - id, - kind, - source_id, - timestamp_ns, - embedding, - }); - Ok(id) - } -} - -impl RfMemoryStore for JsonlRfMemory { - fn store_window(&mut self, w: &CsiWindow) -> Result { - self.append_record( - RecordKind::Window, - w.source_id.clone(), - w.start_ns, - window_embedding(w), - ) - } - - fn store_event(&mut self, e: &CsiEvent) -> Result { - self.append_record( - RecordKind::Event, - e.source_id.clone(), - e.timestamp_ns, - event_embedding(e), - ) - } - - fn query_similar(&self, query: &[f32], k: usize) -> Result, RvcsiError> { - Ok(self.index.query_similar(query, k)) - } - - fn set_baseline( - &mut self, - room: &str, - version: &str, - embedding: Vec, - ) -> Result<(), RvcsiError> { - self.append_line(&StoreLine::baseline(BaselineLine { - room: room.to_string(), - version: version.to_string(), - embedding: embedding.clone(), - }))?; - self.index.set_baseline(room, version, embedding); - Ok(()) - } - - fn compute_drift( - &self, - room: &str, - current: &[f32], - threshold: f32, - ) -> Result, RvcsiError> { - Ok(self.index.compute_drift(room, current, threshold)) - } - - fn len(&self) -> usize { - self.index.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::embedding::window_embedding; - use rvcsi_core::{CsiEventKind, EventId, SessionId, WindowId}; - - fn window(id: u64, amp: f32) -> CsiWindow { - CsiWindow { - window_id: WindowId(id), - session_id: SessionId(1), - source_id: SourceId::from(format!("src-{id}").as_str()), - start_ns: 1_000 + id, - end_ns: 2_000 + id, - frame_count: 10, - mean_amplitude: vec![amp, amp + 1.0, amp + 2.0], - phase_variance: vec![0.1, 0.2, 0.1], - motion_energy: amp / 5.0, - presence_score: 0.6, - quality_score: 0.9, - } - } - - fn event() -> CsiEvent { - CsiEvent::new( - EventId(0), - CsiEventKind::MotionDetected, - SessionId(1), - SourceId::from("ev"), - 9_000, - 0.7, - vec![WindowId(1), WindowId(2)], - ) - } - - #[test] - fn persist_and_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("rf.jsonl"); - - let w1 = window(0, 1.0); - let w2 = window(1, 50.0); - let e = event(); - let base_emb = window_embedding(&window(7, 5.0)); - { - let mut mem = JsonlRfMemory::create(&path).unwrap(); - mem.store_window(&w1).unwrap(); - mem.store_window(&w2).unwrap(); - mem.store_event(&e).unwrap(); - mem.set_baseline("room1", "v1", base_emb.clone()).unwrap(); - mem.flush().unwrap(); - } - - let reopened = JsonlRfMemory::open(&path).unwrap(); - assert_eq!(reopened.len(), 3); - let hits = reopened.query_similar(&window_embedding(&w1), 3).unwrap(); - assert!((hits[0].score - 1.0).abs() < 1e-5); - let ev_hits = reopened.query_similar(&crate::embedding::event_embedding(&e), 1).unwrap(); - assert_eq!(ev_hits[0].kind, RecordKind::Event); - - // baseline persisted - let drift = reopened.compute_drift("room1", &base_emb, 0.1).unwrap().unwrap(); - assert_eq!(drift.baseline_version, "v1"); - assert!(!drift.exceeded); - assert!(drift.distance < 1e-5); - assert!(reopened.compute_drift("other", &base_emb, 0.1).unwrap().is_none()); - } - - #[test] - fn newer_baseline_wins_after_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("rf.jsonl"); - let v1_emb = window_embedding(&window(1, 1.0)); - let v2_emb = window_embedding(&window(2, 2.0)); - { - let mut mem = JsonlRfMemory::create(&path).unwrap(); - mem.set_baseline("r", "v1", v1_emb.clone()).unwrap(); - mem.flush().unwrap(); - } - { - let mut mem = JsonlRfMemory::open(&path).unwrap(); - mem.set_baseline("r", "v2", v2_emb.clone()).unwrap(); - mem.flush().unwrap(); - } - let reopened = JsonlRfMemory::open(&path).unwrap(); - let drift = reopened.compute_drift("r", &v2_emb, 0.5).unwrap().unwrap(); - assert_eq!(drift.baseline_version, "v2"); - assert!(drift.distance < 1e-5); - assert!(!drift.exceeded); - } - - #[test] - fn ids_stay_unique_across_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("rf.jsonl"); - let (id0, id1); - { - let mut mem = JsonlRfMemory::create(&path).unwrap(); - id0 = mem.store_window(&window(0, 1.0)).unwrap(); - id1 = mem.store_window(&window(1, 2.0)).unwrap(); - mem.flush().unwrap(); - } - assert_eq!(id0, EmbeddingId(0)); - assert_eq!(id1, EmbeddingId(1)); - let id2 = { - let mut mem = JsonlRfMemory::open(&path).unwrap(); - mem.store_window(&window(2, 3.0)).unwrap() - }; - assert_eq!(id2, EmbeddingId(2)); - assert_eq!(JsonlRfMemory::open(&path).unwrap().len(), 3); - } - - #[test] - fn open_missing_file_is_io_error() { - match JsonlRfMemory::open("/no/such/rf/store.jsonl") { - Err(RvcsiError::Io(_)) => {} - other => panic!("expected Io error, got {other:?}"), - } - } - - #[test] - fn garbage_line_is_parse_error_with_line_number() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("rf.jsonl"); - { - let mut mem = JsonlRfMemory::create(&path).unwrap(); - mem.store_window(&window(0, 1.0)).unwrap(); - mem.flush().unwrap(); - } - // append a garbage line manually - { - use std::io::Write as _; - let mut f = OpenOptions::new().append(true).open(&path).unwrap(); - f.write_all(b"{not valid}\n").unwrap(); - } - match JsonlRfMemory::open(&path) { - Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 2), - other => panic!("expected Parse at line 2, got {other:?}"), - } - } - - #[test] - fn determinism_across_rebuilds() { - let dir = tempfile::tempdir().unwrap(); - let build = |name: &str| { - let path = dir.path().join(name); - let mut mem = JsonlRfMemory::create(&path).unwrap(); - for i in 0..4 { - mem.store_window(&window(i, (i as f32 + 1.0) * 2.0)).unwrap(); - } - mem.set_baseline("r", "v1", window_embedding(&window(0, 1.0))).unwrap(); - mem.flush().unwrap(); - JsonlRfMemory::open(&path).unwrap() - }; - let a = build("a.jsonl"); - let b = build("b.jsonl"); - assert_eq!(a.len(), b.len()); - let q = window_embedding(&window(1, 4.0)); - assert_eq!(a.query_similar(&q, 4).unwrap(), b.query_similar(&q, 4).unwrap()); - } -} diff --git a/v2/crates/rvcsi-ruvector/src/lib.rs b/v2/crates/rvcsi-ruvector/src/lib.rs deleted file mode 100644 index 7a8ff3f1b9..0000000000 --- a/v2/crates/rvcsi-ruvector/src/lib.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! # rvCSI RuVector bridge -//! -//! Exports temporal RF embeddings + event metadata as a queryable RF-memory -//! store (ADR-095 FR8, D8). -//! -//! This crate is a **standin** for the production RuVector vector-database -//! binding (which gets wired in later). It provides: -//! -//! * deterministic, dependency-free embedding functions — -//! [`window_embedding`] / [`event_embedding`] / [`cosine_similarity`]; -//! * the [`RfMemoryStore`] trait plus value objects ([`EmbeddingId`], -//! [`RecordKind`], [`SimilarHit`], [`DriftReport`]); -//! * two implementations: the in-process [`InMemoryRfMemory`] and the -//! file-backed [`JsonlRfMemory`] (JSONL append log, identical query semantics). -//! -//! Everything here is pure and deterministic given the same sequence of -//! operations — no clocks, randomness, or order-dependent reductions — so -//! captures replayed twice yield byte-identical stores and query results. -//! -//! ``` -//! use rvcsi_ruvector::{InMemoryRfMemory, RfMemoryStore, window_embedding}; -//! use rvcsi_core::{CsiWindow, SessionId, SourceId, WindowId}; -//! -//! let w = CsiWindow { -//! window_id: WindowId(0), -//! session_id: SessionId(1), -//! source_id: SourceId::from("esp32"), -//! start_ns: 1_000, -//! end_ns: 2_000, -//! frame_count: 10, -//! mean_amplitude: vec![1.0, 2.0, 3.0], -//! phase_variance: vec![0.1, 0.2, 0.1], -//! motion_energy: 0.3, -//! presence_score: 0.7, -//! quality_score: 0.9, -//! }; -//! let mut mem = InMemoryRfMemory::new(); -//! let id = mem.store_window(&w).unwrap(); -//! let hits = mem.query_similar(&window_embedding(&w), 1).unwrap(); -//! assert_eq!(hits[0].id, id); -//! assert!((hits[0].score - 1.0).abs() < 1e-5); -//! ``` - -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -mod embedding; -mod jsonl; -mod memory; -mod store; - -pub use embedding::{ - cosine_similarity, event_embedding, window_embedding, EVENT_EMBEDDING_DIM, - WINDOW_EMBEDDING_DIM, -}; -pub use jsonl::JsonlRfMemory; -pub use memory::InMemoryRfMemory; -pub use store::{DriftReport, EmbeddingId, RecordKind, RfMemoryStore, SimilarHit}; diff --git a/v2/crates/rvcsi-ruvector/src/memory.rs b/v2/crates/rvcsi-ruvector/src/memory.rs deleted file mode 100644 index 545945ccc0..0000000000 --- a/v2/crates/rvcsi-ruvector/src/memory.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! [`InMemoryRfMemory`] — an in-process [`RfMemoryStore`] backed by plain -//! `Vec`s. Also defines the shared [`RfIndex`] used by the file-backed store. - -use std::collections::HashMap; - -use rvcsi_core::{CsiEvent, CsiWindow, RvcsiError, SourceId}; - -use crate::embedding::{cosine_similarity, event_embedding, window_embedding}; -use crate::store::{DriftReport, EmbeddingId, RecordKind, RfMemoryStore, SimilarHit}; - -/// One stored record inside an [`RfIndex`]. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct IndexRecord { - pub(crate) id: EmbeddingId, - pub(crate) kind: RecordKind, - pub(crate) source_id: SourceId, - pub(crate) timestamp_ns: u64, - pub(crate) embedding: Vec, -} - -/// The in-memory index that both [`InMemoryRfMemory`] and the file-backed store -/// build queries on top of. Holds records (with monotonic ids) and the latest -/// baseline per room. -#[derive(Debug, Default, Clone)] -pub(crate) struct RfIndex { - records: Vec, - /// room -> (version, embedding); the most recently set wins. - baselines: HashMap)>, - next_id: u64, -} - -impl RfIndex { - pub(crate) fn new() -> Self { - RfIndex::default() - } - - pub(crate) fn mint_id(&mut self) -> EmbeddingId { - let id = EmbeddingId(self.next_id); - self.next_id += 1; - id - } - - /// Insert an already-built record. The record's `id` must come from - /// [`RfIndex::mint_id`] (or be a replay of a previously-minted id, in which - /// case `next_id` is advanced past it so future mints stay unique). - pub(crate) fn insert(&mut self, rec: IndexRecord) { - if rec.id.0 >= self.next_id { - self.next_id = rec.id.0 + 1; - } - self.records.push(rec); - } - - pub(crate) fn set_baseline(&mut self, room: &str, version: &str, embedding: Vec) { - self.baselines - .insert(room.to_string(), (version.to_string(), embedding)); - } - - pub(crate) fn len(&self) -> usize { - self.records.len() - } - - pub(crate) fn query_similar(&self, query: &[f32], k: usize) -> Vec { - if k == 0 { - return Vec::new(); - } - let mut scored: Vec<(usize, f32)> = self - .records - .iter() - .enumerate() - .map(|(i, r)| (i, cosine_similarity(query, &r.embedding))) - .collect(); - // Deterministic sort: by score desc, ties broken by record id asc. - scored.sort_by(|(ia, sa), (ib, sb)| { - sb.partial_cmp(sa) - .unwrap_or(std::cmp::Ordering::Equal) - .then(self.records[*ia].id.cmp(&self.records[*ib].id)) - }); - scored - .into_iter() - .take(k) - .map(|(i, score)| { - let r = &self.records[i]; - SimilarHit { - id: r.id, - score, - kind: r.kind, - source_id: r.source_id.clone(), - timestamp_ns: r.timestamp_ns, - } - }) - .collect() - } - - pub(crate) fn compute_drift( - &self, - room: &str, - current: &[f32], - threshold: f32, - ) -> Option { - let (version, baseline) = self.baselines.get(room)?; - let distance = 1.0 - cosine_similarity(baseline, current); - Some(DriftReport { - room: room.to_string(), - baseline_version: version.clone(), - distance, - threshold, - exceeded: distance > threshold, - }) - } -} - -/// An entirely in-process [`RfMemoryStore`] — no persistence. -/// -/// Useful for tests, ephemeral runs, and as the query engine behind the -/// file-backed [`crate::JsonlRfMemory`]. -#[derive(Debug, Default, Clone)] -pub struct InMemoryRfMemory { - index: RfIndex, -} - -impl InMemoryRfMemory { - /// A fresh, empty store. - pub fn new() -> Self { - InMemoryRfMemory { - index: RfIndex::new(), - } - } -} - -impl RfMemoryStore for InMemoryRfMemory { - fn store_window(&mut self, w: &CsiWindow) -> Result { - let id = self.index.mint_id(); - self.index.insert(IndexRecord { - id, - kind: RecordKind::Window, - source_id: w.source_id.clone(), - timestamp_ns: w.start_ns, - embedding: window_embedding(w), - }); - Ok(id) - } - - fn store_event(&mut self, e: &CsiEvent) -> Result { - let id = self.index.mint_id(); - self.index.insert(IndexRecord { - id, - kind: RecordKind::Event, - source_id: e.source_id.clone(), - timestamp_ns: e.timestamp_ns, - embedding: event_embedding(e), - }); - Ok(id) - } - - fn query_similar(&self, query: &[f32], k: usize) -> Result, RvcsiError> { - Ok(self.index.query_similar(query, k)) - } - - fn set_baseline( - &mut self, - room: &str, - version: &str, - embedding: Vec, - ) -> Result<(), RvcsiError> { - self.index.set_baseline(room, version, embedding); - Ok(()) - } - - fn compute_drift( - &self, - room: &str, - current: &[f32], - threshold: f32, - ) -> Result, RvcsiError> { - Ok(self.index.compute_drift(room, current, threshold)) - } - - fn len(&self) -> usize { - self.index.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rvcsi_core::{CsiEventKind, EventId, SessionId, SourceId, WindowId}; - - fn window(id: u64, amp: f32) -> CsiWindow { - CsiWindow { - window_id: WindowId(id), - session_id: SessionId(1), - source_id: SourceId::from(format!("src-{id}").as_str()), - start_ns: 1_000 + id, - end_ns: 2_000 + id, - frame_count: 10 + id as u32, - mean_amplitude: vec![amp, amp + 1.0, amp + 2.0, amp + 3.0], - phase_variance: vec![0.1, 0.2, 0.1, 0.05], - motion_energy: amp / 10.0, - presence_score: 0.5, - quality_score: 0.9, - } - } - - fn event() -> CsiEvent { - CsiEvent::new( - EventId(0), - CsiEventKind::PresenceStarted, - SessionId(1), - SourceId::from("ev"), - 9_000, - 0.8, - vec![WindowId(1)], - ) - } - - #[test] - fn store_and_query_windows() { - let mut mem = InMemoryRfMemory::new(); - let w1 = window(0, 1.0); - let w2 = window(1, 50.0); - let w3 = window(2, 100.0); - let id1 = mem.store_window(&w1).unwrap(); - mem.store_window(&w2).unwrap(); - mem.store_window(&w3).unwrap(); - assert_eq!(mem.len(), 3); - assert!(!mem.is_empty()); - - let q = window_embedding(&w1); - let hits = mem.query_similar(&q, 3).unwrap(); - assert_eq!(hits.len(), 3); - assert_eq!(hits[0].id, id1); - assert_eq!(hits[0].kind, RecordKind::Window); - assert!((hits[0].score - 1.0).abs() < 1e-5); - // descending - assert!(hits[0].score >= hits[1].score); - assert!(hits[1].score >= hits[2].score); - } - - #[test] - fn store_and_query_event() { - let mut mem = InMemoryRfMemory::new(); - mem.store_window(&window(0, 1.0)).unwrap(); - let e = event(); - let eid = mem.store_event(&e).unwrap(); - let hits = mem.query_similar(&event_embedding(&e), 1).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, eid); - assert_eq!(hits[0].kind, RecordKind::Event); - assert!((hits[0].score - 1.0).abs() < 1e-5); - assert_eq!(hits[0].timestamp_ns, 9_000); - } - - #[test] - fn baseline_drift() { - let mut mem = InMemoryRfMemory::new(); - let base = window(0, 10.0); - let base_emb = window_embedding(&base); - mem.set_baseline("room1", "v1", base_emb.clone()).unwrap(); - - // near-identical: tiny perturbation - let mut near = base.clone(); - near.motion_energy += 0.001; - let near_emb = window_embedding(&near); - let r = mem.compute_drift("room1", &near_emb, 0.2).unwrap().unwrap(); - assert_eq!(r.room, "room1"); - assert_eq!(r.baseline_version, "v1"); - assert!(!r.exceeded, "distance was {}", r.distance); - - // very different - let far_emb = window_embedding(&window(9, 1_000.0)); - let r2 = mem.compute_drift("room1", &far_emb, 0.001).unwrap().unwrap(); - assert!(r2.exceeded, "distance was {}", r2.distance); - - // unknown room - assert!(mem.compute_drift("nope", &near_emb, 0.2).unwrap().is_none()); - } - - #[test] - fn replaying_baseline_keeps_latest() { - let mut mem = InMemoryRfMemory::new(); - mem.set_baseline("r", "v1", window_embedding(&window(0, 1.0))) - .unwrap(); - let v2_emb = window_embedding(&window(1, 2.0)); - mem.set_baseline("r", "v2", v2_emb.clone()).unwrap(); - let r = mem.compute_drift("r", &v2_emb, 0.5).unwrap().unwrap(); - assert_eq!(r.baseline_version, "v2"); - assert!(!r.exceeded); - assert!(r.distance < 1e-5); - } - - #[test] - fn deterministic_across_rebuilds() { - let build = || { - let mut m = InMemoryRfMemory::new(); - for i in 0..5 { - m.store_window(&window(i, (i as f32 + 1.0) * 3.0)).unwrap(); - } - m - }; - let a = build(); - let b = build(); - assert_eq!(a.len(), b.len()); - let q = window_embedding(&window(2, 9.0)); - assert_eq!(a.query_similar(&q, 5).unwrap(), b.query_similar(&q, 5).unwrap()); - } - - #[test] - fn k_zero_returns_empty() { - let mut m = InMemoryRfMemory::new(); - m.store_window(&window(0, 1.0)).unwrap(); - assert!(m.query_similar(&window_embedding(&window(0, 1.0)), 0).unwrap().is_empty()); - } -} diff --git a/v2/crates/rvcsi-ruvector/src/store.rs b/v2/crates/rvcsi-ruvector/src/store.rs deleted file mode 100644 index c762b04e1b..0000000000 --- a/v2/crates/rvcsi-ruvector/src/store.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! The [`RfMemoryStore`] trait and its value objects. -//! -//! An RF-memory store keeps embeddings of [`CsiWindow`](rvcsi_core::CsiWindow)s -//! and [`CsiEvent`](rvcsi_core::CsiEvent)s plus per-room baseline embeddings, -//! and answers similarity / drift queries over them. This is a standin for the -//! production RuVector binding (ADR-095 FR8, D8) — see the crate docs. - -use serde::{Deserialize, Serialize}; - -use rvcsi_core::{CsiEvent, CsiWindow, RvcsiError, SourceId}; - -/// Identifier minted for each stored embedding (monotonic within a store). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct EmbeddingId(pub u64); - -impl EmbeddingId { - /// The raw integer value. - #[inline] - pub const fn value(self) -> u64 { - self.0 - } -} - -impl From for EmbeddingId { - #[inline] - fn from(v: u64) -> Self { - EmbeddingId(v) - } -} - -/// Which kind of record an embedding came from. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RecordKind { - /// Embedding of a [`CsiWindow`](rvcsi_core::CsiWindow). - Window, - /// Embedding of a [`CsiEvent`](rvcsi_core::CsiEvent). - Event, -} - -/// One hit returned by [`RfMemoryStore::query_similar`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SimilarHit { - /// Id of the matched stored embedding. - pub id: EmbeddingId, - /// Cosine similarity to the query in `[-1.0, 1.0]`. - pub score: f32, - /// Whether the matched record was a window or an event. - pub kind: RecordKind, - /// Source the matched record came from. - pub source_id: SourceId, - /// Timestamp of the matched record (ns). - pub timestamp_ns: u64, -} - -/// Result of a baseline-drift comparison ([`RfMemoryStore::compute_drift`]). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DriftReport { - /// Room the baseline belongs to. - pub room: String, - /// Baseline version that was compared against. - pub baseline_version: String, - /// Cosine *distance* `1 - cosine_similarity(baseline, current)` in `[0.0, 2.0]`. - pub distance: f32, - /// Threshold the distance was compared against. - pub threshold: f32, - /// Whether `distance > threshold`. - pub exceeded: bool, -} - -/// A queryable RF-memory store: append window/event embeddings, search by -/// cosine similarity, and track per-room baseline drift. -/// -/// Implementations are deterministic given the same sequence of operations. -pub trait RfMemoryStore { - /// Store the embedding of `w`, returning its newly-minted id. - fn store_window(&mut self, w: &CsiWindow) -> Result; - - /// Store the embedding of `e`, returning its newly-minted id. - fn store_event(&mut self, e: &CsiEvent) -> Result; - - /// Return up to `k` stored records most similar to `query`, by descending - /// cosine similarity. Records whose embedding length differs from `query` - /// (e.g. events vs. window queries) score `0.0` and so sort last. - fn query_similar(&self, query: &[f32], k: usize) -> Result, RvcsiError>; - - /// Set (or replace) the baseline embedding for `room` at `version`. - fn set_baseline( - &mut self, - room: &str, - version: &str, - embedding: Vec, - ) -> Result<(), RvcsiError>; - - /// Compare `current` against `room`'s baseline. Returns `None` if there is - /// no baseline for `room`, otherwise a [`DriftReport`] with - /// `distance = 1 - cosine_similarity(baseline, current)` and - /// `exceeded = distance > threshold`. - fn compute_drift( - &self, - room: &str, - current: &[f32], - threshold: f32, - ) -> Result, RvcsiError>; - - /// Number of stored records (windows + events; baselines are not counted). - fn len(&self) -> usize; - - /// Whether [`RfMemoryStore::len`] is zero. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn embedding_id_roundtrips() { - let id = EmbeddingId::from(42); - assert_eq!(id.value(), 42); - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), id); - } - - #[test] - fn value_objects_serde() { - let hit = SimilarHit { - id: EmbeddingId(1), - score: 0.9, - kind: RecordKind::Window, - source_id: SourceId::from("s"), - timestamp_ns: 5, - }; - let json = serde_json::to_string(&hit).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), hit); - - let d = DriftReport { - room: "lab".into(), - baseline_version: "v1".into(), - distance: 0.1, - threshold: 0.2, - exceeded: false, - }; - let json = serde_json::to_string(&d).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), d); - } -}