Merge pull request #544 from ruvnet/chore/rvcsi-via-submodule

chore(rvcsi): drop inline v2/crates/rvcsi-* — consume vendor/rvcsi + crates.io
This commit is contained in:
rUv 2026-05-12 23:01:10 -04:00 committed by GitHub
commit 00304f9dc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 20 additions and 11842 deletions

View File

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

View File

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

View File

@ -522,7 +522,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](docs/prd/rvcsi-platform-prd.md) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (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 |

View File

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

View File

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

View File

@ -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<String>,
/// 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<String>) -> 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<String>) -> 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);
}
}

View File

@ -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<Path>) -> Result<(CaptureHeader, Vec<CsiFrame>)> {
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<CsiFrame> {
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);
}
}

View File

@ -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<File>,
frames_written: u64,
}
impl FileRecorder {
/// Create `path` (truncating any existing file) and write `header` as the
/// first line.
pub fn create(path: impl AsRef<Path>, header: &CaptureHeader) -> Result<Self> {
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<W: Write, T: serde::Serialize>(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));
}
}

View File

@ -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<File>,
/// 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<String>,
}
impl FileReplayAdapter {
/// Open `path` for replay at real-time speed (`replay_speed == 1.0`).
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
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<Path>, replay_speed: f32) -> Result<Self> {
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<Option<CsiFrame>, 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, .. }));
}
}

View File

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

View File

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

View File

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

View File

@ -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 <stddef.h>
#include <stdint.h>
#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 */

View File

@ -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<NexmonChip> {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"bcm43455c0" | "43455c0" | "43455" | "bcm43455" | "cyw43455" => Some(NexmonChip::Bcm43455c0),
"bcm43436b0" | "43436b0" | "43436" | "bcm43436" => Some(NexmonChip::Bcm43436b0),
"bcm4366c0" | "4366c0" | "4366" | "bcm4366" => Some(NexmonChip::Bcm4366c0),
"bcm4375b1" | "4375b1" | "4375" | "bcm4375" => Some(NexmonChip::Bcm4375b1),
"bcm4358" | "4358" => Some(NexmonChip::Bcm4358),
"bcm4339" | "4339" => Some(NexmonChip::Bcm4339),
_ => None,
}
}
}
/// 5 GHz UNII channels (a representative set; nexmon picks a control channel via `makecsiparams`).
const FIVE_GHZ_CHANNELS: &[u16] = &[
36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149,
153, 157, 161, 165,
];
fn channels_for(chip: NexmonChip) -> Vec<u16> {
let mut v: Vec<u16> = (1..=13).collect();
if chip.dual_band() {
v.extend_from_slice(FIVE_GHZ_CHANNELS);
}
v
}
fn bandwidths_for(chip: NexmonChip) -> Vec<u16> {
match chip {
NexmonChip::Bcm43455c0 | NexmonChip::Bcm4366c0 | NexmonChip::Bcm4358 | NexmonChip::Bcm4339 => vec![20, 40, 80],
NexmonChip::Bcm4375b1 => vec![20, 40, 80, 160],
NexmonChip::Bcm43436b0 => vec![20, 40],
NexmonChip::Unknown { .. } => vec![20, 40, 80],
}
}
/// Subcarrier (FFT) count per supported bandwidth: 20→64, 40→128, 80→256, 160→512.
fn subcarrier_counts_for(chip: NexmonChip) -> Vec<u16> {
bandwidths_for(chip)
.iter()
.map(|bw| (bw / 20) * 64)
.collect()
}
/// Build the [`rvcsi_core::AdapterProfile`] for a Nexmon chip — the channels /
/// bandwidths / expected subcarrier counts `validate_frame` will bound CSI
/// frames against, plus the live-capability flags (Nexmon supports monitor mode
/// and injection on these chips).
pub fn nexmon_adapter_profile(chip: NexmonChip) -> AdapterProfile {
AdapterProfile {
adapter_kind: AdapterKind::Nexmon,
chip: Some(chip.slug()),
firmware_version: None,
driver_version: None,
supported_channels: channels_for(chip),
supported_bandwidths_mhz: bandwidths_for(chip),
expected_subcarrier_counts: subcarrier_counts_for(chip),
supports_live_capture: true,
supports_injection: true,
supports_monitor_mode: true,
}
}
/// Raspberry Pi models with on-board WiFi that nexmon_csi can extract CSI from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RaspberryPiModel {
/// Raspberry Pi 3 Model B+ — CYW43455 / BCM43455c0.
Pi3BPlus,
/// Raspberry Pi 4 Model B — CYW43455 / BCM43455c0.
Pi4,
/// Raspberry Pi 400 — CYW43455 / BCM43455c0.
Pi400,
/// **Raspberry Pi 5** — CYW43455 / BCM43455c0 (same wireless as the Pi 4).
Pi5,
/// Raspberry Pi Zero W — CYW43438? No — the Zero W uses the BCM43438 (2.4 GHz
/// only), which nexmon_csi does **not** support; included here only so callers
/// can detect and reject it. Use a Zero 2 W instead.
PiZeroW,
/// Raspberry Pi Zero 2 W — BCM43436b0 (2.4 GHz only).
PiZero2W,
}
impl RaspberryPiModel {
/// The Broadcom/Cypress WiFi chip on this board.
pub fn nexmon_chip(self) -> NexmonChip {
match self {
RaspberryPiModel::Pi3BPlus
| RaspberryPiModel::Pi4
| RaspberryPiModel::Pi400
| RaspberryPiModel::Pi5 => NexmonChip::Bcm43455c0,
RaspberryPiModel::PiZero2W => NexmonChip::Bcm43436b0,
RaspberryPiModel::PiZeroW => NexmonChip::Unknown { chip_ver: 0x4343 }, // BCM43438 — not CSI-capable
}
}
/// Whether nexmon_csi can extract CSI from this board's WiFi.
pub fn csi_supported(self) -> bool {
!matches!(self, RaspberryPiModel::PiZeroW)
}
/// Stable slug (`"pi5"`, `"pi4"`, `"pi3b+"`, `"pi400"`, `"pizero2w"`, `"pizerow"`).
pub fn slug(self) -> &'static str {
match self {
RaspberryPiModel::Pi3BPlus => "pi3b+",
RaspberryPiModel::Pi4 => "pi4",
RaspberryPiModel::Pi400 => "pi400",
RaspberryPiModel::Pi5 => "pi5",
RaspberryPiModel::PiZeroW => "pizerow",
RaspberryPiModel::PiZero2W => "pizero2w",
}
}
/// Parse a model slug (accepts `pi5`, `pi 5`, `rpi5`, `raspberrypi5`, `pi3b+`/`pi3bplus`, ...).
pub fn from_slug(s: &str) -> Option<RaspberryPiModel> {
let s: String = s.trim().to_ascii_lowercase().chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect();
let s = s.strip_prefix("raspberrypi").or_else(|| s.strip_prefix("rpi")).unwrap_or(&s);
match s {
"pi5" | "5" => Some(RaspberryPiModel::Pi5),
"pi4" | "4" | "pi4b" => Some(RaspberryPiModel::Pi4),
"pi400" | "400" => Some(RaspberryPiModel::Pi400),
"pi3b+" | "pi3bplus" | "3b+" | "3bplus" => Some(RaspberryPiModel::Pi3BPlus),
"pizero2w" | "zero2w" | "pizero2" => Some(RaspberryPiModel::PiZero2W),
"pizerow" | "zerow" => Some(RaspberryPiModel::PiZeroW),
_ => None,
}
}
}
/// Build the [`rvcsi_core::AdapterProfile`] for a Raspberry Pi model (its
/// [`RaspberryPiModel::nexmon_chip`]'s profile, with the `chip` string tagged
/// with the model for legibility).
pub fn raspberry_pi_profile(model: RaspberryPiModel) -> AdapterProfile {
let mut p = nexmon_adapter_profile(model.nexmon_chip());
p.chip = Some(format!("{} ({})", model.nexmon_chip().slug(), model.slug()));
p
}
/// The full registry of Nexmon-supported chips, for `rvcsi nexmon-chips` and SDK callers.
pub fn known_chips() -> &'static [NexmonChip] {
&[
NexmonChip::Bcm43455c0,
NexmonChip::Bcm43436b0,
NexmonChip::Bcm4366c0,
NexmonChip::Bcm4375b1,
NexmonChip::Bcm4358,
NexmonChip::Bcm4339,
]
}
/// The full registry of Raspberry Pi models this crate knows about.
pub fn known_pi_models() -> &'static [RaspberryPiModel] {
&[
RaspberryPiModel::Pi5,
RaspberryPiModel::Pi4,
RaspberryPiModel::Pi400,
RaspberryPiModel::Pi3BPlus,
RaspberryPiModel::PiZero2W,
RaspberryPiModel::PiZeroW,
]
}
impl crate::ffi::NexmonCsiHeader {
/// Resolve this packet's chip from its `chip_ver` word (best-effort; the raw
/// `chip_ver` field is always preserved). For a Raspberry Pi 5 (or 4/400/3B+)
/// capture this returns [`NexmonChip::Bcm43455c0`].
pub fn chip(&self) -> NexmonChip {
NexmonChip::from_chip_ver(self.chip_ver)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pi5_uses_the_same_chip_as_pi4() {
assert_eq!(RaspberryPiModel::Pi5.nexmon_chip(), NexmonChip::Bcm43455c0);
assert_eq!(RaspberryPiModel::Pi4.nexmon_chip(), NexmonChip::Bcm43455c0);
assert!(RaspberryPiModel::Pi5.csi_supported());
let p = raspberry_pi_profile(RaspberryPiModel::Pi5);
assert_eq!(p.adapter_kind, AdapterKind::Nexmon);
assert!(p.chip.as_deref().unwrap().contains("pi5"));
assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]);
assert_eq!(p.expected_subcarrier_counts, vec![64, 128, 256]);
assert!(p.accepts_channel(36)); // 5 GHz
assert!(p.accepts_channel(6)); // 2.4 GHz
assert!(p.accepts_subcarrier_count(256)); // VHT80
assert!(!p.accepts_subcarrier_count(57));
assert!(p.supports_monitor_mode && p.supports_injection);
}
#[test]
fn chip_ver_resolution_best_effort() {
assert_eq!(NexmonChip::from_chip_ver(0x4345), NexmonChip::Bcm43455c0);
assert_eq!(NexmonChip::from_chip_ver(0x4339), NexmonChip::Bcm4339);
assert_eq!(NexmonChip::from_chip_ver(0x4366), NexmonChip::Bcm4366c0);
assert!(matches!(NexmonChip::from_chip_ver(0xABCD), NexmonChip::Unknown { chip_ver: 0xABCD }));
}
#[test]
fn chip_traits() {
assert!(NexmonChip::Bcm43455c0.uses_int16_iq());
assert!(!NexmonChip::Bcm4339.uses_int16_iq());
assert!(NexmonChip::Bcm43455c0.dual_band());
assert!(!NexmonChip::Bcm43436b0.dual_band());
assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).supported_bandwidths_mhz, vec![20, 40]);
assert_eq!(nexmon_adapter_profile(NexmonChip::Bcm43436b0).expected_subcarrier_counts, vec![64, 128]);
// unknown chip -> a permissive-ish 802.11ac default
let u = nexmon_adapter_profile(NexmonChip::Unknown { chip_ver: 0 });
assert_eq!(u.supported_bandwidths_mhz, vec![20, 40, 80]);
}
#[test]
fn slug_parsing() {
assert_eq!(NexmonChip::from_slug("CYW43455"), Some(NexmonChip::Bcm43455c0));
assert_eq!(NexmonChip::from_slug("bcm4366c0"), Some(NexmonChip::Bcm4366c0));
assert_eq!(NexmonChip::from_slug("nope"), None);
assert_eq!(RaspberryPiModel::from_slug("Pi 5"), Some(RaspberryPiModel::Pi5));
assert_eq!(RaspberryPiModel::from_slug("raspberry-pi-5"), Some(RaspberryPiModel::Pi5));
assert_eq!(RaspberryPiModel::from_slug("pi3bplus"), Some(RaspberryPiModel::Pi3BPlus));
assert_eq!(RaspberryPiModel::from_slug("pi42"), None);
assert_eq!(NexmonChip::Bcm43455c0.slug(), "bcm43455c0");
assert_eq!(RaspberryPiModel::Pi5.slug(), "pi5");
}
#[test]
fn registries_nonempty_and_pi5_present() {
assert!(known_chips().contains(&NexmonChip::Bcm43455c0));
assert!(known_pi_models().contains(&RaspberryPiModel::Pi5));
}
}

View File

@ -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<i16>,
/// Noise floor in dBm, if present.
pub noise_floor_dbm: Option<i16>,
/// Source timestamp, ns.
pub timestamp_ns: u64,
/// In-phase samples.
pub i_values: Vec<f32>,
/// Quadrature samples.
pub q_values: Vec<f32>,
}
/// 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<usize> {
// 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<Vec<u8>, 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<RvcsiNxUdpHeader> for NexmonCsiHeader {
fn from(h: RvcsiNxUdpHeader) -> Self {
NexmonCsiHeader {
rssi_dbm: h.rssi_dbm,
fctl: h.fctl,
src_mac: h.src_mac,
seq_cnt: h.seq_cnt,
core: h.core,
spatial_stream: h.spatial_stream,
chanspec: h.chanspec,
chip_ver: h.chip_ver,
channel: h.channel,
bandwidth_mhz: h.bandwidth_mhz,
is_5ghz: h.is_5ghz != 0,
subcarrier_count: h.subcarrier_count,
}
}
}
impl NexmonCsiHeader {
fn to_c(&self) -> RvcsiNxUdpHeader {
RvcsiNxUdpHeader {
rssi_dbm: self.rssi_dbm,
fctl: self.fctl,
src_mac: self.src_mac,
seq_cnt: self.seq_cnt,
core: self.core,
spatial_stream: self.spatial_stream,
chanspec: self.chanspec,
chip_ver: self.chip_ver,
channel: self.channel,
bandwidth_mhz: self.bandwidth_mhz,
is_5ghz: self.is_5ghz as u8,
subcarrier_count: self.subcarrier_count,
}
}
}
fn check(rc: i32) -> Result<(), NexmonFfiError> {
if rc == 0 {
Ok(())
} else {
Err(NexmonFfiError::Shim {
code: rc,
message: strerror(rc),
})
}
}
/// Parse just the 18-byte nexmon_csi UDP header of `payload`.
pub fn parse_nexmon_udp_header(payload: &[u8]) -> Result<NexmonCsiHeader, NexmonFfiError> {
let mut hdr = RvcsiNxUdpHeader::default();
// SAFETY: `payload` valid for `payload.len()`; `hdr` is an owned struct the
// C side only writes on RVCSI_NX_OK (and zero-initialises first).
let rc = unsafe { rvcsi_nx_csi_udp_header(payload.as_ptr(), payload.len(), &mut hdr) };
check(rc)?;
Ok(hdr.into())
}
/// Fully decode a nexmon_csi UDP payload (the 18-byte header + the CSI body).
/// Returns the parsed header and a [`NexmonRecord`] whose `timestamp_ns` is `0`
/// (the caller stamps it from the pcap packet time). `csi_format` is currently
/// only [`NEXMON_CSI_FMT_INT16_IQ`].
pub fn decode_nexmon_udp(
payload: &[u8],
csi_format: i32,
) -> Result<(NexmonCsiHeader, NexmonRecord), NexmonFfiError> {
// First parse the header so we know `nsub` (and reject bad packets early).
let header = parse_nexmon_udp_header(payload)?;
let n = header.subcarrier_count as usize;
if n == 0 || n > MAX_SUBCARRIERS {
return Err(NexmonFfiError::Shim {
code: 7,
message: "subcarrier count out of range".to_string(),
});
}
let mut hdr = RvcsiNxUdpHeader::default();
let mut meta = RvcsiNxMeta {
subcarrier_count: 0,
channel: 0,
bandwidth_mhz: 0,
rssi_dbm: 0,
noise_floor_dbm: 0,
timestamp_ns: 0,
};
let mut i_out = vec![0.0f32; n];
let mut q_out = vec![0.0f32; n];
// SAFETY: `payload` valid for its length; `i_out`/`q_out` valid for `n`
// f32s each (we pass `n` as the capacity); `hdr`/`meta` are owned structs
// the C side fully initialises on RVCSI_NX_OK and writes nothing else.
let rc = unsafe {
rvcsi_nx_csi_udp_decode(
payload.as_ptr(),
payload.len(),
csi_format,
&mut hdr,
&mut meta,
i_out.as_mut_ptr(),
q_out.as_mut_ptr(),
n,
)
};
check(rc)?;
debug_assert_eq!(meta.subcarrier_count as usize, n);
let rec = NexmonRecord {
subcarrier_count: meta.subcarrier_count,
channel: meta.channel,
bandwidth_mhz: meta.bandwidth_mhz,
rssi_dbm: (meta.rssi_dbm != ABSENT_I16).then_some(meta.rssi_dbm),
noise_floor_dbm: (meta.noise_floor_dbm != ABSENT_I16).then_some(meta.noise_floor_dbm),
timestamp_ns: meta.timestamp_ns,
i_values: i_out,
q_values: q_out,
};
Ok((NexmonCsiHeader::from(hdr), rec))
}
/// Serialize a synthetic nexmon_csi UDP payload (18-byte header + int16 I/Q body)
/// — used by tests and the synthetic Nexmon source. `i_values`/`q_values` are the
/// raw int16-valued samples (clamped to the int16 range on write); their length
/// must equal `header.subcarrier_count`.
pub fn encode_nexmon_udp(
header: &NexmonCsiHeader,
i_values: &[f32],
q_values: &[f32],
) -> Result<Vec<u8>, NexmonFfiError> {
let n = header.subcarrier_count as usize;
if n == 0 || n > MAX_SUBCARRIERS || i_values.len() != n || q_values.len() != n {
return Err(NexmonFfiError::Shim {
code: 6,
message: "bad subcarrier count or i/q length".to_string(),
});
}
let c_hdr = header.to_c();
let cap = NEXMON_HEADER_BYTES + n * 4;
let mut buf = vec![0u8; cap];
// SAFETY: `buf` valid for `cap` bytes; `i_in`/`q_in` valid for `n` f32s each
// (checked above); `c_hdr` is a fully initialised owned struct.
let written = unsafe {
rvcsi_nx_csi_udp_write(
buf.as_mut_ptr(),
cap,
&c_hdr as *const RvcsiNxUdpHeader,
header.subcarrier_count,
i_values.as_ptr(),
q_values.as_ptr(),
)
};
if written == 0 {
return Err(NexmonFfiError::Shim {
code: 4,
message: "csi_udp_write failed (capacity or argument error)".to_string(),
});
}
debug_assert_eq!(written, cap);
buf.truncate(written);
Ok(buf)
}
/// Bytes in the nexmon_csi UDP header (mirrors `RVCSI_NX_NEXMON_HDR_BYTES`).
pub const NEXMON_HEADER_BYTES: usize = 18;
/// nexmon_csi UDP payload magic (`0x1111`, the first two LE bytes of the header).
pub const NEXMON_MAGIC: u16 = 0x1111;
#[cfg(test)]
mod tests {
use super::*;
#[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<f32> = (0..nsub).map(|k| (k as i16 - 32) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|k| -(k as i16) as f32 + 5.0).collect();
let payload = encode_nexmon_udp(&hdr, &i, &q).expect("encode");
assert_eq!(payload.len(), NEXMON_HEADER_BYTES + (nsub as usize) * 4);
assert_eq!(u16::from_le_bytes([payload[0], payload[1]]), NEXMON_MAGIC);
// header-only parse
let h = parse_nexmon_udp_header(&payload).expect("hdr");
assert_eq!(h.rssi_dbm, -58);
assert_eq!(h.fctl, 0x08);
assert_eq!(h.src_mac, [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]);
assert_eq!(h.seq_cnt, 0x1234);
assert_eq!(h.core, 1);
assert_eq!(h.chanspec, chanspec);
assert_eq!(h.chip_ver, 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);
}
}
}

View File

@ -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<f32>, Vec<f32>)],
port: u16,
) -> Result<Vec<u8>, NexmonFfiError> {
let payloads: Vec<Vec<u8>> = frames
.iter()
.map(|(_, h, i, q)| encode_nexmon_udp(h, i, q))
.collect::<Result<_, _>>()?;
let refs: Vec<(u64, u16, &[u8])> = frames
.iter()
.zip(payloads.iter())
.map(|((ts, ..), p)| (*ts, port, p.as_slice()))
.collect();
Ok(pcap::synthetic_udp_pcap(&refs))
}
/// A [`CsiSource`] that replays a buffer of rvCSI Nexmon records.
///
/// 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<u8>,
cursor: usize,
next_frame_id: u64,
delivered: u64,
rejected: u64,
status: Option<String>,
}
impl NexmonAdapter {
/// Build an adapter from a buffer of concatenated records.
pub fn from_bytes(
source_id: impl Into<SourceId>,
session_id: SessionId,
bytes: impl Into<Vec<u8>>,
) -> 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<SourceId>,
session_id: SessionId,
path: impl AsRef<Path>,
) -> Result<Self, RvcsiError> {
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<SourceId>,
session_id: SessionId,
bytes: &[u8],
) -> Result<Vec<CsiFrame>, 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<Option<CsiFrame>, 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<CsiFrame>,
headers: Vec<NexmonCsiHeader>,
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<SourceId>,
session_id: SessionId,
pcap_bytes: &[u8],
port: Option<u16>,
) -> Result<Self, RvcsiError> {
debug_assert_eq!(shim_abi_version() >> 16, 1, "rvcsi_nexmon_shim major ABI mismatch");
let source_id = source_id.into();
let reader = PcapReader::parse(pcap_bytes)?;
let link_type = reader.link_type();
let want_port = port.or(Some(NEXMON_DEFAULT_PORT));
let mut frames = Vec::new();
let mut headers = Vec::new();
let mut skipped = 0u64;
let mut next_fid = 0u64;
for (ts_ns, _dst_port, payload) in reader.udp_payloads(want_port) {
match decode_nexmon_udp(payload, NEXMON_CSI_FMT_INT16_IQ) {
Ok((hdr, rec)) => {
let mut frame = CsiFrame::from_iq(
next_fid.into(),
session_id,
source_id.clone(),
AdapterKind::Nexmon,
ts_ns,
rec.channel,
rec.bandwidth_mhz,
rec.i_values,
rec.q_values,
);
next_fid += 1;
frame.rssi_dbm = rec.rssi_dbm;
frame.noise_floor_dbm = rec.noise_floor_dbm;
frames.push(frame);
headers.push(hdr);
}
Err(_) => skipped += 1,
}
}
// Count non-CSI UDP packets on other ports as "skipped" too, for health.
if let Some(p) = want_port {
skipped += reader.udp_payloads(None).filter(|(_, dp, _)| *dp != p).count() as u64;
}
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<SourceId>,
session_id: SessionId,
path: impl AsRef<Path>,
port: Option<u16>,
) -> Result<Self, RvcsiError> {
let bytes = std::fs::read(path)?;
Self::parse(source_id, session_id, &bytes, port)
}
/// Decode every CSI frame in a `.pcap` buffer in one shot (`Pending` frames).
pub fn frames_from_pcap_bytes(
source_id: impl Into<SourceId>,
session_id: SessionId,
pcap_bytes: &[u8],
port: Option<u16>,
) -> Result<Vec<CsiFrame>, RvcsiError> {
Ok(Self::parse(source_id, session_id, pcap_bytes, port)?.frames)
}
/// The capture's link-layer type.
pub fn link_type(&self) -> u32 {
self.link_type
}
/// The parsed nexmon_csi UDP headers, one per decoded frame, in order.
pub fn headers(&self) -> &[NexmonCsiHeader] {
&self.headers
}
/// Total CSI frames decoded from the capture.
pub fn frame_count(&self) -> usize {
self.frames.len()
}
}
impl CsiSource for NexmonPcapAdapter {
fn profile(&self) -> &AdapterProfile {
&self.profile
}
fn session_id(&self) -> SessionId {
self.session_id
}
fn source_id(&self) -> &SourceId {
&self.source_id
}
fn next_frame(&mut self) -> Result<Option<CsiFrame>, RvcsiError> {
let frame = self.frames.get(self.cursor).cloned();
if frame.is_some() {
self.cursor += 1;
}
Ok(frame)
}
fn health(&self) -> SourceHealth {
SourceHealth {
connected: self.cursor < self.frames.len(),
frames_delivered: self.cursor as u64,
frames_rejected: self.skipped,
status: Some(format!(
"pcap link_type={}, {} CSI frame(s), {} non-CSI/skipped",
self.link_type,
self.frames.len(),
self.skipped
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{validate_frame, ValidationPolicy, ValidationStatus};
fn make_record(ts: u64, ch: u16, n: usize, rssi: Option<i16>) -> Vec<u8> {
let i: Vec<f32> = (0..n).map(|k| (k as f32) * 0.5).collect();
let q: Vec<f32> = (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<u8> {
let hdr = NexmonCsiHeader {
rssi_dbm: rssi,
fctl: 0x08,
src_mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x02],
seq_cnt: seq,
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 0x4345,
channel: 0,
bandwidth_mhz: 0,
is_5ghz: false,
subcarrier_count: nsub,
};
let i: Vec<f32> = (0..nsub).map(|k| (k as i16 - 32) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|k| (seq as i16 + k as i16) as f32).collect();
encode_nexmon_udp(&hdr, &i, &q).expect("encode nexmon payload")
}
/// Wrap `payload` in an Ethernet/IPv4/UDP frame to `dst_port`.
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
let mut f = vec![
1, 2, 3, 4, 5, 6, // dst mac
10, 11, 12, 13, 14, 15, // src mac
];
f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4
let total = (20 + 8 + payload.len()) as u16;
f.extend_from_slice(&[0x45, 0x00]);
f.extend_from_slice(&total.to_be_bytes());
f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum
f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip
f.extend_from_slice(&54321u16.to_be_bytes()); // src port
f.extend_from_slice(&dst_port.to_be_bytes()); // dst port
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len
f.extend_from_slice(&[0, 0]); // udp cksum
f.extend_from_slice(payload);
f
}
/// Build a classic LE/microsecond pcap from `(ts_sec, ts_usec, frame)` records.
fn pcap_le_us(link_type: u32, recs: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&0xa1b2_c3d4u32.to_le_bytes());
b.extend_from_slice(&[2, 0, 4, 0]); // ver major/minor
b.extend_from_slice(&0u32.to_le_bytes()); // thiszone
b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
b.extend_from_slice(&link_type.to_le_bytes());
for (s, us, f) in recs {
b.extend_from_slice(&s.to_le_bytes());
b.extend_from_slice(&us.to_le_bytes());
b.extend_from_slice(&(f.len() as u32).to_le_bytes());
b.extend_from_slice(&(f.len() as u32).to_le_bytes());
b.extend_from_slice(f);
}
b
}
#[test]
fn pcap_adapter_decodes_real_nexmon_csi_packets() {
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz
let nsub = 256u16;
let recs = vec![
(1_000u32, 100_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))),
(1_000u32, 600_000u32, eth_ip_udp(9999, &[0xaa; 8])), // unrelated UDP
(1_001u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-61, chanspec, nsub, 2))),
(1_001u32, 50_000u32, eth_ip_udp(5500, &[0x42; 30])), // bad nexmon magic -> skipped
];
let pcap = pcap_le_us(LINKTYPE_ETHERNET, &recs);
let mut adapter = NexmonPcapAdapter::parse("nexmon-pcap", SessionId(9), &pcap, None).unwrap();
assert_eq!(adapter.link_type(), LINKTYPE_ETHERNET);
assert_eq!(adapter.frame_count(), 2);
assert_eq!(adapter.headers().len(), 2);
assert_eq!(adapter.headers()[0].chanspec, chanspec);
assert_eq!(adapter.headers()[0].channel, 36);
assert_eq!(adapter.headers()[0].bandwidth_mhz, 80);
assert!(adapter.headers()[0].is_5ghz);
assert_eq!(adapter.headers()[1].seq_cnt, 2);
let mut frames = Vec::new();
while let Some(f) = adapter.next_frame().unwrap() {
frames.push(f);
}
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].adapter_kind, AdapterKind::Nexmon);
assert_eq!(frames[0].channel, 36);
assert_eq!(frames[0].bandwidth_mhz, 80);
assert_eq!(frames[0].rssi_dbm, Some(-58));
assert_eq!(frames[0].subcarrier_count, nsub);
// pcap timestamp -> frame timestamp (1000 s + 100000 us)
assert_eq!(frames[0].timestamp_ns, 1_000 * 1_000_000_000 + 100_000 * 1_000);
assert_eq!(frames[1].timestamp_ns, 1_001 * 1_000_000_000);
let h = adapter.health();
assert!(!h.connected);
assert_eq!(h.frames_delivered, 2);
assert!(h.frames_rejected >= 2); // the bad-magic one + the unrelated-port one
}
#[test]
fn pcap_adapter_validates_decoded_frames() {
let pcap = pcap_le_us(
LINKTYPE_ETHERNET,
&[(1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-60, 0x1000 | 6, 64, 7)))],
);
let frames = NexmonPcapAdapter::frames_from_pcap_bytes("p", SessionId(0), &pcap, Some(5500)).unwrap();
assert_eq!(frames.len(), 1);
// 64 sc, channel 6 — accepted by a permissive (offline) profile
let mut f = frames[0].clone();
validate_frame(
&mut f,
&AdapterProfile::offline(AdapterKind::Nexmon),
&ValidationPolicy::default(),
None,
)
.unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
assert_eq!(f.channel, 6);
assert_eq!(f.bandwidth_mhz, 20);
}
#[test]
fn pcap_adapter_rejects_garbage_pcap() {
assert!(NexmonPcapAdapter::parse("p", SessionId(0), &[0u8; 8], None).is_err());
assert!(NexmonPcapAdapter::open("p", SessionId(0), "/no/such/file.pcap", None).is_err());
}
#[test]
fn pcap_adapter_auto_detects_raspberry_pi_5_chip() {
// synth_nexmon_payload stamps chip_ver = 0x4345 (BCM4345 family chip ID),
// which is the CYW43455 / BCM43455c0 on a Raspberry Pi 3B+ / 4 / 400 / 5.
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz, ch 36, 80 MHz
let nsub = 256u16;
let pcap = pcap_le_us(
LINKTYPE_ETHERNET,
&[
(1u32, 0u32, eth_ip_udp(5500, &synth_nexmon_payload(-58, chanspec, nsub, 1))),
(1u32, 50_000u32, eth_ip_udp(5500, &synth_nexmon_payload(-59, chanspec, nsub, 2))),
],
);
let adapter = NexmonPcapAdapter::parse("pi5-cap", SessionId(1), &pcap, None).unwrap();
assert_eq!(adapter.detected_chip(), NexmonChip::Bcm43455c0);
assert_eq!(adapter.headers()[0].chip(), NexmonChip::Bcm43455c0);
// the adapter's validation profile is the 43455c0 one (20/40/80, 64/128/256)
let p = adapter.profile();
assert_eq!(p.supported_bandwidths_mhz, vec![20, 40, 80]);
assert!(p.accepts_subcarrier_count(256));
assert!(p.accepts_channel(36));
// 256-sc, ch 36 frame validates fine against the Pi 5 profile
let mut f = adapter.frames[0].clone();
validate_frame(&mut f, &raspberry_pi_profile(RaspberryPiModel::Pi5), &ValidationPolicy::default(), None).unwrap();
assert_eq!(f.validation, ValidationStatus::Accepted);
// explicit override to a Pi 5 also works
let a2 = NexmonPcapAdapter::parse("p", SessionId(0), &pcap, None).unwrap().with_pi_model(RaspberryPiModel::Pi5);
assert_eq!(a2.detected_chip(), NexmonChip::Bcm43455c0);
assert!(a2.profile().chip.as_deref().unwrap().contains("pi5"));
}
}

View File

@ -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<u8>,
}
/// A parsed classic-pcap file.
#[derive(Debug, Clone)]
pub struct PcapReader {
link_type: u32,
packets: Vec<PcapPacket>,
}
fn parse_err(offset: usize, msg: impl Into<String>) -> RvcsiError {
RvcsiError::parse(offset, format!("pcap: {}", msg.into()))
}
struct Endian(bool /* big-endian writer? */);
impl Endian {
fn u32(&self, b: &[u8]) -> u32 {
if self.0 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
} else {
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
}
}
}
impl PcapReader {
/// Parse a classic-pcap byte buffer.
pub fn parse(bytes: &[u8]) -> Result<PcapReader, RvcsiError> {
if bytes.len() < 24 {
return Err(parse_err(0, "buffer shorter than the 24-byte global header"));
}
// The 4 magic bytes on disk identify both byte order and ts resolution.
// 0xa1b2c3d4 written by a LE host -> [d4,c3,b2,a1]; by a BE host -> [a1,b2,c3,d4].
// 0xa1b23c4d (nanosecond ts): LE -> [4d,3c,b2,a1]; BE -> [a1,b2,3c,4d].
let m = [bytes[0], bytes[1], bytes[2], bytes[3]];
let (endian, ts_is_ns) = match m {
[0xd4, 0xc3, 0xb2, 0xa1] => (Endian(false), false),
[0xa1, 0xb2, 0xc3, 0xd4] => (Endian(true), false),
[0x4d, 0x3c, 0xb2, 0xa1] => (Endian(false), true),
[0xa1, 0xb2, 0x3c, 0x4d] => (Endian(true), true),
_ => {
let raw = u32::from_le_bytes(m);
return Err(parse_err(
0,
format!("unrecognised pcap magic 0x{raw:08x} (pcapng is not supported)"),
));
}
};
// bytes 4..6 version_major, 6..8 version_minor, 8..12 thiszone,
// 12..16 sigfigs, 16..20 snaplen, 20..24 network (link type)
let link_type = endian.u32(&bytes[20..24]);
let mut packets = Vec::new();
let mut off = 24usize;
while off + 16 <= bytes.len() {
let ts_sec = endian.u32(&bytes[off..off + 4]) as u64;
let ts_frac = endian.u32(&bytes[off + 4..off + 8]) as u64;
let incl_len = endian.u32(&bytes[off + 8..off + 12]) as usize;
// orig_len at off+12..off+16 is informational; ignored.
let data_start = off + 16;
if incl_len > bytes.len().saturating_sub(data_start) {
// Truncated final record — stop cleanly rather than erroring.
break;
}
let timestamp_ns = ts_sec
.saturating_mul(1_000_000_000)
.saturating_add(if ts_is_ns { ts_frac } else { ts_frac.saturating_mul(1_000) });
packets.push(PcapPacket {
timestamp_ns,
data: bytes[data_start..data_start + incl_len].to_vec(),
});
off = data_start + incl_len;
}
Ok(PcapReader { link_type, packets })
}
/// The capture's link-layer type (one of the `LINKTYPE_*` constants, or another value).
pub fn link_type(&self) -> u32 {
self.link_type
}
/// All captured packets, in file order.
pub fn packets(&self) -> &[PcapPacket] {
&self.packets
}
/// Iterate the UDP payloads in the capture whose destination port matches
/// `port` (or all UDP payloads if `port` is `None`), as `(timestamp_ns,
/// dst_port, payload)`. Non-IPv4 / non-UDP / non-matching packets are skipped.
pub fn udp_payloads(
&self,
port: Option<u16>,
) -> impl Iterator<Item = (u64, u16, &[u8])> + '_ {
let link_type = self.link_type;
self.packets.iter().filter_map(move |pkt| {
let (dst_port, payload) = extract_udp_payload(&pkt.data, link_type)?;
if let Some(p) = port {
if dst_port != p {
return None;
}
}
Some((pkt.timestamp_ns, dst_port, payload))
})
}
}
/// Strip the link / network / transport headers from a captured frame with the
/// given link type and return `(udp_dst_port, udp_payload)`, or `None` if it
/// isn't an IPv4/UDP packet we can peel.
pub fn extract_udp_payload(frame: &[u8], link_type: u32) -> Option<(u16, &[u8])> {
let ip = match link_type {
LINKTYPE_ETHERNET => {
if frame.len() < 14 {
return None;
}
let ethertype = u16::from_be_bytes([frame[12], frame[13]]);
if ethertype != 0x0800 {
return None; // not IPv4 (ignore VLAN-tagged for now)
}
&frame[14..]
}
LINKTYPE_LINUX_SLL => {
if frame.len() < 16 {
return None;
}
let proto = u16::from_be_bytes([frame[14], frame[15]]);
if proto != 0x0800 {
return None;
}
&frame[16..]
}
LINKTYPE_RAW | LINKTYPE_IPV4 => frame,
_ => return None,
};
// IPv4 header
if ip.len() < 20 {
return None;
}
if (ip[0] >> 4) != 4 {
return None; // not IPv4
}
let ihl = (ip[0] & 0x0f) as usize * 4;
if ihl < 20 || ip.len() < ihl {
return None;
}
if ip[9] != 17 {
return None; // not UDP
}
let udp = &ip[ihl..];
if udp.len() < 8 {
return None;
}
let dst_port = u16::from_be_bytes([udp[2], udp[3]]);
let udp_len = u16::from_be_bytes([udp[4], udp[5]]) as usize; // includes the 8-byte UDP header
let payload_len = udp_len.saturating_sub(8).min(udp.len() - 8);
Some((dst_port, &udp[8..8 + payload_len]))
}
/// Build a synthetic classic-pcap byte buffer — little-endian, microsecond
/// timestamps, [`LINKTYPE_ETHERNET`] — wrapping the given UDP payloads, one
/// Ethernet/IPv4/UDP packet each. Entries are `(timestamp_ns, dst_port,
/// payload)`. Intended for tests, examples and the `rvcsi` self-tests: real
/// captures come off a Raspberry Pi running patched firmware
/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`).
pub fn synthetic_udp_pcap(packets: &[(u64, u16, &[u8])]) -> Vec<u8> {
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
let mut f = vec![
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, // dst mac
0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // src mac
];
f.extend_from_slice(&0x0800u16.to_be_bytes()); // ethertype IPv4
let total = (20 + 8 + payload.len()) as u16;
f.extend_from_slice(&[0x45, 0x00]);
f.extend_from_slice(&total.to_be_bytes());
f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]); // id/frag/ttl/proto=UDP/cksum
f.extend_from_slice(&[10, 0, 0, 1, 10, 0, 0, 20]); // src/dst ip
f.extend_from_slice(&54321u16.to_be_bytes()); // src port
f.extend_from_slice(&dst_port.to_be_bytes()); // dst port
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); // udp len
f.extend_from_slice(&[0, 0]); // udp cksum
f.extend_from_slice(payload);
f
}
let mut b = Vec::new();
b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes());
b.extend_from_slice(&[2, 0, 4, 0]); // version major/minor
b.extend_from_slice(&0u32.to_le_bytes()); // thiszone
b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
b.extend_from_slice(&LINKTYPE_ETHERNET.to_le_bytes());
for (ts_ns, dst_port, payload) in packets {
let frame = eth_ip_udp(*dst_port, payload);
let ts_sec = (ts_ns / 1_000_000_000) as u32;
let ts_usec = ((ts_ns % 1_000_000_000) / 1_000) as u32;
b.extend_from_slice(&ts_sec.to_le_bytes());
b.extend_from_slice(&ts_usec.to_le_bytes());
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len
b.extend_from_slice(&frame);
}
b
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic Ethernet/IPv4/UDP frame carrying `payload` to `dst_port`.
fn eth_ip_udp(dst_port: u16, payload: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
// Ethernet II: dst[6] src[6] ethertype[2]
f.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
f.extend_from_slice(&[0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]);
f.extend_from_slice(&0x0800u16.to_be_bytes());
// IPv4: 20-byte header
let total_len = (20 + 8 + payload.len()) as u16;
let mut ip = vec![
0x45, 0x00, // version/IHL, DSCP/ECN
];
ip.extend_from_slice(&total_len.to_be_bytes());
ip.extend_from_slice(&[0, 0, 0, 0, 64, 17]); // id, flags/frag, ttl, proto=UDP
ip.extend_from_slice(&[0, 0]); // header checksum (not checked here)
ip.extend_from_slice(&[10, 0, 0, 1]); // src ip
ip.extend_from_slice(&[10, 0, 0, 20]); // dst ip
assert_eq!(ip.len(), 20);
f.extend_from_slice(&ip);
// UDP: src_port[2] dst_port[2] length[2] checksum[2]
f.extend_from_slice(&54321u16.to_be_bytes());
f.extend_from_slice(&dst_port.to_be_bytes());
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes());
f.extend_from_slice(&[0, 0]); // checksum
f.extend_from_slice(payload);
f
}
/// Build a minimal classic-pcap file (LE, microsecond) wrapping the frames.
fn pcap_le_us(link_type: u32, frames: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&PCAP_MAGIC_US.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes()); // version major
b.extend_from_slice(&4u16.to_le_bytes()); // version minor
b.extend_from_slice(&0i32.to_le_bytes()); // thiszone
b.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
b.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
b.extend_from_slice(&link_type.to_le_bytes());
for (ts_sec, ts_usec, frame) in frames {
b.extend_from_slice(&ts_sec.to_le_bytes());
b.extend_from_slice(&ts_usec.to_le_bytes());
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // incl_len
b.extend_from_slice(&(frame.len() as u32).to_le_bytes()); // orig_len
b.extend_from_slice(frame);
}
b
}
#[test]
fn parses_global_header_and_iterates_udp_payloads() {
let p1 = vec![0xaa; 30];
let p2 = vec![0xbb; 12];
let other = vec![0xcc; 8];
let frames = vec![
(100u32, 250_000u32, eth_ip_udp(5500, &p1)),
(101u32, 500_000u32, eth_ip_udp(9999, &other)), // different port
(102u32, 0u32, eth_ip_udp(5500, &p2)),
];
let file = pcap_le_us(LINKTYPE_ETHERNET, &frames);
let r = PcapReader::parse(&file).unwrap();
assert_eq!(r.link_type(), LINKTYPE_ETHERNET);
assert_eq!(r.packets().len(), 3);
let csi: Vec<_> = r.udp_payloads(Some(5500)).collect();
assert_eq!(csi.len(), 2);
assert_eq!(csi[0].0, 100 * 1_000_000_000 + 250_000 * 1_000); // ts_ns
assert_eq!(csi[0].1, 5500);
assert_eq!(csi[0].2, &p1[..]);
assert_eq!(csi[1].2, &p2[..]);
// no filter -> all 3 UDP payloads
assert_eq!(r.udp_payloads(None).count(), 3);
}
#[test]
fn handles_raw_ipv4_linktype() {
// raw IPv4 frame = the IPv4 packet directly (no Ethernet header)
let payload = vec![0x11; 20];
let eth = eth_ip_udp(5500, &payload);
let raw_ip = eth[14..].to_vec(); // strip the 14-byte Ethernet header
let file = pcap_le_us(LINKTYPE_RAW, &[(5u32, 0u32, raw_ip)]);
let r = PcapReader::parse(&file).unwrap();
let v: Vec<_> = r.udp_payloads(Some(5500)).collect();
assert_eq!(v.len(), 1);
assert_eq!(v[0].2, &payload[..]);
}
#[test]
fn nanosecond_magic_scales_timestamps_correctly() {
let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(7u32, 123u32, eth_ip_udp(5500, &[0u8; 8]))]);
// patch the magic to the nanosecond variant
file[0..4].copy_from_slice(&PCAP_MAGIC_NS.to_le_bytes());
let r = PcapReader::parse(&file).unwrap();
let v: Vec<_> = r.udp_payloads(Some(5500)).collect();
assert_eq!(v[0].0, 7 * 1_000_000_000 + 123); // ts_frac taken as ns, not us
}
#[test]
fn rejects_garbage_and_pcapng() {
assert!(PcapReader::parse(&[0u8; 10]).is_err()); // too short
assert!(PcapReader::parse(&[0u8; 24]).is_err()); // zero magic
// pcapng section-header-block magic (0x0a0d0d0a) — not supported
let mut ng = vec![0x0a, 0x0d, 0x0d, 0x0a];
ng.extend_from_slice(&[0u8; 24]);
assert!(PcapReader::parse(&ng).is_err());
}
#[test]
fn truncated_final_record_is_tolerated() {
let mut file = pcap_le_us(LINKTYPE_ETHERNET, &[(1u32, 0u32, eth_ip_udp(5500, &[0u8; 16]))]);
// append a partial record header + claim a huge incl_len
file.extend_from_slice(&2u32.to_le_bytes());
file.extend_from_slice(&0u32.to_le_bytes());
file.extend_from_slice(&9999u32.to_le_bytes()); // incl_len > remaining
file.extend_from_slice(&9999u32.to_le_bytes());
file.extend_from_slice(&[0xde, 0xad]); // only 2 bytes of "data"
let r = PcapReader::parse(&file).unwrap();
assert_eq!(r.packets().len(), 1); // the complete one only
}
#[test]
fn extract_udp_payload_rejects_non_udp() {
// build an Ethernet/IPv4 frame but with proto = TCP (6)
let mut eth = eth_ip_udp(5500, &[0u8; 8]);
// IPv4 proto byte is at Ethernet(14) + 9 = 23
eth[14 + 9] = 6; // TCP
assert!(extract_udp_payload(&eth, LINKTYPE_ETHERNET).is_none());
// wrong ethertype
let mut eth = eth_ip_udp(5500, &[0u8; 8]);
eth[12] = 0x86;
eth[13] = 0xdd; // IPv6
assert!(extract_udp_payload(&eth, LINKTYPE_ETHERNET).is_none());
// unknown link type
assert!(extract_udp_payload(&eth, 9999).is_none());
}
}

View File

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

View File

@ -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 <nexmon.bin> --out <cap.rvcsi>` — 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 <csi.pcap> --out <cap.rvcsi> [--chip pi5]` —
/// transcode the real nexmon_csi UDP payloads inside a libpcap capture
/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into a `.rvcsi` capture file,
/// validating each frame. `port` is the CSI UDP port (`None` ⇒ 5500). `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<u16>,
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 <csi.pcap>` — summarize a nexmon_csi `.pcap` (link
/// type, CSI frame count, channels, bandwidths, chip versions, RSSI range,
/// time span). `port` is the CSI UDP port (`None` ⇒ 5500).
pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option<u16>, json: bool) -> Result<()> {
let s = runtime::summarize_nexmon_pcap(pcap_path, port).with_context(|| format!("inspecting {pcap_path}"))?;
if json {
writeln!(out, "{}", serde_json::to_string_pretty(&s)?)?;
return Ok(());
}
writeln!(out, "nexmon pcap : {pcap_path}")?;
writeln!(out, " link type : {}", s.link_type)?;
writeln!(out, " CSI frames : {}", s.csi_frame_count)?;
writeln!(out, " skipped pkts : {}", s.skipped_packets)?;
writeln!(
out,
" time span : {} .. {} ns ({} ns)",
s.first_timestamp_ns,
s.last_timestamp_ns,
s.last_timestamp_ns.saturating_sub(s.first_timestamp_ns)
)?;
writeln!(out, " channels : {:?}", s.channels)?;
writeln!(out, " bandwidths : {:?} MHz", s.bandwidths_mhz)?;
writeln!(out, " subcarriers : {:?}", s.subcarrier_counts)?;
writeln!(
out,
" chip versions: {}",
s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::<Vec<_>>().join(", ")
)?;
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 <hex-or-dec>` — decode a Broadcom d11ac chanspec word
/// to `{channel, bandwidth_mhz, is_5ghz}` (JSON, or a human line).
pub fn decode_chanspec_cmd(out: &mut dyn Write, chanspec_str: &str, json: bool) -> Result<()> {
let s = chanspec_str.trim();
let value: u32 = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).with_context(|| format!("not a hex u16: {s}"))?
} else {
s.parse::<u32>().with_context(|| format!("not a decimal u16: {s}"))?
};
let d = rvcsi_adapter_nexmon::decode_chanspec((value & 0xFFFF) as u16);
if json {
writeln!(
out,
"{}",
serde_json::to_string(&serde_json::json!({
"chanspec": d.chanspec, "channel": d.channel,
"bandwidth_mhz": d.bandwidth_mhz, "is_5ghz": d.is_5ghz
}))?
)?;
} else {
writeln!(
out,
"chanspec 0x{:04x}: channel {} @ {} MHz ({})",
d.chanspec,
d.channel,
d.bandwidth_mhz,
if d.is_5ghz { "5 GHz" } else { "2.4 GHz" }
)?;
}
Ok(())
}
/// `rvcsi inspect <path>` — print a summary of a `.rvcsi` capture file.
pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> {
let summary = runtime::summarize_capture(path).with_context(|| format!("inspecting {path}"))?;
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 <path>` / `rvcsi stream --in <path> --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<usize>) -> 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 <path>` — 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::<Vec<_>>(),
e.calibration_version.as_deref().map(|c| format!(" calib={c}")).unwrap_or_default(),
)?;
}
writeln!(out, "-- {} event(s)", evs.len())?;
Ok(())
}
/// `rvcsi health --source <slug> [--target <path>]` — 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 <path>` 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 <path>` 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 <capture> --out <jsonl>` — 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 <capture> [--out <baseline.json>]` — 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<f32> = 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<f32>,
}
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<f32> = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect();
let q: Vec<f32> = (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<F: FnOnce(&mut Vec<u8>) -> 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<f32>, Vec<f32>)> = (0..8u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|s| (s as i16 % 5 + k as i16) as f32).collect();
(
1_000_000_000 + k * 50_000_000,
NexmonCsiHeader {
rssi_dbm: -55 - k as i16,
fctl: 8,
src_mac: [0, 1, 2, 3, 4, 5],
seq_cnt: k as u16,
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 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());
}
}

View File

@ -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<u16>,
/// Validate against a specific chip / Raspberry Pi model — e.g. `pi5`,
/// `pi4`, `pi3b+`, `pizero2w`, `bcm43455c0`, `bcm4366c0` — dropping
/// frames that don't fit it. Default: permissive (any subcarrier count).
#[arg(long)]
chip: Option<String>,
},
/// List the Broadcom/Cypress chips nexmon_csi runs on + the Raspberry Pi models (incl. Pi 5).
NexmonChips {
/// Emit JSON instead of a human listing.
#[arg(long)]
json: bool,
},
/// Summarize a nexmon_csi `.pcap` file (link type, CSI frames, channels, ...).
InspectNexmon {
/// Path to a nexmon_csi `.pcap` capture.
path: String,
/// CSI UDP port (defaults to 5500).
#[arg(long)]
port: Option<u16>,
/// Emit machine-readable JSON instead of a human summary.
#[arg(long)]
json: bool,
},
/// Decode a Broadcom d11ac chanspec word (hex `0x…` or decimal).
DecodeChanspec {
/// The chanspec value, e.g. `0xe024` or `57380`.
chanspec: String,
/// Emit JSON instead of a human line.
#[arg(long)]
json: bool,
},
/// Summarize a `.rvcsi` capture file (frame count, channels, quality, ...).
Inspect {
/// 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<usize>,
/// 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<u16>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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(())
}

View File

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

View File

@ -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<String>,
/// Firmware version string, if known.
pub firmware_version: Option<String>,
/// Driver version string, if known.
pub driver_version: Option<String>,
/// Channels the source can capture on.
pub supported_channels: Vec<u16>,
/// Bandwidths (MHz) the source supports.
pub supported_bandwidths_mhz: Vec<u16>,
/// Subcarrier counts the source is expected to emit (e.g. `[52, 56, 114, 234]`).
pub expected_subcarrier_counts: Vec<u16>,
/// 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 113, 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<String>,
}
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<String>,
/// WiFi channel (live sources only).
#[serde(default)]
pub channel: Option<u16>,
/// Bandwidth in MHz (live sources only).
#[serde(default)]
pub bandwidth_mhz: Option<u16>,
/// Replay speed multiplier (`1.0` = real time); replay source only.
#[serde(default)]
pub replay_speed: Option<f32>,
/// Free-form adapter-specific options.
#[serde(default)]
pub options_json: Option<String>,
}
impl SourceConfig {
/// Build a config for the given source slug with no other options set.
pub fn new(source: impl Into<String>) -> 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<String>) -> 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<dyn CsiSource>`. 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<Option<CsiFrame>, 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::<SourceConfig>(&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);
}
}

View File

@ -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<String>, message: impl Into<String>) -> Self {
RvcsiError::Adapter {
kind: kind.into(),
message: message.into(),
}
}
/// Convenience constructor for parse errors.
pub fn parse(offset: usize, message: impl Into<String>) -> 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"));
}
}

View File

@ -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<WindowId>,
/// Calibration version detection ran against, if any.
pub calibration_version: Option<String>,
/// 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<WindowId>,
) -> 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<String>) -> Self {
self.calibration_version = Some(version.into());
self
}
/// Attach metadata (any serializable value).
pub fn with_metadata<T: Serialize>(mut self, meta: &T) -> Result<Self, serde_json::Error> {
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::<CsiEvent>(&json).unwrap(), e);
}
}

View File

@ -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<i16>,
/// Noise floor, dBm, if reported.
pub noise_floor_dbm: Option<i16>,
/// Receive-antenna index, if reported.
pub antenna_index: Option<u8>,
/// Transmit chain index, if reported.
pub tx_chain: Option<u8>,
/// Receive chain index, if reported.
pub rx_chain: Option<u8>,
/// Number of subcarriers (== length of the four vectors below).
pub subcarrier_count: u16,
/// In-phase components, one per subcarrier.
pub i_values: Vec<f32>,
/// Quadrature components, one per subcarrier.
pub q_values: Vec<f32>,
/// Magnitude `sqrt(i^2 + q^2)`, one per subcarrier.
pub amplitude: Vec<f32>,
/// Phase `atan2(q, i)` in radians, one per subcarrier (unwrapped by DSP later).
pub phase: Vec<f32>,
/// 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<String>,
/// Calibration version this frame was processed against, if any.
pub calibration_version: Option<String>,
}
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<f32>,
q_values: Vec<f32>,
) -> 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<u8>, tx: Option<u8>, rx: Option<u8>) -> 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::<f32>() / 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);
}
}

View File

@ -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<u64> 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<String>) -> 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<String> 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::<SourceId>(&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::<FrameId>(&json).unwrap(), f);
}
}

View File

@ -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<T> = core::result::Result<T, RvcsiError>;

View File

@ -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<String>,
}
impl QualityScore {
fn full() -> Self {
QualityScore {
value: 1.0,
reasons: Vec::new(),
}
}
fn penalize(&mut self, factor: f32, reason: impl Into<String>) {
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<u64>,
) -> 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<f32> = 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 { .. }));
}
}

View File

@ -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<f32>,
/// Phase variance per subcarrier.
pub phase_variance: Vec<f32>,
/// 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)));
}
}

View File

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

View File

@ -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>]) -> 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.10.5 Hz band
/// (630 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<f32> {
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<f32> = 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());
}
}

View File

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

View File

@ -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<Vec<f32>>,
}
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<Vec<f32>>) -> 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<f32>) -> 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<f32> = amp.clone();
let q: Vec<f32> = 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);
}
}

View File

@ -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::<f32>() / 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::<f32>()
/ 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<f32> {
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<f32> {
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<f32> {
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<f32>, 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<f32> = 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<f32> {
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<Vec<f32>, 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 });
}
}

View File

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

View File

@ -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<CsiEvent>;
/// 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<WindowId> = 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<CsiEvent> {
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<CsiEvent> {
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<CsiEvent> {
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<Vec<f32>>,
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<CsiEvent> {
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<f32>) -> 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(10128), 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<f32> = {
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<f32> = 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<f32> = {
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());
}
}

View File

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

View File

@ -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<Box<dyn EventDetector>>,
ids: IdGenerator,
recent: Vec<CsiWindow>,
}
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::<Vec<_>>())
.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<dyn EventDetector>) {
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<CsiEvent> {
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<CsiEvent> {
match self.buffer.flush(&self.ids) {
Some(window) => self.run_detectors(window),
None => Vec::new(),
}
}
fn run_detectors(&mut self, window: CsiWindow) -> Vec<CsiEvent> {
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<f32> = amp.to_vec();
let q: Vec<f32> = 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<CsiFrame> {
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<f32> = (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, &amp, 0.9));
}
frames
}
fn run_stream(frames: &[CsiFrame]) -> Vec<CsiEvent> {
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<CsiEventKind> = 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, &amp, 0.9));
}
assert_eq!(p.recent_windows().len(), 1);
assert_eq!(p.pending_frame_count(), 0);
p.process_frame(&accepted_frame(16, 200_000, &amp, 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, &amp, 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"]
);
}
}

View File

@ -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<u16>,
/// Buffered `amplitude` vectors (one per accepted frame).
amplitudes: Vec<Vec<f32>>,
/// Buffered `phase` vectors (one per accepted frame).
phases: Vec<Vec<f32>>,
/// Buffered `quality_score`s.
qualities: Vec<f32>,
/// Buffered timestamps (ns).
timestamps: Vec<u64>,
}
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<CsiWindow> {
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<CsiWindow> {
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<f32> = amp.to_vec();
let q: Vec<f32> = 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, &amp, 0.9), &g).is_none());
assert!(buf.push(&frame(0, "s", 1, 10, &amp, 0.9), &g).is_none());
assert!(buf.push(&frame(0, "s", 2, 20, &amp, 0.9), &g).is_none());
assert_eq!(buf.pending_frame_count(), 3);
let w = buf.push(&frame(0, "s", 3, 30, &amp, 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, &amp, 0.8), &g).is_none());
assert!(buf.push(&frame(0, "s", 1, 500, &amp, 0.8), &g).is_none());
let w = buf
.push(&frame(0, "s", 2, 1_000, &amp, 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, &amp, 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, &amp, 0.9), &g).is_none());
// Wrong session.
assert!(buf.push(&frame(8, "good", 1, 10, &amp, 0.9), &g).is_none());
// Wrong source.
assert!(buf.push(&frame(7, "bad", 2, 20, &amp, 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, &amp, 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<f32> = (0..32).map(|_| 1.0 + bump).collect();
last = buf.push(&frame(0, "s", k, k * 10, &amp, 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());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T: serde::Serialize>(v: &T) -> napi::Result<String> {
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<String> {
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<String> {
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<String> {
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<u32> {
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<u16>,
chip: Option<String>,
) -> napi::Result<String> {
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<u16>) -> napi::Result<String> {
let summary = runtime::summarize_nexmon_pcap(&path, port).map_err(napi_err)?;
to_json(&summary)
}
/// Decode a Broadcom d11ac chanspec word; returns JSON
/// `{ chanspec, channel, bandwidth_mhz, is_5ghz }`.
#[napi]
pub fn decode_chanspec(chanspec: u32) -> napi::Result<String> {
let d = rvcsi_adapter_nexmon::decode_chanspec((chanspec & 0xFFFF) as u16);
to_json(&serde_json::json!({
"chanspec": d.chanspec,
"channel": d.channel,
"bandwidth_mhz": d.bandwidth_mhz,
"is_5ghz": d.is_5ghz,
}))
}
/// Resolve a `chip_ver` word from a nexmon_csi packet to a chip slug
/// (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise).
#[napi]
pub fn nexmon_chip_name(chip_ver: u32) -> String {
rvcsi_adapter_nexmon::NexmonChip::from_chip_ver((chip_ver & 0xFFFF) as u16).slug()
}
/// The `AdapterProfile` (channels / bandwidths / expected subcarrier counts /
/// capability flags) for a chip / Raspberry-Pi-model spec (`"pi5"`,
/// `"bcm43455c0"`, `"raspberry pi 4"`, ...); returns JSON. Throws if unknown.
#[napi]
pub fn nexmon_profile(spec: String) -> napi::Result<String> {
let p = runtime::nexmon_profile_for(&spec)
.ok_or_else(|| napi::Error::from_reason(format!("unknown nexmon chip / Raspberry Pi model `{spec}`")))?;
to_json(&p)
}
/// JSON listing of the Nexmon-supported chips + the Raspberry Pi models that
/// carry them (incl. the Pi 5 → BCM43455c0): `{ chips: [...], raspberryPiModels: [...] }`.
#[napi]
pub fn nexmon_chips() -> napi::Result<String> {
use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip};
let chips: Vec<_> = known_chips()
.iter()
.map(|c| {
let p = nexmon_adapter_profile(*c);
serde_json::json!({
"slug": c.slug(), "description": c.description(),
"dualBand": c.dual_band(), "int16IqExport": c.uses_int16_iq(),
"bandwidthsMhz": p.supported_bandwidths_mhz,
"expectedSubcarrierCounts": p.expected_subcarrier_counts,
})
})
.collect();
let pis: Vec<_> = known_pi_models()
.iter()
.map(|m| {
let chip = m.nexmon_chip();
serde_json::json!({
"slug": m.slug(),
"chip": if matches!(chip, NexmonChip::Unknown { .. }) { serde_json::Value::Null } else { serde_json::Value::String(chip.slug()) },
"csiSupported": m.csi_supported(),
})
})
.collect();
to_json(&serde_json::json!({ "chips": chips, "raspberryPiModels": pis }))
}
// ---------------------------------------------------------------------------
// Streaming runtime class
// ---------------------------------------------------------------------------
/// 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<RvcsiRuntime> {
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<RvcsiRuntime> {
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<u16>,
) -> napi::Result<RvcsiRuntime> {
Ok(RvcsiRuntime {
inner: CaptureRuntime::open_nexmon_pcap(&path, &source_id, session_id as u64, port)
.map_err(napi_err)?,
})
}
/// Next exposable, validated frame as JSON, or `null` at end-of-stream.
#[napi]
pub fn next_frame_json(&mut self) -> napi::Result<Option<String>> {
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<Option<String>> {
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<String> {
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<String> {
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
}
}

View File

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

View File

@ -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<dyn CsiSource>,
profile: AdapterProfile,
policy: ValidationPolicy,
dsp: SignalPipeline,
events: EventPipeline,
prev_ts: Option<u64>,
frames_seen: u64,
frames_dropped: u64,
}
impl CaptureRuntime {
fn new(source: Box<dyn CsiSource>, 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<Self, RvcsiError> {
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<u8>, 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<Self, RvcsiError> {
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<u16>,
) -> Result<Self, RvcsiError> {
let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::open(
SourceId::from(source_id),
SessionId(session_id),
path,
port,
)?;
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
}
/// Open a real nexmon_csi `.pcap` from an in-memory byte buffer.
pub fn open_nexmon_pcap_bytes(
pcap_bytes: &[u8],
source_id: &str,
session_id: u64,
port: Option<u16>,
) -> Result<Self, RvcsiError> {
let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
SourceId::from(source_id),
SessionId(session_id),
pcap_bytes,
port,
)?;
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
}
/// Validate (if needed) a freshly pulled frame; `None` if it was hard-rejected.
fn admit(&mut self, mut frame: CsiFrame) -> Option<CsiFrame> {
self.frames_seen += 1;
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<Option<CsiFrame>, 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<Option<CsiFrame>, 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<Vec<CsiEvent>, 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<f32> = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect();
let q: Vec<f32> = (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<f32>, Vec<f32>)> = (0..12u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 32 + k as i16) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|_| 1.0f32).collect();
(
1_000_000_000 + k * 50_000_000,
NexmonCsiHeader {
rssi_dbm: -55 - k as i16,
fctl: 8,
src_mac: [0, 1, 2, 3, 4, 5],
seq_cnt: k as u16,
core: 0,
spatial_stream: 0,
chanspec,
chip_ver: 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());
}
}

View File

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

View File

@ -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<String>,
/// 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<u16>,
/// Distinct subcarrier counts seen.
pub subcarrier_counts: Vec<u16>,
/// 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<String>,
}
/// 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<T: Ord + Copy>(mut v: Vec<T>) -> Vec<T> {
v.sort_unstable();
v.dedup();
v
}
/// Summarize a `.rvcsi` capture file.
pub fn summarize_capture(path: &str) -> Result<CaptureSummary, RvcsiError> {
let (header, frames): (CaptureHeader, Vec<CsiFrame>) = 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<CsiFrame>, profile: &AdapterProfile) -> Vec<CsiFrame> {
let policy = ValidationPolicy::default();
let mut out = Vec::with_capacity(raw.len());
let mut prev_ts: Option<u64> = None;
for mut f in raw {
let ts = f.timestamp_ns;
if f.validation == ValidationStatus::Pending {
match validate_frame(&mut f, profile, &policy, prev_ts) {
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<CsiFrame>) -> Vec<CsiFrame> {
validate_frames_against(raw, &AdapterProfile::offline(rvcsi_core::AdapterKind::Nexmon))
}
/// Resolve a chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`,
/// `"raspberry pi 4"`, `"4366c0"`, ...) to an [`AdapterProfile`], for the
/// `--chip` flag and SDK callers. Returns `None` for an unknown spec.
pub fn nexmon_profile_for(spec: &str) -> Option<AdapterProfile> {
if let Some(model) = rvcsi_adapter_nexmon::RaspberryPiModel::from_slug(spec) {
return Some(rvcsi_adapter_nexmon::raspberry_pi_profile(model));
}
rvcsi_adapter_nexmon::NexmonChip::from_slug(spec)
.map(rvcsi_adapter_nexmon::nexmon_adapter_profile)
}
/// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into
/// validated [`CsiFrame`]s. Frames that hard-fail validation are dropped (never
/// returned to JS).
pub fn decode_nexmon_records(
bytes: &[u8],
source_id: &str,
session_id: u64,
) -> Result<Vec<CsiFrame>, RvcsiError> {
let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?;
Ok(validate_frames_permissive(raw))
}
/// Decode the *real* nexmon_csi UDP payloads inside a libpcap (`.pcap`) buffer
/// into validated [`CsiFrame`]s. `port` is the CSI UDP port (`None` ⇒ 5500).
/// Validation is permissive (any subcarrier count / channel survives); pass a
/// chip spec to [`decode_nexmon_pcap_for`] to bound against a specific device.
pub fn decode_nexmon_pcap(
pcap_bytes: &[u8],
source_id: &str,
session_id: u64,
port: Option<u16>,
) -> Result<Vec<CsiFrame>, RvcsiError> {
decode_nexmon_pcap_for(pcap_bytes, source_id, session_id, port, None)
}
/// Like [`decode_nexmon_pcap`] but, when `chip_spec` is `Some` (`"pi5"`,
/// `"bcm43455c0"`, ...), validates each frame against that device's profile and
/// drops the non-conforming ones (e.g. a 256-subcarrier VHT80 frame against a
/// 2.4 GHz-only `bcm43436b0` profile). An unrecognised spec is a `Config` error.
pub fn decode_nexmon_pcap_for(
pcap_bytes: &[u8],
source_id: &str,
session_id: u64,
port: Option<u16>,
chip_spec: Option<&str>,
) -> Result<Vec<CsiFrame>, RvcsiError> {
let raw = rvcsi_adapter_nexmon::NexmonPcapAdapter::frames_from_pcap_bytes(
SourceId::from(source_id),
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<u16>,
/// Distinct bandwidths (MHz) seen.
pub bandwidths_mhz: Vec<u16>,
/// Distinct subcarrier (FFT) counts seen.
pub subcarrier_counts: Vec<u16>,
/// Distinct chip-version words seen (e.g. `0x4345` = the BCM4345 family).
pub chip_versions: Vec<u16>,
/// Distinct resolved chip slugs (`"bcm43455c0"` for a Raspberry Pi 3B+/4/400/5; `"unknown:0xNNNN"` otherwise).
pub chip_names: Vec<String>,
/// The chip the adapter settled on (all packets agreed) — `"bcm43455c0"` for a Pi 5 capture.
pub detected_chip: String,
/// Min / max RSSI (dBm) over the CSI packets; `None` if empty.
pub rssi_dbm_range: Option<(i16, i16)>,
}
/// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, etc.).
pub fn summarize_nexmon_pcap(path: &str, port: Option<u16>) -> Result<NexmonPcapSummary, RvcsiError> {
let bytes = std::fs::read(path)?;
let adapter = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
SourceId::from(format!("pcap:{path}")),
SessionId(0),
&bytes,
port,
)?;
let health = adapter.health();
let 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<Vec<CsiEvent>, 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<u64> = 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<usize, RvcsiError> {
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<f32> = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect();
let q: Vec<f32> = (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<u8> {
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz
let nsub = 256u16;
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..4u64)
.map(|k| {
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
let q: Vec<f32> = (0..nsub).map(|s| (s as i16 % 7 + k as i16) as f32).collect();
(1_000_000_000 + k * 50_000_000, synth_nexmon_header(-58 - k as i16, chanspec, nsub, k as u16 + 1), i, q)
})
.collect();
rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).expect("build pcap")
}
#[test]
fn decode_nexmon_pcap_yields_validated_frames() {
let pcap = synth_nexmon_pcap_bytes();
let frames = decode_nexmon_pcap(&pcap, "nexmon-pcap", 7, None).unwrap();
assert_eq!(frames.len(), 4);
for f in &frames {
assert!(f.is_exposable());
assert_eq!(f.adapter_kind, AdapterKind::Nexmon);
assert_eq!(f.channel, 36);
assert_eq!(f.bandwidth_mhz, 80);
assert_eq!(f.subcarrier_count, 256);
}
assert_eq!(frames[0].timestamp_ns, 1_000_000_000);
assert_eq!(frames[3].timestamp_ns, 1_000_000_000 + 3 * 50_000_000);
// explicit-port form works too
assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(5500)).unwrap().len(), 4);
assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(9999)).unwrap().len(), 0);
// --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);
}
}

View File

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

View File

@ -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<f32>`):
/// * `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<f32> {
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::<f32>().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<f32> {
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<f32> {
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<f32> = 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);
}
}

View File

@ -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<f32>,
}
/// On-disk shape of a baseline line.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BaselineLine {
room: String,
version: String,
embedding: Vec<f32>,
}
/// 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<RecordLine>,
#[serde(skip_serializing_if = "Option::is_none", default)]
baseline: Option<BaselineLine>,
}
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<File>,
index: RfIndex,
}
impl JsonlRfMemory {
/// Create a new, empty store at `path`, truncating any existing file.
pub fn create(path: impl AsRef<Path>) -> Result<Self, RvcsiError> {
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<Path>) -> Result<Self, RvcsiError> {
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<f32>,
) -> Result<EmbeddingId, RvcsiError> {
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<EmbeddingId, RvcsiError> {
self.append_record(
RecordKind::Window,
w.source_id.clone(),
w.start_ns,
window_embedding(w),
)
}
fn store_event(&mut self, e: &CsiEvent) -> Result<EmbeddingId, RvcsiError> {
self.append_record(
RecordKind::Event,
e.source_id.clone(),
e.timestamp_ns,
event_embedding(e),
)
}
fn query_similar(&self, query: &[f32], k: usize) -> Result<Vec<SimilarHit>, RvcsiError> {
Ok(self.index.query_similar(query, k))
}
fn set_baseline(
&mut self,
room: &str,
version: &str,
embedding: Vec<f32>,
) -> 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<Option<DriftReport>, 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());
}
}

View File

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

View File

@ -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<f32>,
}
/// 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<IndexRecord>,
/// room -> (version, embedding); the most recently set wins.
baselines: HashMap<String, (String, Vec<f32>)>,
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<f32>) {
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<SimilarHit> {
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<DriftReport> {
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<EmbeddingId, RvcsiError> {
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<EmbeddingId, RvcsiError> {
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<Vec<SimilarHit>, RvcsiError> {
Ok(self.index.query_similar(query, k))
}
fn set_baseline(
&mut self,
room: &str,
version: &str,
embedding: Vec<f32>,
) -> Result<(), RvcsiError> {
self.index.set_baseline(room, version, embedding);
Ok(())
}
fn compute_drift(
&self,
room: &str,
current: &[f32],
threshold: f32,
) -> Result<Option<DriftReport>, 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());
}
}

View File

@ -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<u64> 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<EmbeddingId, RvcsiError>;
/// Store the embedding of `e`, returning its newly-minted id.
fn store_event(&mut self, e: &CsiEvent) -> Result<EmbeddingId, RvcsiError>;
/// 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<Vec<SimilarHit>, RvcsiError>;
/// Set (or replace) the baseline embedding for `room` at `version`.
fn set_baseline(
&mut self,
room: &str,
version: &str,
embedding: Vec<f32>,
) -> 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<Option<DriftReport>, 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::<EmbeddingId>(&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::<SimilarHit>(&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::<DriftReport>(&json).unwrap(), d);
}
}