mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
d40411e6d7
Adds first-class support for the Raspberry Pi 5's WiFi chip (CYW43455 /
BCM43455c0 — the same 802.11ac wireless as the Pi 4 / Pi 3B+ / Pi 400, and the
chip with the most mature nexmon_csi support), plus a registry of the other
Nexmon-supported Broadcom/Cypress chips.
rvcsi-adapter-nexmon — new `chips.rs`:
- `NexmonChip` (Bcm43455c0, Bcm43436b0, Bcm4366c0, Bcm4375b1, Bcm4358, Bcm4339,
Unknown{chip_ver}) + `RaspberryPiModel` (Pi5/Pi4/Pi400/Pi3BPlus/PiZero2W/
PiZeroW) — Pi5/Pi4/Pi400/Pi3B+ → Bcm43455c0; PiZero2W → Bcm43436b0.
- `nexmon_adapter_profile(chip)` / `raspberry_pi_profile(model)` build the
per-device `AdapterProfile` (channels: 2.4 GHz 1-13 + 5 GHz UNII for dual-band;
bandwidths 20/40/80[/160]; expected subcarrier counts 64/128/256[/512]) that
`validate_frame` bounds CSI frames against.
- `NexmonChip::from_chip_ver` (0x4345 → Bcm43455c0, 0x4339, 0x4358, 0x4366,
0x4375 — best-effort; the raw `chip_ver` is always preserved) and `from_slug`
/ `RaspberryPiModel::from_slug` ("pi5", "raspberry pi 4", "bcm43455c0", ...).
- `NexmonCsiHeader::chip()`; `NexmonPcapAdapter` auto-detects the chip from the
packets' `chip_ver` and uses the matching profile, overridable via
`.with_chip(NexmonChip)` / `.with_pi_model(RaspberryPiModel)`; `.detected_chip()`.
rvcsi-runtime: `decode_nexmon_pcap_for(.., chip_spec)` (validate against a chip /
Pi model, drop non-conforming) + `nexmon_profile_for(spec)`; `NexmonPcapSummary`
gains `chip_names` + `detected_chip`; `CaptureSummary` gains `chip`.
rvcsi-cli: `record --source nexmon-pcap --chip pi5`; new `nexmon-chips`
subcommand (lists chips + Pi models, human or `--json`); `inspect-nexmon` and
`inspect` now print the resolved chip.
rvcsi-node (napi-rs): `nexmonDecodePcap` gains an optional `chip` arg;
`nexmonChipName(chipVer)`, `nexmonProfile(spec)`, `nexmonChips()`. @ruv/rvcsi
SDK + `.d.ts` updated (AdapterProfile / NexmonChipsListing interfaces, the new
fns, `chip` on CaptureSummary, `chip_names`/`detected_chip` on NexmonPcapSummary).
168 rvcsi tests pass (adapter-nexmon 22→28, cli 9→10), 0 failures, clippy-clean.
The synthetic test captures now stamp chip_ver = 0x4345 (the BCM4345 family chip
ID), so the chip-detection happy path is exercised end to end.
ADR-096, CHANGELOG, README, CLAUDE.md updated.
https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
668 lines
28 KiB
Rust
668 lines
28 KiB
Rust
//! Implementations of the `rvcsi` subcommands (ADR-095 FR7).
|
|
//!
|
|
//! Each command writes to a caller-supplied `&mut dyn Write` so the bodies can
|
|
//! be unit-tested against an in-memory buffer.
|
|
|
|
use std::io::Write;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use rvcsi_adapter_file::{read_all, CaptureHeader, FileRecorder, FileReplayAdapter};
|
|
use rvcsi_adapter_nexmon::NexmonAdapter;
|
|
use rvcsi_core::{
|
|
validate_frame, AdapterKind, AdapterProfile, CsiFrame, CsiSource, SessionId, SourceId,
|
|
ValidationPolicy,
|
|
};
|
|
use rvcsi_runtime as runtime;
|
|
|
|
/// `rvcsi record --in <nexmon.bin> --out <cap.rvcsi>` — transcode a buffer of
|
|
/// "rvCSI Nexmon records" (the napi-c shim format) into a `.rvcsi` capture file,
|
|
/// validating each frame on the way in. This gives the CLI a way to produce
|
|
/// `.rvcsi` files without a live radio (which needs the not-yet-shipped daemon).
|
|
pub fn record_from_nexmon(
|
|
out: &mut dyn Write,
|
|
nexmon_path: &str,
|
|
out_path: &str,
|
|
source_id: &str,
|
|
session_id: u64,
|
|
) -> Result<()> {
|
|
let bytes = std::fs::read(nexmon_path).with_context(|| format!("reading {nexmon_path}"))?;
|
|
let mut src = NexmonAdapter::from_bytes(SourceId::from(source_id), SessionId(session_id), bytes);
|
|
let profile = AdapterProfile::offline(AdapterKind::Nexmon);
|
|
let policy = ValidationPolicy::default();
|
|
let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile.clone());
|
|
let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?;
|
|
let (mut written, mut skipped, mut prev_ts) = (0u64, 0u64, None);
|
|
loop {
|
|
match src.next_frame() {
|
|
Ok(None) => break,
|
|
Ok(Some(mut f)) => {
|
|
let ts = f.timestamp_ns;
|
|
match validate_frame(&mut f, &profile, &policy, prev_ts) {
|
|
Ok(()) if f.is_exposable() => {
|
|
prev_ts = Some(ts);
|
|
rec.write_frame(&f)?;
|
|
written += 1;
|
|
}
|
|
_ => skipped += 1,
|
|
}
|
|
}
|
|
Err(e) => {
|
|
writeln!(out, "warning: stopped at a malformed Nexmon record: {e}")?;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
rec.finish()?;
|
|
writeln!(out, "recorded {written} frame(s) to {out_path} ({skipped} dropped by validation)")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi record --source nexmon-pcap --in <csi.pcap> --out <cap.rvcsi> [--chip pi5]` —
|
|
/// transcode the real nexmon_csi UDP payloads inside a libpcap capture
|
|
/// (`tcpdump -i wlan0 dst port 5500 -w csi.pcap`) into a `.rvcsi` capture file,
|
|
/// validating each frame. `port` is the CSI UDP port (`None` ⇒ 5500). `chip` is
|
|
/// an optional chip / Raspberry-Pi-model spec (`"pi5"`, `"bcm43455c0"`, ...) —
|
|
/// when given, frames are validated against that device's profile and the
|
|
/// non-conforming ones dropped (and the profile is stamped on the capture).
|
|
pub fn record_from_nexmon_pcap(
|
|
out: &mut dyn Write,
|
|
pcap_path: &str,
|
|
out_path: &str,
|
|
source_id: &str,
|
|
session_id: u64,
|
|
port: Option<u16>,
|
|
chip: Option<&str>,
|
|
) -> Result<()> {
|
|
let bytes = std::fs::read(pcap_path).with_context(|| format!("reading {pcap_path}"))?;
|
|
let frames = runtime::decode_nexmon_pcap_for(&bytes, source_id, session_id, port, chip)
|
|
.with_context(|| format!("parsing nexmon pcap {pcap_path}"))?;
|
|
let profile = match chip {
|
|
Some(spec) => runtime::nexmon_profile_for(spec)
|
|
.ok_or_else(|| anyhow::anyhow!("unknown nexmon chip / Raspberry Pi model `{spec}`"))?,
|
|
None => AdapterProfile::nexmon_default(),
|
|
};
|
|
let header = CaptureHeader::new(SessionId(session_id), SourceId::from(source_id), profile);
|
|
let mut rec = FileRecorder::create(out_path, &header).with_context(|| format!("creating {out_path}"))?;
|
|
for f in &frames {
|
|
rec.write_frame(f)?;
|
|
}
|
|
rec.finish()?;
|
|
let chip_note = chip.map(|c| format!(" (chip {c})")).unwrap_or_default();
|
|
writeln!(out, "recorded {} frame(s) from {pcap_path} to {out_path}{chip_note}", frames.len())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi nexmon-chips` — list the Broadcom/Cypress chips nexmon_csi runs on and
|
|
/// the Raspberry Pi models that carry them (incl. the Pi 5 → BCM43455c0).
|
|
pub fn nexmon_chips_cmd(out: &mut dyn Write, json: bool) -> Result<()> {
|
|
use rvcsi_adapter_nexmon::{known_chips, known_pi_models, nexmon_adapter_profile, NexmonChip};
|
|
if json {
|
|
let chips: Vec<_> = known_chips()
|
|
.iter()
|
|
.map(|c| {
|
|
let p = nexmon_adapter_profile(*c);
|
|
serde_json::json!({
|
|
"slug": c.slug(), "description": c.description(),
|
|
"dual_band": c.dual_band(), "int16_iq_export": c.uses_int16_iq(),
|
|
"bandwidths_mhz": p.supported_bandwidths_mhz,
|
|
"expected_subcarrier_counts": p.expected_subcarrier_counts,
|
|
})
|
|
})
|
|
.collect();
|
|
let pis: Vec<_> = known_pi_models()
|
|
.iter()
|
|
.map(|m| serde_json::json!({
|
|
"slug": m.slug(), "chip": m.nexmon_chip().slug(), "csi_supported": m.csi_supported(),
|
|
}))
|
|
.collect();
|
|
writeln!(out, "{}", serde_json::to_string_pretty(&serde_json::json!({ "chips": chips, "raspberry_pi_models": pis }))?)?;
|
|
return Ok(());
|
|
}
|
|
writeln!(out, "Nexmon-supported Broadcom/Cypress chips:")?;
|
|
for c in known_chips() {
|
|
let p = nexmon_adapter_profile(*c);
|
|
writeln!(
|
|
out,
|
|
" {:<12} {} [bw {:?} MHz, sc {:?}{}]",
|
|
c.slug(),
|
|
c.description(),
|
|
p.supported_bandwidths_mhz,
|
|
p.expected_subcarrier_counts,
|
|
if c.uses_int16_iq() { "" } else { ", legacy packed-float export" }
|
|
)?;
|
|
}
|
|
writeln!(out, "\nRaspberry Pi models:")?;
|
|
for m in known_pi_models() {
|
|
let chip = m.nexmon_chip();
|
|
let chip_slug = if matches!(chip, NexmonChip::Unknown { .. }) { "(no CSI support)".to_string() } else { chip.slug() };
|
|
writeln!(out, " {:<10} -> {}{}", m.slug(), chip_slug, if m.csi_supported() { "" } else { " [WiFi present but not CSI-capable]" })?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi inspect-nexmon <csi.pcap>` — summarize a nexmon_csi `.pcap` (link
|
|
/// type, CSI frame count, channels, bandwidths, chip versions, RSSI range,
|
|
/// time span). `port` is the CSI UDP port (`None` ⇒ 5500).
|
|
pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option<u16>, json: bool) -> Result<()> {
|
|
let s = runtime::summarize_nexmon_pcap(pcap_path, port).with_context(|| format!("inspecting {pcap_path}"))?;
|
|
if json {
|
|
writeln!(out, "{}", serde_json::to_string_pretty(&s)?)?;
|
|
return Ok(());
|
|
}
|
|
writeln!(out, "nexmon pcap : {pcap_path}")?;
|
|
writeln!(out, " link type : {}", s.link_type)?;
|
|
writeln!(out, " CSI frames : {}", s.csi_frame_count)?;
|
|
writeln!(out, " skipped pkts : {}", s.skipped_packets)?;
|
|
writeln!(
|
|
out,
|
|
" time span : {} .. {} ns ({} ns)",
|
|
s.first_timestamp_ns,
|
|
s.last_timestamp_ns,
|
|
s.last_timestamp_ns.saturating_sub(s.first_timestamp_ns)
|
|
)?;
|
|
writeln!(out, " channels : {:?}", s.channels)?;
|
|
writeln!(out, " bandwidths : {:?} MHz", s.bandwidths_mhz)?;
|
|
writeln!(out, " subcarriers : {:?}", s.subcarrier_counts)?;
|
|
writeln!(
|
|
out,
|
|
" chip versions: {}",
|
|
s.chip_versions.iter().map(|v| format!("0x{v:04x}")).collect::<Vec<_>>().join(", ")
|
|
)?;
|
|
writeln!(out, " chip : {} (seen: {})", s.detected_chip, s.chip_names.join(", "))?;
|
|
match s.rssi_dbm_range {
|
|
Some((lo, hi)) => writeln!(out, " rssi range : {lo} .. {hi} dBm")?,
|
|
None => writeln!(out, " rssi range : (none)")?,
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi decode-chanspec <hex-or-dec>` — decode a Broadcom d11ac chanspec word
|
|
/// to `{channel, bandwidth_mhz, is_5ghz}` (JSON, or a human line).
|
|
pub fn decode_chanspec_cmd(out: &mut dyn Write, chanspec_str: &str, json: bool) -> Result<()> {
|
|
let s = chanspec_str.trim();
|
|
let value: u32 = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
|
|
u32::from_str_radix(hex, 16).with_context(|| format!("not a hex u16: {s}"))?
|
|
} else {
|
|
s.parse::<u32>().with_context(|| format!("not a decimal u16: {s}"))?
|
|
};
|
|
let d = rvcsi_adapter_nexmon::decode_chanspec((value & 0xFFFF) as u16);
|
|
if json {
|
|
writeln!(
|
|
out,
|
|
"{}",
|
|
serde_json::to_string(&serde_json::json!({
|
|
"chanspec": d.chanspec, "channel": d.channel,
|
|
"bandwidth_mhz": d.bandwidth_mhz, "is_5ghz": d.is_5ghz
|
|
}))?
|
|
)?;
|
|
} else {
|
|
writeln!(
|
|
out,
|
|
"chanspec 0x{:04x}: channel {} @ {} MHz ({})",
|
|
d.chanspec,
|
|
d.channel,
|
|
d.bandwidth_mhz,
|
|
if d.is_5ghz { "5 GHz" } else { "2.4 GHz" }
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi inspect <path>` — print a summary of a `.rvcsi` capture file.
|
|
pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> {
|
|
let summary = runtime::summarize_capture(path).with_context(|| format!("inspecting {path}"))?;
|
|
if json {
|
|
writeln!(out, "{}", serde_json::to_string_pretty(&summary)?)?;
|
|
return Ok(());
|
|
}
|
|
writeln!(out, "capture : {path}")?;
|
|
writeln!(out, " version : {}", summary.capture_version)?;
|
|
writeln!(out, " session : {}", summary.session_id)?;
|
|
writeln!(out, " source : {}", summary.source_id)?;
|
|
writeln!(out, " adapter : {}", summary.adapter_kind)?;
|
|
if let Some(chip) = &summary.chip {
|
|
writeln!(out, " chip : {chip}")?;
|
|
}
|
|
writeln!(out, " frames : {}", summary.frame_count)?;
|
|
writeln!(
|
|
out,
|
|
" time span : {} .. {} ns ({} ns)",
|
|
summary.first_timestamp_ns,
|
|
summary.last_timestamp_ns,
|
|
summary.last_timestamp_ns.saturating_sub(summary.first_timestamp_ns)
|
|
)?;
|
|
writeln!(out, " channels : {:?}", summary.channels)?;
|
|
writeln!(out, " subcarriers : {:?}", summary.subcarrier_counts)?;
|
|
writeln!(out, " mean quality : {:.3}", summary.mean_quality)?;
|
|
let b = summary.validation_breakdown;
|
|
writeln!(
|
|
out,
|
|
" validation : accepted={} degraded={} recovered={} rejected={} pending={}",
|
|
b.accepted, b.degraded, b.recovered, b.rejected, b.pending
|
|
)?;
|
|
writeln!(out, " calibration : {}", summary.calibration_version.as_deref().unwrap_or("(none)"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi replay <path>` / `rvcsi stream --in <path> --format json` — emit one
|
|
/// line per frame. With `json`, the full `CsiFrame` JSON; otherwise a compact
|
|
/// `frame_id ts ch rssi quality validation` line. `limit` caps the count
|
|
/// (`None` = all). `speed` is accepted but not enforced here (the daemon paces
|
|
/// real-time replay); a non-1.0 value is noted on stderr by the caller.
|
|
pub fn replay(out: &mut dyn Write, path: &str, json: bool, limit: Option<usize>) -> Result<()> {
|
|
let mut adapter = FileReplayAdapter::open(path).with_context(|| format!("opening {path}"))?;
|
|
let mut n = 0usize;
|
|
while let Some(frame) = adapter.next_frame()? {
|
|
if json {
|
|
writeln!(out, "{}", serde_json::to_string(&frame)?)?;
|
|
} else {
|
|
writeln!(
|
|
out,
|
|
"{:>8} {:>16} ch{:<3} rssi={:>5} q={:.3} {:?}",
|
|
frame.frame_id.value(),
|
|
frame.timestamp_ns,
|
|
frame.channel,
|
|
frame.rssi_dbm.map(|r| r.to_string()).unwrap_or_else(|| "-".into()),
|
|
frame.quality_score,
|
|
frame.validation,
|
|
)?;
|
|
}
|
|
n += 1;
|
|
if let Some(lim) = limit {
|
|
if n >= lim {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if !json {
|
|
writeln!(out, "-- {n} frame(s)")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi events <path>` — replay the capture through DSP + the event pipeline
|
|
/// and print the emitted events (compact, or full JSON with `json`).
|
|
pub fn events(out: &mut dyn Write, path: &str, json: bool) -> Result<()> {
|
|
let evs = runtime::events_from_capture(path).with_context(|| format!("processing {path}"))?;
|
|
if json {
|
|
writeln!(out, "{}", serde_json::to_string_pretty(&evs)?)?;
|
|
return Ok(());
|
|
}
|
|
for e in &evs {
|
|
writeln!(
|
|
out,
|
|
"{:>16} ns {:<22} conf={:.3} evidence={:?}{}",
|
|
e.timestamp_ns,
|
|
e.kind.slug(),
|
|
e.confidence,
|
|
e.evidence_window_ids.iter().map(|w| w.value()).collect::<Vec<_>>(),
|
|
e.calibration_version.as_deref().map(|c| format!(" calib={c}")).unwrap_or_default(),
|
|
)?;
|
|
}
|
|
writeln!(out, "-- {} event(s)", evs.len())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi health --source <slug> [--target <path>]` — open the source, drain it,
|
|
/// and print the final `SourceHealth` as JSON. File and Nexmon sources work
|
|
/// offline; live radios are not available in this build.
|
|
pub fn health(out: &mut dyn Write, source: &str, target: Option<&str>) -> Result<()> {
|
|
let h = match source {
|
|
"file" | "replay" => {
|
|
let path = target.context("`--target <path>` is required for the file source")?;
|
|
let mut a = FileReplayAdapter::open(path)?;
|
|
while a.next_frame()?.is_some() {}
|
|
a.health()
|
|
}
|
|
"nexmon" => {
|
|
let path = target.context("`--target <path>` is required for the nexmon source")?;
|
|
let bytes = std::fs::read(path)?;
|
|
let mut a = NexmonAdapter::from_bytes(SourceId::from("nexmon"), SessionId(0), bytes);
|
|
// pull until exhausted or a malformed record stops us
|
|
while let Ok(Some(_)) = a.next_frame() {}
|
|
a.health()
|
|
}
|
|
"esp32" | "intel" | "atheros" => {
|
|
anyhow::bail!("live capture for source `{source}` is not available in this build; use the `rvcsi-daemon` (not yet shipped) or replay a `.rvcsi` capture");
|
|
}
|
|
other => anyhow::bail!("unknown source `{other}` (expected: file, replay, nexmon, esp32, intel, atheros)"),
|
|
};
|
|
writeln!(out, "{}", serde_json::to_string_pretty(&h)?)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi export ruvector --in <capture> --out <jsonl>` — window the capture and
|
|
/// store each window's embedding into a JSONL RF-memory file.
|
|
pub fn export_ruvector(out: &mut dyn Write, capture: &str, out_jsonl: &str) -> Result<()> {
|
|
let stored = runtime::export_capture_to_rf_memory(capture, out_jsonl)
|
|
.with_context(|| format!("exporting {capture} -> {out_jsonl}"))?;
|
|
writeln!(out, "stored {stored} window embedding(s) to {out_jsonl}")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `rvcsi calibrate --in <capture> [--out <baseline.json>]` — a v0 calibration:
|
|
/// learn the per-subcarrier mean amplitude (the "baseline") over all exposable
|
|
/// frames in a capture and emit it as JSON. Real, versioned, room-scoped
|
|
/// calibration (ADR-095 D14) lands with the daemon.
|
|
pub fn calibrate(out: &mut dyn Write, capture: &str, out_path: Option<&str>) -> Result<()> {
|
|
let (header, frames) = read_all(capture).with_context(|| format!("reading {capture}"))?;
|
|
let exposable: Vec<&CsiFrame> = frames.iter().filter(|f| f.is_exposable()).collect();
|
|
if exposable.is_empty() {
|
|
anyhow::bail!("no exposable frames in {capture} — cannot calibrate");
|
|
}
|
|
let n = exposable[0].subcarrier_count as usize;
|
|
let mut acc = vec![0.0f64; n];
|
|
let mut count = 0usize;
|
|
for f in &exposable {
|
|
if f.subcarrier_count as usize != n {
|
|
continue;
|
|
}
|
|
for (a, v) in acc.iter_mut().zip(f.amplitude.iter()) {
|
|
*a += *v as f64;
|
|
}
|
|
count += 1;
|
|
}
|
|
let baseline: Vec<f32> = acc.iter().map(|a| (*a / count.max(1) as f64) as f32).collect();
|
|
#[derive(serde::Serialize)]
|
|
struct Baseline<'a> {
|
|
source_id: &'a str,
|
|
session_id: u64,
|
|
version: String,
|
|
subcarrier_count: usize,
|
|
frames_used: usize,
|
|
baseline_amplitude: Vec<f32>,
|
|
}
|
|
let payload = Baseline {
|
|
source_id: header.source_id.as_str(),
|
|
session_id: header.session_id.value(),
|
|
version: format!("{}@auto-{count}", header.source_id.as_str()),
|
|
subcarrier_count: n,
|
|
frames_used: count,
|
|
baseline_amplitude: baseline,
|
|
};
|
|
let json = serde_json::to_string_pretty(&payload)?;
|
|
if let Some(p) = out_path {
|
|
std::fs::write(p, &json)?;
|
|
writeln!(out, "wrote baseline ({n} subcarriers, {count} frames) to {p}")?;
|
|
} else {
|
|
writeln!(out, "{json}")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rvcsi_adapter_nexmon::{encode_record, NexmonRecord};
|
|
use rvcsi_core::{FrameId, ValidationStatus};
|
|
|
|
fn write_capture(path: &std::path::Path, n: usize) {
|
|
let header = CaptureHeader::new(
|
|
SessionId(2),
|
|
SourceId::from("cli-it"),
|
|
AdapterProfile::offline(AdapterKind::File),
|
|
);
|
|
let mut rec = FileRecorder::create(path, &header).unwrap();
|
|
for k in 0..n {
|
|
let amp_scale = if (k / 8) % 2 == 0 { 0.0 } else { 1.5 };
|
|
let i: Vec<f32> = (0..32).map(|s| 1.0 + amp_scale * (((k + s) % 5) as f32 - 2.0)).collect();
|
|
let q: Vec<f32> = (0..32).map(|_| 0.5).collect();
|
|
let mut f = CsiFrame::from_iq(
|
|
FrameId(k as u64),
|
|
SessionId(2),
|
|
SourceId::from("cli-it"),
|
|
AdapterKind::File,
|
|
1_000 + k as u64 * 50_000_000,
|
|
6,
|
|
20,
|
|
i,
|
|
q,
|
|
)
|
|
.with_rssi(-55);
|
|
f.validation = ValidationStatus::Accepted;
|
|
f.quality_score = 0.9;
|
|
rec.write_frame(&f).unwrap();
|
|
}
|
|
rec.finish().unwrap();
|
|
}
|
|
|
|
fn run<F: FnOnce(&mut Vec<u8>) -> Result<()>>(f: F) -> String {
|
|
let mut buf = Vec::new();
|
|
f(&mut buf).unwrap();
|
|
String::from_utf8(buf).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn inspect_human_and_json() {
|
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
write_capture(tmp.path(), 12);
|
|
let p = tmp.path().to_str().unwrap();
|
|
let human = run(|o| inspect(o, p, false));
|
|
assert!(human.contains("frames : 12"));
|
|
assert!(human.contains("channels : [6]"));
|
|
let json = run(|o| inspect(o, p, true));
|
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(v["frame_count"], 12);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_compact_and_json_and_limit() {
|
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
write_capture(tmp.path(), 5);
|
|
let p = tmp.path().to_str().unwrap();
|
|
let compact = run(|o| replay(o, p, false, None));
|
|
assert!(compact.contains("-- 5 frame(s)"));
|
|
let json = run(|o| replay(o, p, true, Some(3)));
|
|
assert_eq!(json.lines().count(), 3);
|
|
for line in json.lines() {
|
|
let _: CsiFrame = serde_json::from_str(line).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn events_command_emits_something() {
|
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
write_capture(tmp.path(), 64);
|
|
let p = tmp.path().to_str().unwrap();
|
|
let out = run(|o| events(o, p, false));
|
|
assert!(out.contains("event(s)"));
|
|
let json = run(|o| events(o, p, true));
|
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
assert!(v.is_array());
|
|
}
|
|
|
|
#[test]
|
|
fn health_file_source() {
|
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
write_capture(tmp.path(), 7);
|
|
let p = tmp.path().to_str().unwrap();
|
|
let out = run(|o| health(o, "file", Some(p)));
|
|
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
|
|
assert_eq!(v["frames_delivered"], 7);
|
|
assert_eq!(v["connected"], false);
|
|
// unknown / live sources error cleanly
|
|
let mut buf = Vec::new();
|
|
assert!(health(&mut buf, "esp32", Some(p)).is_err());
|
|
assert!(health(&mut buf, "bogus", None).is_err());
|
|
assert!(health(&mut buf, "file", None).is_err()); // missing --target
|
|
}
|
|
|
|
#[test]
|
|
fn export_and_calibrate() {
|
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
write_capture(tmp.path(), 64);
|
|
let p = tmp.path().to_str().unwrap();
|
|
let out_jsonl = tempfile::NamedTempFile::new().unwrap();
|
|
let out = run(|o| export_ruvector(o, p, out_jsonl.path().to_str().unwrap()));
|
|
assert!(out.contains("stored "));
|
|
// calibrate to stdout
|
|
let calib = run(|o| calibrate(o, p, None));
|
|
let v: serde_json::Value = serde_json::from_str(&calib).unwrap();
|
|
assert_eq!(v["subcarrier_count"], 32);
|
|
assert!(v["baseline_amplitude"].as_array().unwrap().len() == 32);
|
|
// calibrate to file
|
|
let baseline_file = tempfile::NamedTempFile::new().unwrap();
|
|
let out2 = run(|o| calibrate(o, p, Some(baseline_file.path().to_str().unwrap())));
|
|
assert!(out2.contains("wrote baseline"));
|
|
let written = std::fs::read_to_string(baseline_file.path()).unwrap();
|
|
assert!(written.contains("baseline_amplitude"));
|
|
}
|
|
|
|
#[test]
|
|
fn record_from_nexmon_then_inspect_and_replay() {
|
|
// build a small Nexmon record dump (64-subcarrier, the default profile)
|
|
let mut dump = Vec::new();
|
|
for k in 0..6u64 {
|
|
let rec = NexmonRecord {
|
|
subcarrier_count: 64,
|
|
channel: 36,
|
|
bandwidth_mhz: 80,
|
|
rssi_dbm: Some(-60 - k as i16),
|
|
noise_floor_dbm: Some(-92),
|
|
timestamp_ns: 1_000 + k * 50_000_000,
|
|
i_values: (0..64).map(|s| (s as f32 % 3.0) - 1.0).collect(),
|
|
q_values: (0..64).map(|s| (s as f32 % 5.0) * 0.1).collect(),
|
|
};
|
|
dump.extend(encode_record(&rec).unwrap());
|
|
}
|
|
let dump_file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(dump_file.path(), &dump).unwrap();
|
|
let cap_file = tempfile::NamedTempFile::new().unwrap();
|
|
|
|
let out = run(|o| {
|
|
record_from_nexmon(
|
|
o,
|
|
dump_file.path().to_str().unwrap(),
|
|
cap_file.path().to_str().unwrap(),
|
|
"nexmon-rec",
|
|
3,
|
|
)
|
|
});
|
|
assert!(out.contains("recorded 6 frame(s)"), "{out}");
|
|
|
|
// the produced capture is a real .rvcsi the other commands can read
|
|
let summary = run(|o| inspect(o, cap_file.path().to_str().unwrap(), false));
|
|
assert!(summary.contains("frames : 6"));
|
|
assert!(summary.contains("source : nexmon-rec"));
|
|
let replayed = run(|o| replay(o, cap_file.path().to_str().unwrap(), false, None));
|
|
assert!(replayed.contains("-- 6 frame(s)"));
|
|
}
|
|
|
|
#[test]
|
|
fn nexmon_pcap_record_and_inspect_roundtrip() {
|
|
use rvcsi_adapter_nexmon::NexmonCsiHeader;
|
|
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz
|
|
let nsub = 256u16;
|
|
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..8u64)
|
|
.map(|k| {
|
|
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
|
|
let q: Vec<f32> = (0..nsub).map(|s| (s as i16 % 5 + k as i16) as f32).collect();
|
|
(
|
|
1_000_000_000 + k * 50_000_000,
|
|
NexmonCsiHeader {
|
|
rssi_dbm: -55 - k as i16,
|
|
fctl: 8,
|
|
src_mac: [0, 1, 2, 3, 4, 5],
|
|
seq_cnt: k as u16,
|
|
core: 0,
|
|
spatial_stream: 0,
|
|
chanspec,
|
|
chip_ver: 0x4345,
|
|
channel: 0,
|
|
bandwidth_mhz: 0,
|
|
is_5ghz: false,
|
|
subcarrier_count: nsub,
|
|
},
|
|
i,
|
|
q,
|
|
)
|
|
})
|
|
.collect();
|
|
let pcap_bytes = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap();
|
|
let pcap_file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(pcap_file.path(), &pcap_bytes).unwrap();
|
|
let pcap_path = pcap_file.path().to_str().unwrap();
|
|
|
|
// inspect-nexmon (human + json) — chip_ver 0x4345 resolves to the BCM43455c0
|
|
// (the Raspberry Pi 3B+/4/400/5 chip)
|
|
let human = run(|o| inspect_nexmon(o, pcap_path, None, false));
|
|
assert!(human.contains("CSI frames : 8"), "{human}");
|
|
assert!(human.contains("channels : [36]"));
|
|
assert!(human.contains("0x4345"));
|
|
assert!(human.contains("chip : bcm43455c0"), "{human}");
|
|
let j = run(|o| inspect_nexmon(o, pcap_path, None, true));
|
|
let v: serde_json::Value = serde_json::from_str(&j).unwrap();
|
|
assert_eq!(v["csi_frame_count"], 8);
|
|
assert_eq!(v["bandwidths_mhz"][0], 80);
|
|
assert_eq!(v["detected_chip"], "bcm43455c0");
|
|
assert_eq!(v["chip_names"][0], "bcm43455c0");
|
|
|
|
// record --source nexmon-pcap --chip pi5 -> .rvcsi; the 256-sc VHT80 ch36
|
|
// frames all fit a Raspberry Pi 5 (BCM43455c0)
|
|
let cap_file = tempfile::NamedTempFile::new().unwrap();
|
|
let cap_path = cap_file.path().to_str().unwrap();
|
|
let out = run(|o| record_from_nexmon_pcap(o, pcap_path, cap_path, "nx-pcap", 3, None, Some("pi5")));
|
|
assert!(out.contains("recorded 8 frame(s)") && out.contains("chip pi5"), "{out}");
|
|
let summary = run(|o| inspect(o, cap_path, false));
|
|
assert!(summary.contains("frames : 8"));
|
|
assert!(summary.contains("source : nx-pcap"));
|
|
assert!(summary.contains("channels : [36]"));
|
|
assert!(summary.contains("pi5"), "{summary}"); // the Pi 5 profile was stamped on the capture
|
|
|
|
// --chip pizero2w (2.4 GHz only, ≤128 sc) drops every 256-sc frame
|
|
let cap2 = tempfile::NamedTempFile::new().unwrap();
|
|
let out2 = run(|o| record_from_nexmon_pcap(o, pcap_path, cap2.path().to_str().unwrap(), "z", 0, None, Some("pizero2w")));
|
|
assert!(out2.contains("recorded 0 frame(s)"), "{out2}");
|
|
// unknown --chip is an error
|
|
let mut buf = Vec::new();
|
|
assert!(record_from_nexmon_pcap(&mut buf, pcap_path, cap_path, "x", 0, None, Some("not-a-chip")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn nexmon_chips_listing_includes_pi5() {
|
|
let human = run(|o| nexmon_chips_cmd(o, false));
|
|
assert!(human.contains("bcm43455c0"), "{human}");
|
|
assert!(human.contains("pi5"), "{human}");
|
|
assert!(human.to_lowercase().contains("raspberry pi"), "{human}");
|
|
let j = run(|o| nexmon_chips_cmd(o, true));
|
|
let v: serde_json::Value = serde_json::from_str(&j).unwrap();
|
|
let chips = v["chips"].as_array().unwrap();
|
|
assert!(chips.iter().any(|c| c["slug"] == "bcm43455c0"));
|
|
let pis = v["raspberry_pi_models"].as_array().unwrap();
|
|
let pi5 = pis.iter().find(|m| m["slug"] == "pi5").expect("pi5 in listing");
|
|
assert_eq!(pi5["chip"], "bcm43455c0");
|
|
assert_eq!(pi5["csi_supported"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_chanspec_command() {
|
|
let out = run(|o| decode_chanspec_cmd(o, "0xe024", false)); // 5G | BW80(0x2000) | ch36 ... 0xe024 = 0xc000|0x2000|0x24
|
|
assert!(out.contains("channel 36"), "{out}");
|
|
assert!(out.contains("80 MHz"));
|
|
assert!(out.contains("5 GHz"));
|
|
let out = run(|o| decode_chanspec_cmd(o, "4102", false)); // 0x1006 = BW20(0x1000)|ch6
|
|
assert!(out.contains("channel 6"));
|
|
assert!(out.contains("2.4 GHz"));
|
|
let j = run(|o| decode_chanspec_cmd(o, "0x1006", true));
|
|
let v: serde_json::Value = serde_json::from_str(&j).unwrap();
|
|
assert_eq!(v["channel"], 6);
|
|
// bad input errors cleanly
|
|
let mut buf = Vec::new();
|
|
assert!(decode_chanspec_cmd(&mut buf, "0xZZZZ", false).is_err());
|
|
assert!(decode_chanspec_cmd(&mut buf, "not-a-number", false).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn errors_on_missing_capture() {
|
|
let mut buf = Vec::new();
|
|
assert!(inspect(&mut buf, "/no/such/file.rvcsi", false).is_err());
|
|
assert!(replay(&mut buf, "/no/such/file.rvcsi", false, None).is_err());
|
|
assert!(events(&mut buf, "/no/such/file.rvcsi", false).is_err());
|
|
assert!(calibrate(&mut buf, "/no/such/file.rvcsi", None).is_err());
|
|
assert!(record_from_nexmon(&mut buf, "/no/x.bin", "/tmp/y.rvcsi", "s", 0).is_err());
|
|
assert!(record_from_nexmon_pcap(&mut buf, "/no/x.pcap", "/tmp/y.rvcsi", "s", 0, None, None).is_err());
|
|
assert!(inspect_nexmon(&mut buf, "/no/such/file.pcap", None, false).is_err());
|
|
}
|
|
}
|