mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+134
-10
@@ -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<usize>,
|
||||
/// Real-time pacing multiplier. Accepted for compatibility but not
|
||||
/// enforced by the CLI (the `rvcsi-daemon` paces real-time replay);
|
||||
/// a value other than `1.0` is noted on stderr.
|
||||
#[arg(long, default_value_t = 1.0)]
|
||||
speed: f32,
|
||||
},
|
||||
/// Stream frames from a source to stdout as JSON lines (a v0 stand-in for
|
||||
/// the daemon's WebSocket output). Currently supports `.rvcsi` files via `--in`.
|
||||
Stream {
|
||||
/// Path to a `.rvcsi` capture file to stream.
|
||||
#[arg(long = "in")]
|
||||
input: String,
|
||||
/// Output format (only `json` is supported in this build).
|
||||
#[arg(long, default_value = "json")]
|
||||
format: String,
|
||||
/// WebSocket port. Accepted but not served by the CLI — needs `rvcsi-daemon`.
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
},
|
||||
/// Replay a capture through the DSP + event pipeline and print the events.
|
||||
Events {
|
||||
/// Path to a `.rvcsi` capture file.
|
||||
path: String,
|
||||
/// Emit events as JSON instead of compact lines.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Open a source, drain it, and print its `SourceHealth` as JSON.
|
||||
Health {
|
||||
/// Source slug: `file`, `replay`, `nexmon` (offline); `esp32`/`intel`/`atheros` need the daemon.
|
||||
#[arg(long)]
|
||||
source: String,
|
||||
/// Path / interface for the source (required for `file`/`replay`/`nexmon`).
|
||||
#[arg(long)]
|
||||
target: Option<String>,
|
||||
},
|
||||
/// Learn a v0 baseline (per-subcarrier mean amplitude) from a capture.
|
||||
Calibrate {
|
||||
/// Path to a `.rvcsi` capture file.
|
||||
#[arg(long = "in")]
|
||||
input: String,
|
||||
/// Write the baseline JSON here instead of stdout.
|
||||
#[arg(long = "out")]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Export data derived from a capture.
|
||||
Export {
|
||||
#[command(subcommand)]
|
||||
target: ExportTarget,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ExportTarget {
|
||||
/// Window a capture and store each window's embedding into a JSONL RF-memory file.
|
||||
Ruvector(ExportRuvector),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ExportRuvector {
|
||||
/// Path to a `.rvcsi` capture file.
|
||||
#[arg(long = "in")]
|
||||
input: String,
|
||||
/// Path to the output JSONL RF-memory file.
|
||||
#[arg(long = "out")]
|
||||
output: String,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
match cli.command {
|
||||
Command::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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user