feat(rvcsi): rvcsi-runtime composition + rvcsi-node (napi-rs) + rvcsi-cli + @ruv/rvcsi TS SDK
- rvcsi-runtime — the composition layer (no FFI): CaptureRuntime (CsiSource + validate_frame + SignalPipeline + EventPipeline, with next_validated_frame / next_clean_frame / drain_events / health) plus one-shot helpers (summarize_capture → CaptureSummary, decode_nexmon_records, events_from_capture, export_capture_to_rf_memory, rf_memory_self_check). 10 tests. - rvcsi-node — the napi-rs seam (cdylib+rlib, build.rs runs napi_build::setup): thin #[napi] wrappers over rvcsi-runtime — rvcsiVersion / nexmonShimAbiVersion / nexmonDecodeRecords / inspectCaptureFile / eventsFromCaptureFile / exportCaptureToRfMemory + an RvcsiRuntime streaming class. Everything that crosses the boundary is a validated/normalized rvCSI struct serialized to JSON (D6). deny(clippy::all). - @ruv/rvcsi npm package (package.json + index.js + index.d.ts + README + __test__/api.test.cjs) — curated JS surface that JSON-parses the addon's output into plain CsiFrame/CsiWindow/CsiEvent/SourceHealth/CaptureSummary objects; lazy native-addon load with a helpful "not built" error. - rvcsi-cli — the `rvcsi` binary: record (Nexmon dump → .rvcsi, validating), inspect, replay, stream, events, health, calibrate (v0 baseline), export ruvector. 7 tests exercising every subcommand against in-memory captures. - rvcsi-cli no longer depends on rvcsi-node (a binary can't link the napi addon); the shared logic moved to rvcsi-runtime. .gitignore: ignore the generated *.node / binding.js / binding.d.ts / npm/ under rvcsi-node. All rvcsi crates: build together OK, clippy-clean, 140 unit/integration tests + 2 doctests, 0 failures (core 29, dsp 28, events 18, adapter-file 20+1, adapter-nexmon 9, ruvector 20+1, runtime 10, cli 7). https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
This commit is contained in:
parent
6432dfbd2d
commit
7393cc2b73
|
|
@ -252,3 +252,9 @@ firmware/esp32-csi-node/build_firmware.batdata/
|
||||||
models/
|
models/
|
||||||
demo_pointcloud.ply
|
demo_pointcloud.ply
|
||||||
demo_splats.json
|
demo_splats.json
|
||||||
|
|
||||||
|
# rvCSI napi-rs addon — generated by `napi build` (do not commit)
|
||||||
|
v2/crates/rvcsi-node/*.node
|
||||||
|
v2/crates/rvcsi-node/binding.js
|
||||||
|
v2/crates/rvcsi-node/binding.d.ts
|
||||||
|
v2/crates/rvcsi-node/npm/
|
||||||
|
|
|
||||||
|
|
@ -5984,11 +5984,10 @@ dependencies = [
|
||||||
"rvcsi-adapter-file",
|
"rvcsi-adapter-file",
|
||||||
"rvcsi-adapter-nexmon",
|
"rvcsi-adapter-nexmon",
|
||||||
"rvcsi-core",
|
"rvcsi-core",
|
||||||
"rvcsi-dsp",
|
"rvcsi-runtime",
|
||||||
"rvcsi-events",
|
|
||||||
"rvcsi-ruvector",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -6027,6 +6026,17 @@ dependencies = [
|
||||||
"napi",
|
"napi",
|
||||||
"napi-build",
|
"napi-build",
|
||||||
"napi-derive",
|
"napi-derive",
|
||||||
|
"rvcsi-core",
|
||||||
|
"rvcsi-runtime",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rvcsi-runtime"
|
||||||
|
version = "0.3.0"
|
||||||
|
dependencies = [
|
||||||
"rvcsi-adapter-file",
|
"rvcsi-adapter-file",
|
||||||
"rvcsi-adapter-nexmon",
|
"rvcsi-adapter-nexmon",
|
||||||
"rvcsi-core",
|
"rvcsi-core",
|
||||||
|
|
@ -6035,6 +6045,7 @@ dependencies = [
|
||||||
"rvcsi-ruvector",
|
"rvcsi-ruvector",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ members = [
|
||||||
"crates/rvcsi-adapter-file",
|
"crates/rvcsi-adapter-file",
|
||||||
"crates/rvcsi-adapter-nexmon",
|
"crates/rvcsi-adapter-nexmon",
|
||||||
"crates/rvcsi-ruvector",
|
"crates/rvcsi-ruvector",
|
||||||
|
"crates/rvcsi-runtime",
|
||||||
"crates/rvcsi-node",
|
"crates/rvcsi-node",
|
||||||
"crates/rvcsi-cli",
|
"crates/rvcsi-cli",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "rvCSI command-line tool — inspect, replay, stream, calibrate, health, export (ADR-095 FR7)"
|
description = "rvCSI command-line tool — inspect, replay, stream, events, health, calibrate, export (ADR-095 FR7)"
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
keywords = ["wifi", "csi", "cli", "rvcsi"]
|
keywords = ["wifi", "csi", "cli", "rvcsi"]
|
||||||
categories = ["science", "command-line-utilities"]
|
categories = ["science", "command-line-utilities"]
|
||||||
|
|
@ -15,12 +15,13 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rvcsi-core = { path = "../rvcsi-core" }
|
rvcsi-core = { path = "../rvcsi-core" }
|
||||||
rvcsi-dsp = { path = "../rvcsi-dsp" }
|
|
||||||
rvcsi-events = { path = "../rvcsi-events" }
|
|
||||||
rvcsi-adapter-file = { path = "../rvcsi-adapter-file" }
|
rvcsi-adapter-file = { path = "../rvcsi-adapter-file" }
|
||||||
rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" }
|
rvcsi-adapter-nexmon = { path = "../rvcsi-adapter-nexmon" }
|
||||||
rvcsi-ruvector = { path = "../rvcsi-ruvector" }
|
rvcsi-runtime = { path = "../rvcsi-runtime" }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.10"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
//! 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 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)?;
|
||||||
|
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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
//! `rvcsi` — the rvCSI command-line tool (skeleton; completed during integration).
|
//! `rvcsi` — the rvCSI command-line tool (ADR-095 FR7).
|
||||||
//!
|
//!
|
||||||
//! Subcommands (ADR-095 FR7): `inspect`, `replay`, `stream`, `calibrate`,
|
//! Subcommands: `inspect`, `replay`, `stream`, `events`, `health`, `calibrate`,
|
||||||
//! `health`, `export`. The skeleton wires `inspect` so the binary is usable;
|
//! `export`. Long-running capture / WebSocket streaming live in the (not-yet-
|
||||||
//! the rest are filled in by the CLI swarm agent / integration step.
|
//! shipped) `rvcsi-daemon`; this CLI works against `.rvcsi` capture files and
|
||||||
|
//! Nexmon record dumps.
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
mod commands;
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "rvcsi", version, about = "rvCSI — edge RF sensing runtime CLI")]
|
#[command(name = "rvcsi", version, about = "rvCSI — edge RF sensing runtime CLI", long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
|
|
@ -15,19 +20,138 @@ struct Cli {
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Print a summary of a capture file (frame count, channels, quality).
|
/// Transcode a Nexmon record dump into a `.rvcsi` capture (validating each frame).
|
||||||
|
Record {
|
||||||
|
/// Path to a buffer of "rvCSI Nexmon records" (the napi-c shim format).
|
||||||
|
#[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,
|
||||||
|
},
|
||||||
|
/// Summarize a `.rvcsi` capture file (frame count, channels, quality, ...).
|
||||||
Inspect {
|
Inspect {
|
||||||
/// Path to a `.rvcsi` capture file.
|
/// Path to a `.rvcsi` capture file.
|
||||||
path: String,
|
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<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut out = stdout.lock();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Inspect { path } => {
|
Command::Record { input, output, source_id, session } => {
|
||||||
println!("rvcsi inspect: {path} (not yet implemented in the skeleton)");
|
commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)?
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,12 @@ crate-type = ["cdylib", "rlib"]
|
||||||
napi = { workspace = true }
|
napi = { workspace = true }
|
||||||
napi-derive = { workspace = true }
|
napi-derive = { workspace = true }
|
||||||
rvcsi-core = { path = "../rvcsi-core" }
|
rvcsi-core = { path = "../rvcsi-core" }
|
||||||
rvcsi-dsp = { path = "../rvcsi-dsp" }
|
rvcsi-runtime = { path = "../rvcsi-runtime" }
|
||||||
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 = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
napi-build = { workspace = true }
|
napi-build = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.10"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# @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`.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
'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',
|
||||||
|
'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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact summary of a `.rvcsi` capture file. */
|
||||||
|
export interface CaptureSummary {
|
||||||
|
capture_version: number;
|
||||||
|
session_id: number;
|
||||||
|
source_id: string;
|
||||||
|
adapter_kind: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
'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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
inspectCaptureFile,
|
||||||
|
eventsFromCaptureFile,
|
||||||
|
exportCaptureToRfMemory,
|
||||||
|
RvCsi,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"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,14 +1,55 @@
|
||||||
//! # rvCSI Node.js bindings — napi-rs (skeleton; completed during integration)
|
//! # rvCSI Node.js bindings — napi-rs (ADR-095 D3/D4, ADR-096)
|
||||||
//!
|
//!
|
||||||
//! The safe TypeScript-facing surface over the rvCSI Rust runtime
|
//! The safe TypeScript-facing surface over the rvCSI Rust runtime. Nothing here
|
||||||
//! (ADR-095 D3/D4, ADR-096). Nothing here exposes raw pointers; every value
|
//! exposes raw pointers; every value that crosses the boundary is either a
|
||||||
//! that crosses the boundary is a validated/normalized struct or a JSON string.
|
//! 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)]
|
#![deny(clippy::all)]
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate napi_derive;
|
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).
|
/// rvCSI runtime version (the workspace crate version).
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn rvcsi_version() -> String {
|
pub fn rvcsi_version() -> String {
|
||||||
|
|
@ -18,5 +59,108 @@ pub fn rvcsi_version() -> String {
|
||||||
/// ABI version of the linked napi-c Nexmon shim (`major << 16 | minor`).
|
/// ABI version of the linked napi-c Nexmon shim (`major << 16 | minor`).
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn nexmon_shim_abi_version() -> u32 {
|
pub fn nexmon_shim_abi_version() -> u32 {
|
||||||
rvcsi_adapter_nexmon::shim_abi_version()
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
[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"
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
//! 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
//! # 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_records, events_from_capture, export_capture_to_rf_memory, rf_memory_self_check,
|
||||||
|
summarize_capture, CaptureSummary, 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()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,354 @@
|
||||||
|
//! 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,
|
||||||
|
/// 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(),
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into
|
||||||
|
/// validated [`CsiFrame`]s. Each frame is run through [`validate_frame`] against
|
||||||
|
/// a permissive profile (so synthetic / non-default subcarrier counts survive);
|
||||||
|
/// frames that hard-fail validation are dropped (never returned to JS).
|
||||||
|
pub fn decode_nexmon_records(
|
||||||
|
bytes: &[u8],
|
||||||
|
source_id: &str,
|
||||||
|
session_id: u64,
|
||||||
|
) -> Result<Vec<CsiFrame>, RvcsiError> {
|
||||||
|
let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?;
|
||||||
|
let profile = AdapterProfile::offline(rvcsi_core::AdapterKind::Nexmon);
|
||||||
|
let policy = ValidationPolicy::default();
|
||||||
|
let mut out = Vec::with_capacity(raw.len());
|
||||||
|
let mut prev_ts: Option<u64> = None;
|
||||||
|
for mut f in raw {
|
||||||
|
let ts = f.timestamp_ns;
|
||||||
|
match validate_frame(&mut f, &profile, &policy, prev_ts) {
|
||||||
|
Ok(()) => {
|
||||||
|
if f.is_exposable() {
|
||||||
|
prev_ts = Some(ts);
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => { /* hard-rejected — dropped, not returned to JS */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue