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:
commit
00304f9dc7
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*`
|
||||
crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/
|
||||
`-runtime`/`-node`/`-cli` — added inline in #542) now live in
|
||||
[`github.com/ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi): published to crates.io
|
||||
as `rvcsi-* 0.3.x`, to npm as `@ruv/rvcsi`, with a Claude Code plugin marketplace and
|
||||
a RuView-style README. RuView vendors it under `vendor/rvcsi` (alongside
|
||||
`vendor/ruvector` / `vendor/midstream` / `vendor/sublinear-time-solver`) and no longer
|
||||
carries inline copies in `v2/crates/`; consumers depend on the published crates (or the
|
||||
submodule's `crates/rvcsi-*` paths). `v2/Cargo.toml`, `CLAUDE.md`, and the README docs
|
||||
table updated accordingly. The ADRs (ADR-095, ADR-096), PRD, and DDD model stay in
|
||||
`docs/` here as the design record of the incubation.
|
||||
|
||||
### Fixed
|
||||
- **README: corrected the camera-supervised pose-accuracy claim.** The README stated
|
||||
"92.9% PCK@20" for camera-supervised training; that figure does not appear in
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -23,15 +23,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
|||
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `rvcsi-core` | rvCSI: normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` trait, `validate_frame` pipeline (ADR-095/096) |
|
||||
| `rvcsi-dsp` | rvCSI: reusable DSP stages (DC removal, phase unwrap, Hampel, smoothing, variance, baseline subtraction, motion/presence/breathing features, `SignalPipeline`) |
|
||||
| `rvcsi-events` | rvCSI: `WindowBuffer` + `EventDetector` state machines (presence/motion/quality/baseline-drift) + `EventPipeline` |
|
||||
| `rvcsi-adapter-file` | rvCSI: `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` (deterministic replay) |
|
||||
| `rvcsi-adapter-nexmon` | rvCSI: the **napi-c** seam — `native/rvcsi_nexmon_shim.{c,h}` (the only C; ABI 1.1; rvCSI-record + real nexmon_csi UDP + chanspec; `build.rs`+`cc`) + pure-Rust pcap reader + Nexmon-chip / Raspberry-Pi-model registry (incl. **Pi 5** = BCM43455c0) + `NexmonAdapter` / `NexmonPcapAdapter` (chip auto-detect) |
|
||||
| `rvcsi-ruvector` | rvCSI: deterministic RF-memory embeddings, `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` (RuVector standin) |
|
||||
| `rvcsi-runtime` | rvCSI: composition layer — `CaptureRuntime` (source + validate + DSP + events) + one-shot capture/nexmon-pcap helpers |
|
||||
| `rvcsi-node` | rvCSI: the **napi-rs** seam — `["cdylib","rlib"]` Node addon; ships the `@ruv/rvcsi` npm package |
|
||||
| `rvcsi-cli` | rvCSI: the `rvcsi` binary — record/inspect/inspect-nexmon/decode-chanspec/replay/stream/events/health/calibrate/export |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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, .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(ð, LINKTYPE_ETHERNET).is_none());
|
||||
// wrong ethertype
|
||||
let mut eth = eth_ip_udp(5500, &[0u8; 8]);
|
||||
eth[12] = 0x86;
|
||||
eth[13] = 0xdd; // IPv6
|
||||
assert!(extract_udp_payload(ð, LINKTYPE_ETHERNET).is_none());
|
||||
// unknown link type
|
||||
assert!(extract_udp_payload(ð, 9999).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 1–13, 2.4 GHz).
|
||||
pub fn esp32_default() -> Self {
|
||||
AdapterProfile {
|
||||
adapter_kind: AdapterKind::Esp32,
|
||||
chip: Some("ESP32-S3".to_string()),
|
||||
firmware_version: None,
|
||||
driver_version: None,
|
||||
supported_channels: (1..=13).collect(),
|
||||
supported_bandwidths_mhz: vec![20, 40],
|
||||
expected_subcarrier_counts: vec![64, 128, 192],
|
||||
supports_live_capture: true,
|
||||
supports_injection: false,
|
||||
supports_monitor_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A typical Nexmon (BCM43455c0) CSI profile: 802.11ac, 20/40/80 MHz.
|
||||
pub fn nexmon_default() -> Self {
|
||||
AdapterProfile {
|
||||
adapter_kind: AdapterKind::Nexmon,
|
||||
chip: Some("BCM43455c0".to_string()),
|
||||
firmware_version: None,
|
||||
driver_version: None,
|
||||
supported_channels: vec![1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161],
|
||||
supported_bandwidths_mhz: vec![20, 40, 80],
|
||||
expected_subcarrier_counts: vec![64, 128, 256],
|
||||
supports_live_capture: true,
|
||||
supports_injection: true,
|
||||
supports_monitor_mode: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if `count` is acceptable for this profile (always true when the
|
||||
/// expected list is empty, e.g. offline sources).
|
||||
pub fn accepts_subcarrier_count(&self, count: u16) -> bool {
|
||||
self.expected_subcarrier_counts.is_empty()
|
||||
|| self.expected_subcarrier_counts.contains(&count)
|
||||
}
|
||||
|
||||
/// `true` if `channel` is acceptable (always true when the list is empty).
|
||||
pub fn accepts_channel(&self, channel: u16) -> bool {
|
||||
self.supported_channels.is_empty() || self.supported_channels.contains(&channel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Health snapshot for a source (returned by [`CsiSource::health`] and the
|
||||
/// `rvcsi health` CLI / `rvcsi_health_report` MCP tool).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceHealth {
|
||||
/// `true` while the source is producing frames.
|
||||
pub connected: bool,
|
||||
/// Frames delivered since the session started.
|
||||
pub frames_delivered: u64,
|
||||
/// Frames rejected by validation since the session started.
|
||||
pub frames_rejected: u64,
|
||||
/// Optional human-readable status / last error.
|
||||
pub status: Option<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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.1–0.5 Hz band
|
||||
/// (6–30 bpm),
|
||||
/// 3. if there is a clear dominant peak — its normalized autocorrelation
|
||||
/// (peak / zero-lag) exceeds `~0.3` and it is a local maximum — return
|
||||
/// `Some(60 * sample_rate_hz / best_lag)`, otherwise `None`.
|
||||
///
|
||||
/// Returns `None` unless there are at least two full periods of data at the
|
||||
/// slowest band edge (so the caller need not pre-trim). This is **heuristic**
|
||||
/// and is meant to be quality-gated by the caller; do not treat the result as
|
||||
/// a medical-grade vital sign.
|
||||
pub fn breathing_band_estimate(amplitude_series: &[f32], sample_rate_hz: f32) -> Option<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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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(10–128), so an *absolute* anomaly_threshold of 1.0 fired on
|
||||
// essentially every window. With a *relative* threshold a few-percent
|
||||
// wobble around a large baseline must stay quiet.
|
||||
let g = IdGenerator::new();
|
||||
let mut d = BaselineDriftDetector::new(); // defaults: drift 0.15, anomaly 1.0
|
||||
// A realistic ESP32-ish window: two big "DC/pilot" subcarriers plus a
|
||||
// band of small data subcarriers; ±3 % jitter window to window.
|
||||
let base: Vec<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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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, &, 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, &, 0.9));
|
||||
}
|
||||
assert_eq!(p.recent_windows().len(), 1);
|
||||
assert_eq!(p.pending_frame_count(), 0);
|
||||
p.process_frame(&accepted_frame(16, 200_000, &, 0.9));
|
||||
assert_eq!(p.pending_frame_count(), 1);
|
||||
let leftover = p.flush();
|
||||
let _ = leftover;
|
||||
assert_eq!(p.recent_windows().len(), 2);
|
||||
assert_eq!(p.pending_frame_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_skips_foreign_frames() {
|
||||
let mut p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev"));
|
||||
let amp = vec![1.0f32; 8];
|
||||
let mut foreign = accepted_frame(0, 0, &, 0.9);
|
||||
foreign.session_id = SessionId(99);
|
||||
assert!(p.process_frame(&foreign).is_empty());
|
||||
assert_eq!(p.pending_frame_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detector_names_in_order() {
|
||||
let p = EventPipeline::with_defaults(SessionId(1), SourceId::from("dev"));
|
||||
assert_eq!(
|
||||
p.detector_names(),
|
||||
vec!["presence", "motion", "quality", "baseline_drift"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, &, 0.9), &g).is_none());
|
||||
assert!(buf.push(&frame(0, "s", 1, 10, &, 0.9), &g).is_none());
|
||||
assert!(buf.push(&frame(0, "s", 2, 20, &, 0.9), &g).is_none());
|
||||
assert_eq!(buf.pending_frame_count(), 3);
|
||||
let w = buf.push(&frame(0, "s", 3, 30, &, 0.9), &g).expect("window");
|
||||
assert_eq!(w.frame_count, 4);
|
||||
assert_eq!(buf.pending_frame_count(), 0);
|
||||
assert!(w.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_on_duration_with_fewer_frames() {
|
||||
let g = IdGenerator::new();
|
||||
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 100, 1_000);
|
||||
let amp = [1.0f32, 2.0];
|
||||
assert!(buf.push(&frame(0, "s", 0, 0, &, 0.8), &g).is_none());
|
||||
assert!(buf.push(&frame(0, "s", 1, 500, &, 0.8), &g).is_none());
|
||||
let w = buf
|
||||
.push(&frame(0, "s", 2, 1_000, &, 0.8), &g)
|
||||
.expect("window closed on duration");
|
||||
assert_eq!(w.frame_count, 3);
|
||||
assert_eq!(w.start_ns, 0);
|
||||
assert_eq!(w.end_ns, 1_000);
|
||||
assert!(w.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_returns_remainder_and_handles_single_frame() {
|
||||
let g = IdGenerator::new();
|
||||
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 10, u64::MAX);
|
||||
let amp = [1.0f32, 1.0];
|
||||
assert!(buf.push(&frame(0, "s", 0, 100, &, 0.7), &g).is_none());
|
||||
let w = buf.flush(&g).expect("flush returns the single buffered frame");
|
||||
assert_eq!(w.frame_count, 1);
|
||||
assert_eq!(w.start_ns, 100);
|
||||
assert_eq!(w.end_ns, 101); // bumped so validate() passes
|
||||
assert_eq!(w.motion_energy, 0.0);
|
||||
assert!(w.validate().is_ok());
|
||||
assert!(buf.flush(&g).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_mismatched_session_and_source() {
|
||||
let g = IdGenerator::new();
|
||||
let mut buf = WindowBuffer::new(SessionId(7), SourceId::from("good"), 4, u64::MAX);
|
||||
let amp = [1.0f32, 1.0];
|
||||
assert!(buf.push(&frame(7, "good", 0, 0, &, 0.9), &g).is_none());
|
||||
// Wrong session.
|
||||
assert!(buf.push(&frame(8, "good", 1, 10, &, 0.9), &g).is_none());
|
||||
// Wrong source.
|
||||
assert!(buf.push(&frame(7, "bad", 2, 20, &, 0.9), &g).is_none());
|
||||
assert_eq!(buf.pending_frame_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_exposable_and_mismatched_subcarrier_count() {
|
||||
let g = IdGenerator::new();
|
||||
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 4, u64::MAX);
|
||||
// Non-exposable frame is dropped.
|
||||
let mut bad = frame(0, "s", 0, 0, &[1.0, 1.0], 0.9);
|
||||
bad.validation = ValidationStatus::Pending;
|
||||
assert!(buf.push(&bad, &g).is_none());
|
||||
assert_eq!(buf.pending_frame_count(), 0);
|
||||
// First good frame fixes subcarrier count = 2.
|
||||
assert!(buf.push(&frame(0, "s", 1, 10, &[1.0, 1.0], 0.9), &g).is_none());
|
||||
// Different subcarrier count is dropped.
|
||||
assert!(buf
|
||||
.push(&frame(0, "s", 2, 20, &[1.0, 1.0, 1.0], 0.9), &g)
|
||||
.is_none());
|
||||
assert_eq!(buf.pending_frame_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_frames_have_zero_motion_low_presence() {
|
||||
let g = IdGenerator::new();
|
||||
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 8, u64::MAX);
|
||||
let amp = [1.0f32; 32];
|
||||
let mut last = None;
|
||||
for k in 0..8u64 {
|
||||
last = buf.push(&frame(0, "s", k, k * 10, &, 0.9), &g);
|
||||
}
|
||||
let w = last.expect("window");
|
||||
assert_eq!(w.motion_energy, 0.0);
|
||||
assert!(w.presence_score < 0.5, "presence_score = {}", w.presence_score);
|
||||
assert!(w.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_jitter_raises_motion_and_presence() {
|
||||
let g = IdGenerator::new();
|
||||
let mut buf = WindowBuffer::new(SessionId(0), SourceId::from("s"), 16, u64::MAX);
|
||||
// Large alternating jitter -> high motion energy.
|
||||
let mut last = None;
|
||||
for k in 0..16u64 {
|
||||
let bump = if k % 2 == 0 { 0.0 } else { 1.0 };
|
||||
let amp: Vec<f32> = (0..32).map(|_| 1.0 + bump).collect();
|
||||
last = buf.push(&frame(0, "s", k, k * 10, &, 0.9), &g);
|
||||
}
|
||||
let w = last.expect("window");
|
||||
assert!(w.motion_energy > 0.1, "motion_energy = {}", w.motion_energy);
|
||||
assert!(w.presence_score > 0.5, "presence_score = {}", w.presence_score);
|
||||
assert!(w.validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue