From 7393cc2b73c983cc40f2eed4fa837d3f075eafb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 00:17:45 +0000 Subject: [PATCH] feat(rvcsi): rvcsi-runtime composition + rvcsi-node (napi-rs) + rvcsi-cli + @ruv/rvcsi TS SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 6 + v2/Cargo.lock | 17 +- v2/Cargo.toml | 1 + v2/crates/rvcsi-cli/Cargo.toml | 9 +- v2/crates/rvcsi-cli/src/commands.rs | 407 +++++++++++++++++++++ v2/crates/rvcsi-cli/src/main.rs | 144 +++++++- v2/crates/rvcsi-node/Cargo.toml | 9 +- v2/crates/rvcsi-node/README.md | 64 ++++ v2/crates/rvcsi-node/__test__/api.test.cjs | 41 +++ v2/crates/rvcsi-node/index.d.ts | 171 +++++++++ v2/crates/rvcsi-node/index.js | 156 ++++++++ v2/crates/rvcsi-node/package.json | 35 ++ v2/crates/rvcsi-node/src/lib.rs | 154 +++++++- v2/crates/rvcsi-runtime/Cargo.toml | 23 ++ v2/crates/rvcsi-runtime/src/capture.rs | 265 ++++++++++++++ v2/crates/rvcsi-runtime/src/lib.rs | 31 ++ v2/crates/rvcsi-runtime/src/summary.rs | 354 ++++++++++++++++++ 17 files changed, 1860 insertions(+), 27 deletions(-) create mode 100644 v2/crates/rvcsi-cli/src/commands.rs create mode 100644 v2/crates/rvcsi-node/README.md create mode 100644 v2/crates/rvcsi-node/__test__/api.test.cjs create mode 100644 v2/crates/rvcsi-node/index.d.ts create mode 100644 v2/crates/rvcsi-node/index.js create mode 100644 v2/crates/rvcsi-node/package.json create mode 100644 v2/crates/rvcsi-runtime/Cargo.toml create mode 100644 v2/crates/rvcsi-runtime/src/capture.rs create mode 100644 v2/crates/rvcsi-runtime/src/lib.rs create mode 100644 v2/crates/rvcsi-runtime/src/summary.rs diff --git a/.gitignore b/.gitignore index 9caaea62..0aae0374 100644 --- a/.gitignore +++ b/.gitignore @@ -252,3 +252,9 @@ firmware/esp32-csi-node/build_firmware.batdata/ models/ demo_pointcloud.ply 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/ diff --git a/v2/Cargo.lock b/v2/Cargo.lock index b5cb1d31..bcb3fcbf 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -5984,11 +5984,10 @@ dependencies = [ "rvcsi-adapter-file", "rvcsi-adapter-nexmon", "rvcsi-core", - "rvcsi-dsp", - "rvcsi-events", - "rvcsi-ruvector", + "rvcsi-runtime", "serde", "serde_json", + "tempfile", ] [[package]] @@ -6027,6 +6026,17 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "rvcsi-core", + "rvcsi-runtime", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "rvcsi-runtime" +version = "0.3.0" +dependencies = [ "rvcsi-adapter-file", "rvcsi-adapter-nexmon", "rvcsi-core", @@ -6035,6 +6045,7 @@ dependencies = [ "rvcsi-ruvector", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 787aef08..6ce3e3b1 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/rvcsi-adapter-file", "crates/rvcsi-adapter-nexmon", "crates/rvcsi-ruvector", + "crates/rvcsi-runtime", "crates/rvcsi-node", "crates/rvcsi-cli", ] diff --git a/v2/crates/rvcsi-cli/Cargo.toml b/v2/crates/rvcsi-cli/Cargo.toml index 5400d6ef..2607b2b3 100644 --- a/v2/crates/rvcsi-cli/Cargo.toml +++ b/v2/crates/rvcsi-cli/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true authors.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 keywords = ["wifi", "csi", "cli", "rvcsi"] categories = ["science", "command-line-utilities"] @@ -15,12 +15,13 @@ path = "src/main.rs" [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" } +rvcsi-runtime = { path = "../rvcsi-runtime" } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } + +[dev-dependencies] +tempfile = "3.10" diff --git a/v2/crates/rvcsi-cli/src/commands.rs b/v2/crates/rvcsi-cli/src/commands.rs new file mode 100644 index 00000000..afcad905 --- /dev/null +++ b/v2/crates/rvcsi-cli/src/commands.rs @@ -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 --out ` — transcode a buffer of +/// "rvCSI Nexmon records" (the napi-c shim format) into a `.rvcsi` capture file, +/// validating each frame on the way in. This gives the CLI a way to produce +/// `.rvcsi` files without a live radio (which needs the not-yet-shipped daemon). +pub fn record_from_nexmon( + out: &mut dyn Write, + nexmon_path: &str, + out_path: &str, + source_id: &str, + session_id: u64, +) -> Result<()> { + let bytes = std::fs::read(nexmon_path).with_context(|| format!("reading {nexmon_path}"))?; + let mut src = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes); + let profile = AdapterProfile::offline(AdapterKind::Nexmon); + let policy = ValidationPolicy::default(); + let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile.clone()); + let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?; + let (mut written, mut skipped, mut prev_ts) = (0u64, 0u64, None); + loop { + match src.next_frame() { + Ok(None) => break, + Ok(Some(mut f)) => { + let ts = f.timestamp_ns; + match validate_frame(&mut f, &profile, &policy, prev_ts) { + Ok(()) if f.is_exposable() => { + prev_ts = Some(ts); + rec.write_frame(&f)?; + written += 1; + } + _ => skipped += 1, + } + } + Err(e) => { + writeln!(out, "warning: stopped at a malformed Nexmon record: {e}")?; + break; + } + } + } + rec.finish()?; + writeln!(out, "recorded {written} frame(s) to {out_path} ({skipped} dropped by validation)")?; + Ok(()) +} + +/// `rvcsi inspect ` — print a summary of a `.rvcsi` capture file. +pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> { + let summary = runtime::summarize_capture(path).with_context(|| format!("inspecting {path}"))?; + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&summary)?)?; + return Ok(()); + } + writeln!(out, "capture : {path}")?; + writeln!(out, " version : {}", summary.capture_version)?; + writeln!(out, " session : {}", summary.session_id)?; + writeln!(out, " source : {}", summary.source_id)?; + writeln!(out, " adapter : {}", summary.adapter_kind)?; + writeln!(out, " frames : {}", summary.frame_count)?; + writeln!( + out, + " time span : {} .. {} ns ({} ns)", + summary.first_timestamp_ns, + summary.last_timestamp_ns, + summary.last_timestamp_ns.saturating_sub(summary.first_timestamp_ns) + )?; + writeln!(out, " channels : {:?}", summary.channels)?; + writeln!(out, " subcarriers : {:?}", summary.subcarrier_counts)?; + writeln!(out, " mean quality : {:.3}", summary.mean_quality)?; + let b = summary.validation_breakdown; + writeln!( + out, + " validation : accepted={} degraded={} recovered={} rejected={} pending={}", + b.accepted, b.degraded, b.recovered, b.rejected, b.pending + )?; + writeln!(out, " calibration : {}", summary.calibration_version.as_deref().unwrap_or("(none)"))?; + Ok(()) +} + +/// `rvcsi replay ` / `rvcsi stream --in --format json` — emit one +/// line per frame. With `json`, the full `CsiFrame` JSON; otherwise a compact +/// `frame_id ts ch rssi quality validation` line. `limit` caps the count +/// (`None` = all). `speed` is accepted but not enforced here (the daemon paces +/// real-time replay); a non-1.0 value is noted on stderr by the caller. +pub fn replay(out: &mut dyn Write, path: &str, json: bool, limit: Option) -> Result<()> { + let mut adapter = FileReplayAdapter::open(path).with_context(|| format!("opening {path}"))?; + let mut n = 0usize; + while let Some(frame) = adapter.next_frame()? { + if json { + writeln!(out, "{}", serde_json::to_string(&frame)?)?; + } else { + writeln!( + out, + "{:>8} {:>16} ch{:<3} rssi={:>5} q={:.3} {:?}", + frame.frame_id.value(), + frame.timestamp_ns, + frame.channel, + frame.rssi_dbm.map(|r| r.to_string()).unwrap_or_else(|| "-".into()), + frame.quality_score, + frame.validation, + )?; + } + n += 1; + if let Some(lim) = limit { + if n >= lim { + break; + } + } + } + if !json { + writeln!(out, "-- {n} frame(s)")?; + } + Ok(()) +} + +/// `rvcsi events ` — replay the capture through DSP + the event pipeline +/// and print the emitted events (compact, or full JSON with `json`). +pub fn events(out: &mut dyn Write, path: &str, json: bool) -> Result<()> { + let evs = runtime::events_from_capture(path).with_context(|| format!("processing {path}"))?; + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&evs)?)?; + return Ok(()); + } + for e in &evs { + writeln!( + out, + "{:>16} ns {:<22} conf={:.3} evidence={:?}{}", + e.timestamp_ns, + e.kind.slug(), + e.confidence, + e.evidence_window_ids.iter().map(|w| w.value()).collect::>(), + e.calibration_version.as_deref().map(|c| format!(" calib={c}")).unwrap_or_default(), + )?; + } + writeln!(out, "-- {} event(s)", evs.len())?; + Ok(()) +} + +/// `rvcsi health --source [--target ]` — open the source, drain it, +/// and print the final `SourceHealth` as JSON. File and Nexmon sources work +/// offline; live radios are not available in this build. +pub fn health(out: &mut dyn Write, source: &str, target: Option<&str>) -> Result<()> { + let h = match source { + "file" | "replay" => { + let path = target.context("`--target ` is required for the file source")?; + let mut a = FileReplayAdapter::open(path)?; + while a.next_frame()?.is_some() {} + a.health() + } + "nexmon" => { + let path = target.context("`--target ` is required for the nexmon source")?; + let bytes = std::fs::read(path)?; + let mut a = NexmonAdapter::from_bytes(SourceId::from("nexmon"), SessionId(0), bytes); + // pull until exhausted or a malformed record stops us + while let Ok(Some(_)) = a.next_frame() {} + a.health() + } + "esp32" | "intel" | "atheros" => { + anyhow::bail!("live capture for source `{source}` is not available in this build; use the `rvcsi-daemon` (not yet shipped) or replay a `.rvcsi` capture"); + } + other => anyhow::bail!("unknown source `{other}` (expected: file, replay, nexmon, esp32, intel, atheros)"), + }; + writeln!(out, "{}", serde_json::to_string_pretty(&h)?)?; + Ok(()) +} + +/// `rvcsi export ruvector --in --out ` — window the capture and +/// store each window's embedding into a JSONL RF-memory file. +pub fn export_ruvector(out: &mut dyn Write, capture: &str, out_jsonl: &str) -> Result<()> { + let stored = runtime::export_capture_to_rf_memory(capture, out_jsonl) + .with_context(|| format!("exporting {capture} -> {out_jsonl}"))?; + writeln!(out, "stored {stored} window embedding(s) to {out_jsonl}")?; + Ok(()) +} + +/// `rvcsi calibrate --in [--out ]` — a v0 calibration: +/// learn the per-subcarrier mean amplitude (the "baseline") over all exposable +/// frames in a capture and emit it as JSON. Real, versioned, room-scoped +/// calibration (ADR-095 D14) lands with the daemon. +pub fn calibrate(out: &mut dyn Write, capture: &str, out_path: Option<&str>) -> Result<()> { + let (header, frames) = read_all(capture).with_context(|| format!("reading {capture}"))?; + let exposable: Vec<&CsiFrame> = frames.iter().filter(|f| f.is_exposable()).collect(); + if exposable.is_empty() { + anyhow::bail!("no exposable frames in {capture} — cannot calibrate"); + } + let n = exposable[0].subcarrier_count as usize; + let mut acc = vec![0.0f64; n]; + let mut count = 0usize; + for f in &exposable { + if f.subcarrier_count as usize != n { + continue; + } + for (a, v) in acc.iter_mut().zip(f.amplitude.iter()) { + *a += *v as f64; + } + count += 1; + } + let baseline: Vec = acc.iter().map(|a| (*a / count.max(1) as f64) as f32).collect(); + #[derive(serde::Serialize)] + struct Baseline<'a> { + source_id: &'a str, + session_id: u64, + version: String, + subcarrier_count: usize, + frames_used: usize, + baseline_amplitude: Vec, + } + let payload = Baseline { + source_id: header.source_id.as_str(), + session_id: header.session_id.value(), + version: format!("{}@auto-{count}", header.source_id.as_str()), + subcarrier_count: n, + frames_used: count, + baseline_amplitude: baseline, + }; + let json = serde_json::to_string_pretty(&payload)?; + if let Some(p) = out_path { + std::fs::write(p, &json)?; + writeln!(out, "wrote baseline ({n} subcarriers, {count} frames) to {p}")?; + } else { + writeln!(out, "{json}")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rvcsi_adapter_nexmon::{encode_record, NexmonRecord}; + use rvcsi_core::{FrameId, ValidationStatus}; + + fn write_capture(path: &std::path::Path, n: usize) { + let header = CaptureHeader::new( + SessionId(2), + SourceId::from("cli-it"), + AdapterProfile::offline(AdapterKind::File), + ); + let mut rec = FileRecorder::create(path, &header).unwrap(); + for k in 0..n { + let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 }; + let i: Vec = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect(); + let q: Vec = (0..32).map(|_| 0.5).collect(); + let mut f = CsiFrame::from_iq( + FrameId(k as u64), + SessionId(2), + SourceId::from("cli-it"), + AdapterKind::File, + 1_000 + k as u64 * 50_000_000, + 6, + 20, + i, + q, + ) + .with_rssi(-55); + f.validation = ValidationStatus::Accepted; + f.quality_score = 0.9; + rec.write_frame(&f).unwrap(); + } + rec.finish().unwrap(); + } + + fn run) -> Result<()>>(f: F) -> String { + let mut buf = Vec::new(); + f(&mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn inspect_human_and_json() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 12); + let p = tmp.path().to_str().unwrap(); + let human = run(|o| inspect(o, p, false)); + assert!(human.contains("frames : 12")); + assert!(human.contains("channels : [6]")); + let json = run(|o| inspect(o, p, true)); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["frame_count"], 12); + } + + #[test] + fn replay_compact_and_json_and_limit() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 5); + let p = tmp.path().to_str().unwrap(); + let compact = run(|o| replay(o, p, false, None)); + assert!(compact.contains("-- 5 frame(s)")); + let json = run(|o| replay(o, p, true, Some(3))); + assert_eq!(json.lines().count(), 3); + for line in json.lines() { + let _: CsiFrame = serde_json::from_str(line).unwrap(); + } + } + + #[test] + fn events_command_emits_something() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 64); + let p = tmp.path().to_str().unwrap(); + let out = run(|o| events(o, p, false)); + assert!(out.contains("event(s)")); + let json = run(|o| events(o, p, true)); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(v.is_array()); + } + + #[test] + fn health_file_source() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 7); + let p = tmp.path().to_str().unwrap(); + let out = run(|o| health(o, "file", Some(p))); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["frames_delivered"], 7); + assert_eq!(v["connected"], false); + // unknown / live sources error cleanly + let mut buf = Vec::new(); + assert!(health(&mut buf, "esp32", Some(p)).is_err()); + assert!(health(&mut buf, "bogus", None).is_err()); + assert!(health(&mut buf, "file", None).is_err()); // missing --target + } + + #[test] + fn export_and_calibrate() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 64); + let p = tmp.path().to_str().unwrap(); + let out_jsonl = tempfile::NamedTempFile::new().unwrap(); + let out = run(|o| export_ruvector(o, p, out_jsonl.path().to_str().unwrap())); + assert!(out.contains("stored ")); + // calibrate to stdout + let calib = run(|o| calibrate(o, p, None)); + let v: serde_json::Value = serde_json::from_str(&calib).unwrap(); + assert_eq!(v["subcarrier_count"], 32); + assert!(v["baseline_amplitude"].as_array().unwrap().len() == 32); + // calibrate to file + let baseline_file = tempfile::NamedTempFile::new().unwrap(); + let out2 = run(|o| calibrate(o, p, Some(baseline_file.path().to_str().unwrap()))); + assert!(out2.contains("wrote baseline")); + let written = std::fs::read_to_string(baseline_file.path()).unwrap(); + assert!(written.contains("baseline_amplitude")); + } + + #[test] + fn record_from_nexmon_then_inspect_and_replay() { + // build a small Nexmon record dump (64-subcarrier, the default profile) + let mut dump = Vec::new(); + for k in 0..6u64 { + let rec = NexmonRecord { + subcarrier_count: 64, + channel: 36, + bandwidth_mhz: 80, + rssi_dbm: Some(-60 - k as i16), + noise_floor_dbm: Some(-92), + timestamp_ns: 1_000 + k * 50_000_000, + i_values: (0..64).map(|s| (s as f32 % 3.0) - 1.0).collect(), + q_values: (0..64).map(|s| (s as f32 % 5.0) * 0.1).collect(), + }; + dump.extend(encode_record(&rec).unwrap()); + } + let dump_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(dump_file.path(), &dump).unwrap(); + let cap_file = tempfile::NamedTempFile::new().unwrap(); + + let out = run(|o| { + record_from_nexmon( + o, + dump_file.path().to_str().unwrap(), + cap_file.path().to_str().unwrap(), + "nexmon-rec", + 3, + ) + }); + assert!(out.contains("recorded 6 frame(s)"), "{out}"); + + // the produced capture is a real .rvcsi the other commands can read + let summary = run(|o| inspect(o, cap_file.path().to_str().unwrap(), false)); + assert!(summary.contains("frames : 6")); + assert!(summary.contains("source : nexmon-rec")); + let replayed = run(|o| replay(o, cap_file.path().to_str().unwrap(), false, None)); + assert!(replayed.contains("-- 6 frame(s)")); + } + + #[test] + fn 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()); + } +} diff --git a/v2/crates/rvcsi-cli/src/main.rs b/v2/crates/rvcsi-cli/src/main.rs index 4ded0e1c..de8ecd69 100644 --- a/v2/crates/rvcsi-cli/src/main.rs +++ b/v2/crates/rvcsi-cli/src/main.rs @@ -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`, -//! `health`, `export`. The skeleton wires `inspect` so the binary is usable; -//! the rest are filled in by the CLI swarm agent / integration step. +//! 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. -use clap::{Parser, Subcommand}; +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")] +#[command(name = "rvcsi", version, about = "rvCSI — edge RF sensing runtime CLI", long_about = None)] struct Cli { #[command(subcommand)] command: Command, @@ -15,19 +20,138 @@ struct Cli { #[derive(Subcommand)] 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 { /// Path to a `.rvcsi` capture file. path: String, + /// Emit machine-readable JSON instead of a human summary. + #[arg(long)] + json: bool, }, + /// Replay a `.rvcsi` capture, emitting one line per frame. + Replay { + /// Path to a `.rvcsi` capture file. + path: String, + /// Emit each frame as a full JSON object instead of a compact line. + #[arg(long)] + json: bool, + /// Stop after this many frames. + #[arg(long)] + limit: Option, + /// Real-time pacing multiplier. Accepted for compatibility but not + /// enforced by the CLI (the `rvcsi-daemon` paces real-time replay); + /// a value other than `1.0` is noted on stderr. + #[arg(long, default_value_t = 1.0)] + speed: f32, + }, + /// Stream frames from a source to stdout as JSON lines (a v0 stand-in for + /// the daemon's WebSocket output). Currently supports `.rvcsi` files via `--in`. + Stream { + /// Path to a `.rvcsi` capture file to stream. + #[arg(long = "in")] + input: String, + /// Output format (only `json` is supported in this build). + #[arg(long, default_value = "json")] + format: String, + /// WebSocket port. Accepted but not served by the CLI — needs `rvcsi-daemon`. + #[arg(long)] + port: Option, + }, + /// Replay a capture through the DSP + event pipeline and print the events. + Events { + /// Path to a `.rvcsi` capture file. + path: String, + /// Emit events as JSON instead of compact lines. + #[arg(long)] + json: bool, + }, + /// Open a source, drain it, and print its `SourceHealth` as JSON. + Health { + /// Source slug: `file`, `replay`, `nexmon` (offline); `esp32`/`intel`/`atheros` need the daemon. + #[arg(long)] + source: String, + /// Path / interface for the source (required for `file`/`replay`/`nexmon`). + #[arg(long)] + target: Option, + }, + /// Learn a v0 baseline (per-subcarrier mean amplitude) from a capture. + Calibrate { + /// Path to a `.rvcsi` capture file. + #[arg(long = "in")] + input: String, + /// Write the baseline JSON here instead of stdout. + #[arg(long = "out")] + output: Option, + }, + /// Export data derived from a capture. + Export { + #[command(subcommand)] + target: ExportTarget, + }, +} + +#[derive(Subcommand)] +enum ExportTarget { + /// Window a capture and store each window's embedding into a JSONL RF-memory file. + Ruvector(ExportRuvector), +} + +#[derive(Args)] +struct ExportRuvector { + /// Path to a `.rvcsi` capture file. + #[arg(long = "in")] + input: String, + /// Path to the output JSONL RF-memory file. + #[arg(long = "out")] + output: String, } fn main() -> anyhow::Result<()> { let cli = Cli::parse(); + let stdout = io::stdout(); + let mut out = stdout.lock(); match cli.command { - Command::Inspect { path } => { - println!("rvcsi inspect: {path} (not yet implemented in the skeleton)"); - Ok(()) + Command::Record { input, output, source_id, session } => { + commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)? } + Command::Inspect { path, json } => commands::inspect(&mut out, &path, json)?, + Command::Replay { path, json, limit, speed } => { + if (speed - 1.0).abs() > f32::EPSILON { + eprintln!("note: --speed {speed} is not enforced by the CLI; replaying as fast as possible"); + } + commands::replay(&mut out, &path, json, limit)?; + } + Command::Stream { input, format, port } => { + if format != "json" { + anyhow::bail!("unsupported --format `{format}` (only `json` is available in this build)"); + } + if let Some(p) = port { + eprintln!("note: --port {p} (WebSocket) needs the rvcsi-daemon; streaming JSON lines to stdout instead"); + } + commands::replay(&mut out, &input, true, None)?; + } + Command::Events { path, json } => commands::events(&mut out, &path, json)?, + Command::Health { source, target } => commands::health(&mut out, &source, target.as_deref())?, + Command::Calibrate { input, output } => commands::calibrate(&mut out, &input, output.as_deref())?, + Command::Export { target } => match target { + ExportTarget::Ruvector(a) => commands::export_ruvector(&mut out, &a.input, &a.output)?, + }, } + out.flush()?; + Ok(()) } diff --git a/v2/crates/rvcsi-node/Cargo.toml b/v2/crates/rvcsi-node/Cargo.toml index 23c9f1bf..3855be89 100644 --- a/v2/crates/rvcsi-node/Cargo.toml +++ b/v2/crates/rvcsi-node/Cargo.toml @@ -18,13 +18,12 @@ crate-type = ["cdylib", "rlib"] napi = { workspace = true } napi-derive = { workspace = true } 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" } +rvcsi-runtime = { path = "../rvcsi-runtime" } serde = { workspace = true } serde_json = { workspace = true } [build-dependencies] napi-build = { workspace = true } + +[dev-dependencies] +tempfile = "3.10" diff --git a/v2/crates/rvcsi-node/README.md b/v2/crates/rvcsi-node/README.md new file mode 100644 index 00000000..c8a8fb1b --- /dev/null +++ b/v2/crates/rvcsi-node/README.md @@ -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..node + binding.js + binding.d.ts +``` + +(`cargo build -p rvcsi-node` also compiles the addon as a `cdylib`; `napi build` +additionally emits the platform loader and `.d.ts`.) + +## Usage + +```js +const { RvCsi, inspectCaptureFile, eventsFromCaptureFile, nexmonDecodeRecords } = require('@ruv/rvcsi'); + +// One-shot: summarize a capture +const summary = inspectCaptureFile('lab.rvcsi'); +console.log(summary.frame_count, summary.channels, summary.mean_quality); + +// One-shot: replay a capture into events +for (const e of eventsFromCaptureFile('lab.rvcsi')) { + console.log(e.kind, e.timestamp_ns, e.confidence); +} + +// Streaming +const rt = RvCsi.openCaptureFile('lab.rvcsi'); +let frame; +while ((frame = rt.nextCleanFrame()) !== null) { + // frame.validation is 'Accepted' | 'Degraded' | 'Recovered' — never 'Pending'/'Rejected' + if (frame.quality_score > 0.5) { /* ... */ } +} +const events = rt.drainEvents(); +console.log(rt.health()); + +// Decode raw Nexmon records (the napi-c shim format) straight from a Buffer +const fs = require('fs'); +const frames = nexmonDecodeRecords(fs.readFileSync('nexmon.bin'), 'wlan0', 1); +``` + +TypeScript types ship in `index.d.ts` (`CsiFrame`, `CsiWindow`, `CsiEvent`, +`SourceHealth`, `CaptureSummary`, `ValidationStatus`, `CsiEventKind`, ...). + +## What's here vs. not (yet) + +Implemented: file/replay + Nexmon sources, the validation pipeline, the DSP +stages, window aggregation + the event state machines, RuVector-style RF-memory +export. Not yet wired into this addon: live radio capture, the WebSocket daemon, +and the MCP tool server — those come with `rvcsi-daemon` / `rvcsi-mcp`. diff --git a/v2/crates/rvcsi-node/__test__/api.test.cjs b/v2/crates/rvcsi-node/__test__/api.test.cjs new file mode 100644 index 00000000..6a5bba8c --- /dev/null +++ b/v2/crates/rvcsi-node/__test__/api.test.cjs @@ -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); + } +}); diff --git a/v2/crates/rvcsi-node/index.d.ts b/v2/crates/rvcsi-node/index.d.ts new file mode 100644 index 00000000..4669c679 --- /dev/null +++ b/v2/crates/rvcsi-node/index.d.ts @@ -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; +} diff --git a/v2/crates/rvcsi-node/index.js b/v2/crates/rvcsi-node/index.js new file mode 100644 index 00000000..50e52928 --- /dev/null +++ b/v2/crates/rvcsi-node/index.js @@ -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, +}; diff --git a/v2/crates/rvcsi-node/package.json b/v2/crates/rvcsi-node/package.json new file mode 100644 index 00000000..f6da5af0 --- /dev/null +++ b/v2/crates/rvcsi-node/package.json @@ -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" + } +} diff --git a/v2/crates/rvcsi-node/src/lib.rs b/v2/crates/rvcsi-node/src/lib.rs index 3c26b75a..01f24385 100644 --- a/v2/crates/rvcsi-node/src/lib.rs +++ b/v2/crates/rvcsi-node/src/lib.rs @@ -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 -//! (ADR-095 D3/D4, ADR-096). Nothing here exposes raw pointers; every value -//! that crosses the boundary is a validated/normalized struct or a JSON string. +//! The safe TypeScript-facing surface over the rvCSI Rust runtime. Nothing here +//! exposes raw pointers; every value that crosses the boundary is either a +//! normalized rvCSI struct *serialized to JSON* or a scalar. Frames are run +//! through [`rvcsi_core::validate_frame`] inside [`rvcsi_runtime`] before they +//! reach JS (D6), so a JS caller never sees a `Pending` or `Rejected` frame. +//! +//! All real logic lives in the `rvcsi-runtime` crate (plain Rust, unit-tested +//! without a Node env); the `#[napi]` items below are one-liner wrappers. +//! +//! ## JS surface (also see the generated `index.d.ts` in the npm package) +//! +//! Free functions: +//! * `rvcsiVersion(): string` +//! * `nexmonShimAbiVersion(): number` — ABI of the linked napi-c shim +//! * `nexmonDecodeRecords(buf: Buffer, sourceId: string, sessionId: number): string` +//! — JSON array of validated `CsiFrame`s decoded from the C-shim record format +//! * `inspectCaptureFile(path: string): string` — JSON `CaptureSummary` +//! * `eventsFromCaptureFile(path: string): string` — JSON array of `CsiEvent`s +//! * `exportCaptureToRfMemory(capturePath: string, outJsonlPath: string): number` +//! — windows stored +//! +//! Class `RvcsiRuntime` (streaming): +//! * `RvcsiRuntime.openCaptureFile(path): RvcsiRuntime` +//! * `RvcsiRuntime.openNexmonFile(path, sourceId, sessionId): RvcsiRuntime` +//! * `.nextFrameJson(): string | null` / `.nextCleanFrameJson(): string | null` +//! * `.drainEventsJson(): string` — JSON array of `CsiEvent`s +//! * `.healthJson(): string` — JSON `SourceHealth` +//! * `.framesSeen` / `.framesDropped` (getters) #![deny(clippy::all)] #[macro_use] extern crate napi_derive; +use napi::bindgen_prelude::Buffer; + +use rvcsi_runtime::{self as runtime, CaptureRuntime}; + +fn napi_err(e: impl std::fmt::Display) -> napi::Error { + napi::Error::from_reason(e.to_string()) +} + +fn to_json(v: &T) -> napi::Result { + serde_json::to_string(v).map_err(napi_err) +} + +// --------------------------------------------------------------------------- +// Free functions +// --------------------------------------------------------------------------- + /// rvCSI runtime version (the workspace crate version). #[napi] pub fn rvcsi_version() -> String { @@ -18,5 +59,108 @@ pub fn rvcsi_version() -> String { /// ABI version of the linked napi-c Nexmon shim (`major << 16 | minor`). #[napi] 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 { + let frames = runtime::decode_nexmon_records(buf.as_ref(), &source_id, session_id as u64).map_err(napi_err)?; + to_json(&frames) +} + +/// Summarize a `.rvcsi` capture file; returns JSON for a `CaptureSummary`. +#[napi] +pub fn inspect_capture_file(path: String) -> napi::Result { + let summary = runtime::summarize_capture(&path).map_err(napi_err)?; + to_json(&summary) +} + +/// Replay a `.rvcsi` capture through the DSP + event pipeline; returns a JSON +/// array of `CsiEvent`s. +#[napi] +pub fn events_from_capture_file(path: String) -> napi::Result { + let events = runtime::events_from_capture(&path).map_err(napi_err)?; + to_json(&events) +} + +/// Replay a `.rvcsi` capture, window it, and store each window's embedding into +/// a JSONL RF-memory file; returns the number of windows stored. +#[napi] +pub fn export_capture_to_rf_memory(capture_path: String, out_jsonl_path: String) -> napi::Result { + let n = runtime::export_capture_to_rf_memory(&capture_path, &out_jsonl_path).map_err(napi_err)?; + Ok(n as u32) +} + +// --------------------------------------------------------------------------- +// Streaming runtime class +// --------------------------------------------------------------------------- + +/// A streaming capture runtime: a source + the DSP stage + the event pipeline. +#[napi] +pub struct RvcsiRuntime { + inner: CaptureRuntime, +} + +#[napi] +impl RvcsiRuntime { + /// Open a `.rvcsi` capture file as the source. + #[napi(factory)] + pub fn open_capture_file(path: String) -> napi::Result { + Ok(RvcsiRuntime { + inner: CaptureRuntime::open_capture_file(&path).map_err(napi_err)?, + }) + } + + /// Open a Nexmon capture file (concatenated rvCSI Nexmon records) as the source. + #[napi(factory)] + pub fn open_nexmon_file(path: String, source_id: String, session_id: u32) -> napi::Result { + Ok(RvcsiRuntime { + inner: CaptureRuntime::open_nexmon_file(&path, &source_id, session_id as u64).map_err(napi_err)?, + }) + } + + /// Next exposable, validated frame as JSON, or `null` at end-of-stream. + #[napi] + pub fn next_frame_json(&mut self) -> napi::Result> { + match self.inner.next_validated_frame().map_err(napi_err)? { + Some(f) => Ok(Some(to_json(&f)?)), + None => Ok(None), + } + } + + /// Like `nextFrameJson` but with the DSP pipeline applied (cleaned amplitude/phase). + #[napi] + pub fn next_clean_frame_json(&mut self) -> napi::Result> { + match self.inner.next_clean_frame().map_err(napi_err)? { + Some(f) => Ok(Some(to_json(&f)?)), + None => Ok(None), + } + } + + /// Drain the rest of the stream through DSP + the event pipeline; JSON array of `CsiEvent`s. + #[napi] + pub fn drain_events_json(&mut self) -> napi::Result { + let events = self.inner.drain_events().map_err(napi_err)?; + to_json(&events) + } + + /// Health snapshot as JSON (`SourceHealth`). + #[napi] + pub fn health_json(&self) -> napi::Result { + to_json(&self.inner.health()) + } + + /// Frames pulled from the source so far. + #[napi(getter)] + pub fn frames_seen(&self) -> u32 { + self.inner.frames_seen() as u32 + } + + /// Frames dropped by validation so far. + #[napi(getter)] + pub fn frames_dropped(&self) -> u32 { + self.inner.frames_dropped() as u32 + } } diff --git a/v2/crates/rvcsi-runtime/Cargo.toml b/v2/crates/rvcsi-runtime/Cargo.toml new file mode 100644 index 00000000..2e6bbab3 --- /dev/null +++ b/v2/crates/rvcsi-runtime/Cargo.toml @@ -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" diff --git a/v2/crates/rvcsi-runtime/src/capture.rs b/v2/crates/rvcsi-runtime/src/capture.rs new file mode 100644 index 00000000..1820aa24 --- /dev/null +++ b/v2/crates/rvcsi-runtime/src/capture.rs @@ -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, + profile: AdapterProfile, + policy: ValidationPolicy, + dsp: SignalPipeline, + events: EventPipeline, + prev_ts: Option, + frames_seen: u64, + frames_dropped: u64, +} + +impl CaptureRuntime { + fn new(source: Box, policy: ValidationPolicy) -> Self { + let profile = source.profile().clone(); + let session_id = source.session_id(); + let source_id = source.source_id().clone(); + CaptureRuntime { + source, + profile, + policy, + dsp: SignalPipeline::default(), + events: EventPipeline::with_defaults(session_id, source_id), + prev_ts: None, + frames_seen: 0, + frames_dropped: 0, + } + } + + /// Open a `.rvcsi` capture file as the source. + pub fn open_capture_file(path: &str) -> Result { + let source = FileReplayAdapter::open(path)?; + Ok(Self::new(Box::new(source), ValidationPolicy::default())) + } + + /// Open a buffer of "rvCSI Nexmon records" (the napi-c shim format) as the source. + pub fn open_nexmon_bytes(bytes: Vec, source_id: &str, session_id: u64) -> Self { + let source = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes); + // Permissive policy: the C-shim records may carry non-default subcarrier counts. + Self::new(Box::new(source), ValidationPolicy::default()) + } + + /// Open a Nexmon capture *file* (concatenated records) as the source. + pub fn open_nexmon_file(path: &str, source_id: &str, session_id: u64) -> Result { + let bytes = std::fs::read(path)?; + Ok(Self::open_nexmon_bytes(bytes, source_id, session_id)) + } + + /// Validate (if needed) a freshly pulled frame; `None` if it was hard-rejected. + fn admit(&mut self, mut frame: CsiFrame) -> Option { + self.frames_seen += 1; + if frame.validation == ValidationStatus::Pending { + let ts = frame.timestamp_ns; + match validate_frame(&mut frame, &self.profile, &self.policy, self.prev_ts) { + Ok(()) if frame.is_exposable() => { + self.prev_ts = Some(ts); + Some(frame) + } + _ => { + self.frames_dropped += 1; + None + } + } + } else if frame.is_exposable() { + Some(frame) + } else { + self.frames_dropped += 1; + None + } + } + + /// Pull the next exposable frame, validating it if necessary. `Ok(None)` at + /// end-of-stream. The frame's `amplitude`/`phase` are NOT yet DSP-cleaned + /// (call [`CaptureRuntime::next_clean_frame`] for that). + pub fn next_validated_frame(&mut self) -> Result, RvcsiError> { + loop { + match self.source.next_frame()? { + None => return Ok(None), + Some(frame) => { + if let Some(f) = self.admit(frame) { + return Ok(Some(f)); + } + } + } + } + } + + /// Like [`CaptureRuntime::next_validated_frame`] but with `SignalPipeline` + /// applied (DC removal, phase unwrap, Hampel filter, smoothing). + pub fn next_clean_frame(&mut self) -> Result, RvcsiError> { + match self.next_validated_frame()? { + None => Ok(None), + Some(mut f) => { + self.dsp.process_frame(&mut f); + Ok(Some(f)) + } + } + } + + /// Drain the rest of the stream through DSP + the event pipeline and return + /// every emitted event (in order). + pub fn drain_events(&mut self) -> Result, RvcsiError> { + let mut out = Vec::new(); + while let Some(mut f) = self.next_validated_frame()? { + self.dsp.process_frame(&mut f); + out.extend(self.events.process_frame(&f)); + } + out.extend(self.events.flush()); + Ok(out) + } + + /// Health snapshot combining the source's view and the runtime's counters. + pub fn health(&self) -> SourceHealth { + let mut h = self.source.health(); + // Augment the status with the runtime's drop count. + let extra = format!("frames_seen={}, frames_dropped={}", self.frames_seen, self.frames_dropped); + h.status = Some(match h.status { + Some(s) => format!("{s}; {extra}"), + None => extra, + }); + h + } + + /// Frames pulled from the source so far. + pub fn frames_seen(&self) -> u64 { + self.frames_seen + } + + /// Frames dropped by validation so far. + pub fn frames_dropped(&self) -> u64 { + self.frames_dropped + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rvcsi_adapter_file::{CaptureHeader, FileRecorder}; + use rvcsi_adapter_nexmon::{encode_record, NexmonRecord}; + use rvcsi_core::{AdapterKind, FrameId}; + + fn write_capture(path: &std::path::Path, n: usize) { + let header = CaptureHeader::new( + SessionId(1), + SourceId::from("rt"), + AdapterProfile::offline(AdapterKind::File), + ); + let mut rec = FileRecorder::create(path, &header).unwrap(); + for k in 0..n { + let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 }; + let i: Vec = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect(); + let q: Vec = (0..32).map(|_| 0.5).collect(); + let mut f = CsiFrame::from_iq( + FrameId(k as u64), + SessionId(1), + SourceId::from("rt"), + AdapterKind::File, + 1_000 + k as u64 * 50_000_000, + 6, + 20, + i, + q, + ) + .with_rssi(-55); + f.validation = ValidationStatus::Accepted; + f.quality_score = 0.9; + rec.write_frame(&f).unwrap(); + } + rec.finish().unwrap(); + } + + #[test] + fn streams_validated_frames_from_a_capture() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 5); + let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap(); + let mut count = 0; + while let Some(f) = rt.next_validated_frame().unwrap() { + assert!(f.is_exposable()); + count += 1; + } + assert_eq!(count, 5); + assert_eq!(rt.frames_seen(), 5); + assert_eq!(rt.frames_dropped(), 0); + let h = rt.health(); + assert!(h.status.unwrap().contains("frames_seen=5")); + } + + #[test] + fn clean_frame_applies_dsp_without_changing_validation() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 3); + let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap(); + let f = rt.next_clean_frame().unwrap().unwrap(); + assert_eq!(f.validation, ValidationStatus::Accepted); + assert_eq!(f.quality_score, 0.9); + assert_eq!(f.amplitude.len(), 32); + } + + #[test] + fn drains_events_from_an_alternating_stream() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 64); + let mut rt = CaptureRuntime::open_capture_file(tmp.path().to_str().unwrap()).unwrap(); + let events = rt.drain_events().unwrap(); + assert!(!events.is_empty()); + for e in &events { + e.validate().unwrap(); + } + } + + #[test] + fn runs_a_nexmon_record_stream() { + let mk = |ts: u64| { + let rec = NexmonRecord { + subcarrier_count: 64, + channel: 36, + bandwidth_mhz: 80, + rssi_dbm: Some(-60), + noise_floor_dbm: Some(-92), + timestamp_ns: ts, + i_values: (0..64).map(|k| (k as f32 % 3.0) - 1.0).collect(), + q_values: (0..64).map(|k| (k as f32 % 5.0) * 0.1).collect(), + }; + encode_record(&rec).unwrap() + }; + let mut buf = Vec::new(); + for k in 0..40 { + buf.extend(mk(1_000 + k * 50_000_000)); + } + let mut rt = CaptureRuntime::open_nexmon_bytes(buf, "nexmon-rt", 3); + let mut n = 0; + while let Some(f) = rt.next_validated_frame().unwrap() { + assert_eq!(f.adapter_kind, AdapterKind::Nexmon); + assert!(f.is_exposable()); + n += 1; + } + assert_eq!(n, 40); + } + + #[test] + fn 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()); + } +} diff --git a/v2/crates/rvcsi-runtime/src/lib.rs b/v2/crates/rvcsi-runtime/src/lib.rs new file mode 100644 index 00000000..8af58ec2 --- /dev/null +++ b/v2/crates/rvcsi-runtime/src/lib.rs @@ -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() +} diff --git a/v2/crates/rvcsi-runtime/src/summary.rs b/v2/crates/rvcsi-runtime/src/summary.rs new file mode 100644 index 00000000..e9bf5d46 --- /dev/null +++ b/v2/crates/rvcsi-runtime/src/summary.rs @@ -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, + /// Distinct subcarrier counts seen. + pub subcarrier_counts: Vec, + /// Mean `quality_score` over all frames (`0.0` for an empty capture). + pub mean_quality: f32, + /// Count of frames by `ValidationStatus` (`accepted`, `degraded`, `recovered`, + /// `rejected`, `pending`). + pub validation_breakdown: ValidationBreakdown, + /// Calibration version recorded in the header, if any. + pub calibration_version: Option, +} + +/// Per-`ValidationStatus` frame counts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidationBreakdown { + /// `ValidationStatus::Pending` + pub pending: usize, + /// `ValidationStatus::Accepted` + pub accepted: usize, + /// `ValidationStatus::Degraded` + pub degraded: usize, + /// `ValidationStatus::Rejected` + pub rejected: usize, + /// `ValidationStatus::Recovered` + pub recovered: usize, +} + +impl ValidationBreakdown { + fn tally(&mut self, s: ValidationStatus) { + match s { + ValidationStatus::Pending => self.pending += 1, + ValidationStatus::Accepted => self.accepted += 1, + ValidationStatus::Degraded => self.degraded += 1, + ValidationStatus::Rejected => self.rejected += 1, + ValidationStatus::Recovered => self.recovered += 1, + } + } +} + +fn sorted_unique(mut v: Vec) -> Vec { + v.sort_unstable(); + v.dedup(); + v +} + +/// Summarize a `.rvcsi` capture file. +pub fn summarize_capture(path: &str) -> Result { + let (header, frames): (CaptureHeader, Vec) = read_all(path)?; + let mut channels = Vec::new(); + let mut subcarrier_counts = Vec::new(); + let mut breakdown = ValidationBreakdown::default(); + let mut quality_sum = 0.0f32; + let (mut first_ts, mut last_ts) = (u64::MAX, 0u64); + for f in &frames { + channels.push(f.channel); + subcarrier_counts.push(f.subcarrier_count); + breakdown.tally(f.validation); + quality_sum += f.quality_score; + first_ts = first_ts.min(f.timestamp_ns); + last_ts = last_ts.max(f.timestamp_ns); + } + if frames.is_empty() { + first_ts = 0; + } + Ok(CaptureSummary { + capture_version: header.rvcsi_capture_version, + session_id: header.session_id.value(), + source_id: header.source_id.0, + adapter_kind: header.adapter_profile.adapter_kind.slug().to_string(), + 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, 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 = 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, RvcsiError> { + let (header, frames) = read_all(path)?; + let dsp = SignalPipeline::default(); + let mut pipeline = EventPipeline::with_defaults(header.session_id, header.source_id.clone()); + let profile = header.adapter_profile.clone(); + let policy = header.validation_policy.clone(); + let mut prev_ts: Option = None; + let mut events = Vec::new(); + for mut f in frames { + if f.validation == ValidationStatus::Pending { + let ts = f.timestamp_ns; + if validate_frame(&mut f, &profile, &policy, prev_ts).is_err() || !f.is_exposable() { + continue; + } + prev_ts = Some(ts); + } + dsp.process_frame(&mut f); + events.extend(pipeline.process_frame(&f)); + } + events.extend(pipeline.flush()); + Ok(events) +} + +/// Replay a `.rvcsi` capture, window it, and store every window's embedding into +/// a JSONL RF-memory file (the `rvcsi export ruvector` payload). Returns the +/// number of windows stored. +pub fn export_capture_to_rf_memory(capture_path: &str, out_jsonl_path: &str) -> Result { + let (header, frames) = read_all(capture_path)?; + let mut pipeline = EventPipeline::with_defaults(header.session_id, header.source_id.clone()); + let dsp = SignalPipeline::default(); + let mut store = JsonlRfMemory::create(out_jsonl_path)?; + let mut stored = 0usize; + for mut f in frames { + if !f.is_exposable() { + continue; + } + dsp.process_frame(&mut f); + let _ = pipeline.process_frame(&f); + } + let _ = pipeline.flush(); + for w in pipeline.recent_windows() { + store.store_window(w)?; + stored += 1; + } + Ok(stored) +} + +/// Convenience used by tests / examples: window a capture in memory and return +/// `(window_count, top_self_similarity)` — storing each window then querying +/// with the first window's embedding should yield itself with score ≈ 1.0. +pub fn rf_memory_self_check(capture_path: &str) -> Result<(usize, f32), RvcsiError> { + let (header, frames) = read_all(capture_path)?; + let mut pipeline = EventPipeline::with_defaults(header.session_id, header.source_id.clone()); + for f in &frames { + if f.is_exposable() { + let _ = pipeline.process_frame(f); + } + } + let _ = pipeline.flush(); + let windows: Vec<_> = pipeline.recent_windows().to_vec(); + let mut store = InMemoryRfMemory::new(); + for w in &windows { + store.store_window(w)?; + } + if windows.is_empty() { + return Ok((0, 0.0)); + } + let q = window_embedding(&windows[0]); + let hits = store.query_similar(&q, 1)?; + Ok((windows.len(), hits.first().map(|h| h.score).unwrap_or(0.0))) +} + +#[cfg(test)] +mod tests { + use super::*; + use rvcsi_adapter_file::FileRecorder; + use rvcsi_adapter_nexmon::{encode_record, NexmonRecord}; + use rvcsi_core::{AdapterKind, FrameId}; + + fn write_capture(path: &std::path::Path, n: usize) { + let header = CaptureHeader::new( + SessionId(1), + SourceId::from("it"), + AdapterProfile::offline(AdapterKind::File), + ); + let mut rec = FileRecorder::create(path, &header).unwrap(); + for k in 0..n { + // alternate "quiet" and "active" amplitudes so the event pipeline has something to do + let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 }; + let i: Vec = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect(); + let q: Vec = (0..32).map(|s| 0.5 + amp_scale * (((k * 3 + s) % 7) as f32 - 3.0) * 0.1).collect(); + let mut f = CsiFrame::from_iq( + FrameId(k as u64), + SessionId(1), + SourceId::from("it"), + AdapterKind::File, + 1_000 + k as u64 * 50_000_000, // 50 ms apart + 6, + 20, + i, + q, + ) + .with_rssi(-55); + f.validation = ValidationStatus::Accepted; + f.quality_score = 0.9; + rec.write_frame(&f).unwrap(); + } + rec.finish().unwrap(); + } + + #[test] + fn summarize_a_recorded_capture() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 10); + let s = summarize_capture(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(s.capture_version, 1); + assert_eq!(s.session_id, 1); + assert_eq!(s.frame_count, 10); + assert_eq!(s.channels, vec![6]); + assert_eq!(s.subcarrier_counts, vec![32]); + assert_eq!(s.validation_breakdown.accepted, 10); + assert!((s.mean_quality - 0.9).abs() < 1e-5); + assert_eq!(s.first_timestamp_ns, 1_000); + assert!(s.last_timestamp_ns > s.first_timestamp_ns); + } + + #[test] + fn summarize_empty_capture() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let header = CaptureHeader::new(SessionId(9), SourceId::from("e"), AdapterProfile::offline(AdapterKind::File)); + FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap(); + let s = summarize_capture(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(s.frame_count, 0); + assert_eq!(s.mean_quality, 0.0); + assert_eq!(s.first_timestamp_ns, 0); + } + + #[test] + fn decode_nexmon_records_validates_and_returns_frames() { + // two 64-subcarrier records + let mk = |ts: u64, rssi: i16| { + let rec = NexmonRecord { + subcarrier_count: 64, + channel: 36, + bandwidth_mhz: 80, + rssi_dbm: Some(rssi), + noise_floor_dbm: Some(-92), + timestamp_ns: ts, + i_values: (0..64).map(|k| (k as f32) * 0.25).collect(), + q_values: (0..64).map(|k| -(k as f32) * 0.1).collect(), + }; + encode_record(&rec).unwrap() + }; + let mut buf = mk(1_000, -58); + buf.extend(mk(2_000, -59)); + let frames = decode_nexmon_records(&buf, "nexmon-test", 7).unwrap(); + assert_eq!(frames.len(), 2); + for f in &frames { + assert!(f.is_exposable()); + assert_eq!(f.subcarrier_count, 64); + assert_eq!(f.adapter_kind, AdapterKind::Nexmon); + } + assert_eq!(frames[1].timestamp_ns, 2_000); + } + + #[test] + fn events_and_export_from_capture() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_capture(tmp.path(), 64); + let events = events_from_capture(tmp.path().to_str().unwrap()).unwrap(); + // the alternating quiet/active stream should produce at least one event, + // and every event must be well-formed. + assert!(!events.is_empty(), "expected the event pipeline to emit something"); + for e in &events { + e.validate().unwrap(); + assert!((0.0..=1.0).contains(&e.confidence)); + assert!(!e.evidence_window_ids.is_empty()); + } + + let out = tempfile::NamedTempFile::new().unwrap(); + let stored = export_capture_to_rf_memory( + tmp.path().to_str().unwrap(), + out.path().to_str().unwrap(), + ) + .unwrap(); + assert!(stored > 0); + // re-open the JSONL store and confirm the records round-tripped + let reopened = JsonlRfMemory::open(out.path().to_str().unwrap()).unwrap(); + assert_eq!(reopened.len(), stored); + + let (wc, score) = rf_memory_self_check(tmp.path().to_str().unwrap()).unwrap(); + assert!(wc > 0); + assert!((score - 1.0).abs() < 1e-4, "self-similarity should be ~1.0, got {score}"); + } + + #[test] + fn missing_capture_file_is_a_structured_error() { + assert!(summarize_capture("/nonexistent/path/x.rvcsi").is_err()); + assert!(events_from_capture("/nonexistent/path/x.rvcsi").is_err()); + } +}