mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
feat(rvcsi): real nexmon_csi UDP/PCAP fidelity — chanspec decode, libpcap reader, NexmonPcapAdapter
Raises the Nexmon path from a normalized record format to parsing what the patched Broadcom firmware actually emits, end to end. napi-c shim (ABI 1.0 -> 1.1, additive): - rvcsi_nx_csi_udp_header / rvcsi_nx_csi_udp_decode — parse the real nexmon_csi UDP payload: the 18-byte header (magic 0x1111, rssi int8, fctl, src_mac[6], seq_cnt, core/spatial-stream, Broadcom chanspec, chip_ver) + nsub complex CSI samples (modern int16 LE I/Q export — what CSIKit/csireader.py read for the BCM43455c0 / 4358 / 4366c0; nsub = (len-18)/4). rvcsi_nx_csi_udp_write to synthesize payloads for tests. rvcsi_nx_decode_chanspec — d11ac chanspec -> channel (chanspec & 0xff) / bandwidth (bits [13:11], cross-checked against the FFT size) / band (bits [15:14], cross-checked against the channel number). Still allocation-free, bounds-checked, structured errors, never panics. - ffi.rs wraps it: decode_chanspec / parse_nexmon_udp_header / decode_nexmon_udp / encode_nexmon_udp + DecodedChanspec / NexmonCsiHeader; every unsafe block documented; the ABI guard now expects 1.1. rvcsi-adapter-nexmon: - pcap.rs — a dependency-free classic-libpcap reader (all four byte-order / timestamp-resolution magics; Ethernet / raw-IPv4 / Linux-SLL link types; tolerates a truncated final record; pcapng is a follow-up) + extract_udp_payload + a synthetic_udp_pcap / synthetic_nexmon_pcap test/example generator. - NexmonPcapAdapter (a CsiSource) — reads the CSI UDP packets out of a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture, decodes each via the C shim, stamps the frame timestamp from the pcap packet time; non-CSI packets counted as "skipped" in health. rvcsi-runtime: decode_nexmon_pcap, summarize_nexmon_pcap (+ NexmonPcapSummary: link type, CSI frame count, channels, bandwidths, subcarrier counts, chip versions, RSSI range, time span), CaptureRuntime::open_nexmon_pcap[_bytes]. rvcsi-node (napi-rs): nexmonDecodePcap, inspectNexmonPcap, decodeChanspec, RvcsiRuntime.openNexmonPcap. @ruv/rvcsi SDK + .d.ts updated (NexmonPcapSummary, DecodedChanspec). rvcsi-cli: `record --source nexmon-pcap`, `inspect-nexmon`, `decode-chanspec`. 161 rvcsi tests pass (adapter-nexmon 9->22), 0 failures, clippy-clean. ADR-096 §2.2/§2.3/§5, CHANGELOG, CLAUDE.md updated. https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
This commit is contained in:
@@ -65,6 +65,39 @@ impl CaptureRuntime {
|
||||
Ok(Self::open_nexmon_bytes(bytes, source_id, session_id))
|
||||
}
|
||||
|
||||
/// Open a real nexmon_csi `.pcap` capture (`tcpdump -i wlan0 dst port 5500 -w …`)
|
||||
/// as the source. `port` is the CSI UDP port (`None` ⇒ 5500).
|
||||
pub fn open_nexmon_pcap(
|
||||
path: &str,
|
||||
source_id: &str,
|
||||
session_id: u64,
|
||||
port: Option<u16>,
|
||||
) -> Result<Self, RvcsiError> {
|
||||
let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::open(
|
||||
SourceId::from(source_id),
|
||||
SessionId(session_id),
|
||||
path,
|
||||
port,
|
||||
)?;
|
||||
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
|
||||
}
|
||||
|
||||
/// Open a real nexmon_csi `.pcap` from an in-memory byte buffer.
|
||||
pub fn open_nexmon_pcap_bytes(
|
||||
pcap_bytes: &[u8],
|
||||
source_id: &str,
|
||||
session_id: u64,
|
||||
port: Option<u16>,
|
||||
) -> Result<Self, RvcsiError> {
|
||||
let source = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
|
||||
SourceId::from(source_id),
|
||||
SessionId(session_id),
|
||||
pcap_bytes,
|
||||
port,
|
||||
)?;
|
||||
Ok(Self::new(Box::new(source), ValidationPolicy::default()))
|
||||
}
|
||||
|
||||
/// Validate (if needed) a freshly pulled frame; `None` if it was hard-rejected.
|
||||
fn admit(&mut self, mut frame: CsiFrame) -> Option<CsiFrame> {
|
||||
self.frames_seen += 1;
|
||||
@@ -257,9 +290,61 @@ mod tests {
|
||||
assert_eq!(n, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_a_real_nexmon_csi_pcap() {
|
||||
use rvcsi_adapter_nexmon::NexmonCsiHeader;
|
||||
let chanspec = 0x1000u16 | 6; // 2.4 GHz ch6 20 MHz
|
||||
let nsub = 64u16;
|
||||
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..12u64)
|
||||
.map(|k| {
|
||||
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 32 + k as i16) as f32).collect();
|
||||
let q: Vec<f32> = (0..nsub).map(|_| 1.0f32).collect();
|
||||
(
|
||||
1_000_000_000 + k * 50_000_000,
|
||||
NexmonCsiHeader {
|
||||
rssi_dbm: -55 - k as i16,
|
||||
fctl: 8,
|
||||
src_mac: [0, 1, 2, 3, 4, 5],
|
||||
seq_cnt: k as u16,
|
||||
core: 0,
|
||||
spatial_stream: 0,
|
||||
chanspec,
|
||||
chip_ver: 0x0142,
|
||||
channel: 0,
|
||||
bandwidth_mhz: 0,
|
||||
is_5ghz: false,
|
||||
subcarrier_count: nsub,
|
||||
},
|
||||
i,
|
||||
q,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let pcap = rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).unwrap();
|
||||
let mut rt = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "nexmon-pcap-rt", 1, None).unwrap();
|
||||
let mut got = 0;
|
||||
while let Some(f) = rt.next_validated_frame().unwrap() {
|
||||
assert_eq!(f.adapter_kind, AdapterKind::Nexmon);
|
||||
assert_eq!(f.channel, 6);
|
||||
assert_eq!(f.bandwidth_mhz, 20);
|
||||
assert!(f.is_exposable());
|
||||
got += 1;
|
||||
}
|
||||
assert_eq!(got, 12);
|
||||
let events = {
|
||||
let mut rt2 = CaptureRuntime::open_nexmon_pcap_bytes(&pcap, "n", 2, None).unwrap();
|
||||
rt2.drain_events().unwrap()
|
||||
};
|
||||
for e in &events {
|
||||
e.validate().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_an_error() {
|
||||
assert!(CaptureRuntime::open_capture_file("/nope/x.rvcsi").is_err());
|
||||
assert!(CaptureRuntime::open_nexmon_file("/nope/x.bin", "s", 0).is_err());
|
||||
assert!(CaptureRuntime::open_nexmon_pcap("/nope/x.pcap", "s", 0, None).is_err());
|
||||
assert!(CaptureRuntime::open_nexmon_pcap_bytes(&[0u8; 8], "s", 0, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ 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,
|
||||
decode_nexmon_pcap, decode_nexmon_records, events_from_capture, export_capture_to_rf_memory,
|
||||
rf_memory_self_check, summarize_capture, summarize_nexmon_pcap, CaptureSummary,
|
||||
NexmonPcapSummary, ValidationBreakdown,
|
||||
};
|
||||
|
||||
/// ABI version of the linked napi-c Nexmon shim (re-exported for convenience).
|
||||
|
||||
@@ -119,33 +119,139 @@ pub fn summarize_capture(path: &str) -> Result<CaptureSummary, RvcsiError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into
|
||||
/// validated [`CsiFrame`]s. Each frame is run through [`validate_frame`] against
|
||||
/// a permissive profile (so synthetic / non-default subcarrier counts survive);
|
||||
/// frames that hard-fail validation are dropped (never returned to JS).
|
||||
pub fn decode_nexmon_records(
|
||||
bytes: &[u8],
|
||||
source_id: &str,
|
||||
session_id: u64,
|
||||
) -> Result<Vec<CsiFrame>, RvcsiError> {
|
||||
let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?;
|
||||
/// Validate a batch of raw (`Pending`) frames against a permissive profile, in
|
||||
/// timestamp order; drop the hard-rejected ones and return the survivors. Used
|
||||
/// for the Nexmon paths, where the firmware may report non-default subcarrier
|
||||
/// counts and we want everything decodable to flow.
|
||||
fn validate_frames_permissive(raw: Vec<CsiFrame>) -> Vec<CsiFrame> {
|
||||
let profile = AdapterProfile::offline(rvcsi_core::AdapterKind::Nexmon);
|
||||
let policy = ValidationPolicy::default();
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
let mut prev_ts: Option<u64> = None;
|
||||
for mut f in raw {
|
||||
let ts = f.timestamp_ns;
|
||||
match validate_frame(&mut f, &profile, &policy, prev_ts) {
|
||||
Ok(()) => {
|
||||
if f.is_exposable() {
|
||||
if f.validation == ValidationStatus::Pending {
|
||||
match validate_frame(&mut f, &profile, &policy, prev_ts) {
|
||||
Ok(()) if f.is_exposable() => {
|
||||
prev_ts = Some(ts);
|
||||
out.push(f);
|
||||
}
|
||||
_ => { /* hard-rejected — dropped */ }
|
||||
}
|
||||
Err(_) => { /* hard-rejected — dropped, not returned to JS */ }
|
||||
} else if f.is_exposable() {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode a buffer of "rvCSI Nexmon records" (the napi-c shim format) into
|
||||
/// validated [`CsiFrame`]s. Frames that hard-fail validation are dropped (never
|
||||
/// returned to JS).
|
||||
pub fn decode_nexmon_records(
|
||||
bytes: &[u8],
|
||||
source_id: &str,
|
||||
session_id: u64,
|
||||
) -> Result<Vec<CsiFrame>, RvcsiError> {
|
||||
let raw = NexmonAdapter::frames_from_bytes(SourceId::from(source_id), SessionId(session_id), bytes)?;
|
||||
Ok(validate_frames_permissive(raw))
|
||||
}
|
||||
|
||||
/// Decode the *real* nexmon_csi UDP payloads inside a libpcap (`.pcap`) buffer
|
||||
/// into validated [`CsiFrame`]s. `port` is the CSI UDP port (`None` ⇒ 5500).
|
||||
pub fn decode_nexmon_pcap(
|
||||
pcap_bytes: &[u8],
|
||||
source_id: &str,
|
||||
session_id: u64,
|
||||
port: Option<u16>,
|
||||
) -> Result<Vec<CsiFrame>, RvcsiError> {
|
||||
let raw = rvcsi_adapter_nexmon::NexmonPcapAdapter::frames_from_pcap_bytes(
|
||||
SourceId::from(source_id),
|
||||
SessionId(session_id),
|
||||
pcap_bytes,
|
||||
port,
|
||||
)?;
|
||||
Ok(validate_frames_permissive(raw))
|
||||
}
|
||||
|
||||
/// A compact summary of a nexmon_csi `.pcap` capture (the `rvcsi inspect-nexmon`
|
||||
/// payload).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NexmonPcapSummary {
|
||||
/// libpcap link-layer type of the capture.
|
||||
pub link_type: u32,
|
||||
/// CSI frames decoded from the capture.
|
||||
pub csi_frame_count: usize,
|
||||
/// Non-CSI / skipped UDP packets (wrong port, not IPv4/UDP, bad nexmon magic).
|
||||
pub skipped_packets: u64,
|
||||
/// First / last CSI packet timestamp (ns since the Unix epoch); `0` if empty.
|
||||
pub first_timestamp_ns: u64,
|
||||
/// Last CSI packet timestamp (ns).
|
||||
pub last_timestamp_ns: u64,
|
||||
/// Distinct WiFi channels seen (decoded from the chanspec).
|
||||
pub channels: Vec<u16>,
|
||||
/// Distinct bandwidths (MHz) seen.
|
||||
pub bandwidths_mhz: Vec<u16>,
|
||||
/// Distinct subcarrier (FFT) counts seen.
|
||||
pub subcarrier_counts: Vec<u16>,
|
||||
/// Distinct chip-version words seen (e.g. `0x0142` = BCM43455c0).
|
||||
pub chip_versions: Vec<u16>,
|
||||
/// Min / max RSSI (dBm) over the CSI packets; `None` if empty.
|
||||
pub rssi_dbm_range: Option<(i16, i16)>,
|
||||
}
|
||||
|
||||
/// Summarize a nexmon_csi `.pcap` file (link type, frame counts, channels, etc.).
|
||||
pub fn summarize_nexmon_pcap(path: &str, port: Option<u16>) -> Result<NexmonPcapSummary, RvcsiError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let adapter = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
|
||||
SourceId::from(format!("pcap:{path}")),
|
||||
SessionId(0),
|
||||
&bytes,
|
||||
port,
|
||||
)?;
|
||||
let health = adapter.health();
|
||||
let headers = adapter.headers();
|
||||
let mut channels = Vec::new();
|
||||
let mut bandwidths = Vec::new();
|
||||
let mut subs = Vec::new();
|
||||
let mut chips = Vec::new();
|
||||
let (mut rssi_lo, mut rssi_hi) = (i16::MAX, i16::MIN);
|
||||
for h in headers {
|
||||
channels.push(h.channel);
|
||||
bandwidths.push(h.bandwidth_mhz);
|
||||
subs.push(h.subcarrier_count);
|
||||
chips.push(h.chip_ver);
|
||||
rssi_lo = rssi_lo.min(h.rssi_dbm);
|
||||
rssi_hi = rssi_hi.max(h.rssi_dbm);
|
||||
}
|
||||
let (mut first_ts, mut last_ts) = (u64::MAX, 0u64);
|
||||
// re-iterate frames for timestamps (headers don't carry the pcap time)
|
||||
let mut a2 = rvcsi_adapter_nexmon::NexmonPcapAdapter::parse(
|
||||
SourceId::from("pcap-ts"),
|
||||
SessionId(0),
|
||||
&bytes,
|
||||
port,
|
||||
)?;
|
||||
use rvcsi_core::CsiSource;
|
||||
while let Some(f) = a2.next_frame()? {
|
||||
first_ts = first_ts.min(f.timestamp_ns);
|
||||
last_ts = last_ts.max(f.timestamp_ns);
|
||||
}
|
||||
if headers.is_empty() {
|
||||
first_ts = 0;
|
||||
}
|
||||
Ok(NexmonPcapSummary {
|
||||
link_type: adapter.link_type(),
|
||||
csi_frame_count: headers.len(),
|
||||
skipped_packets: health.frames_rejected,
|
||||
first_timestamp_ns: first_ts,
|
||||
last_timestamp_ns: last_ts,
|
||||
channels: sorted_unique(channels),
|
||||
bandwidths_mhz: sorted_unique(bandwidths),
|
||||
subcarrier_counts: sorted_unique(subs),
|
||||
chip_versions: sorted_unique(chips),
|
||||
rssi_dbm_range: (!headers.is_empty()).then_some((rssi_lo, rssi_hi)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Replay a `.rvcsi` capture through the DSP + event pipeline and collect every
|
||||
@@ -227,7 +333,7 @@ pub fn rf_memory_self_check(capture_path: &str) -> Result<(usize, f32), RvcsiErr
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rvcsi_adapter_file::FileRecorder;
|
||||
use rvcsi_adapter_nexmon::{encode_record, NexmonRecord};
|
||||
use rvcsi_adapter_nexmon::{encode_record, NexmonCsiHeader, NexmonRecord};
|
||||
use rvcsi_core::{AdapterKind, FrameId};
|
||||
|
||||
fn write_capture(path: &std::path::Path, n: usize) {
|
||||
@@ -350,5 +456,73 @@ mod tests {
|
||||
fn missing_capture_file_is_a_structured_error() {
|
||||
assert!(summarize_capture("/nonexistent/path/x.rvcsi").is_err());
|
||||
assert!(events_from_capture("/nonexistent/path/x.rvcsi").is_err());
|
||||
assert!(decode_nexmon_pcap(&[0u8; 8], "s", 0, None).is_err());
|
||||
assert!(summarize_nexmon_pcap("/nonexistent/path/x.pcap", None).is_err());
|
||||
}
|
||||
|
||||
fn synth_nexmon_header(rssi: i16, chanspec: u16, nsub: u16, seq: u16) -> NexmonCsiHeader {
|
||||
NexmonCsiHeader {
|
||||
rssi_dbm: rssi,
|
||||
fctl: 0x08,
|
||||
src_mac: [0, 1, 2, 3, 4, 5],
|
||||
seq_cnt: seq,
|
||||
core: 0,
|
||||
spatial_stream: 0,
|
||||
chanspec,
|
||||
chip_ver: 0x0142,
|
||||
channel: 0,
|
||||
bandwidth_mhz: 0,
|
||||
is_5ghz: false,
|
||||
subcarrier_count: nsub,
|
||||
}
|
||||
}
|
||||
|
||||
fn synth_nexmon_pcap_bytes() -> Vec<u8> {
|
||||
let chanspec = 0xc000u16 | 0x2000 | 36; // 5 GHz ch36 80 MHz
|
||||
let nsub = 256u16;
|
||||
let frames: Vec<(u64, NexmonCsiHeader, Vec<f32>, Vec<f32>)> = (0..4u64)
|
||||
.map(|k| {
|
||||
let i: Vec<f32> = (0..nsub).map(|s| (s as i16 - 128 + k as i16) as f32).collect();
|
||||
let q: Vec<f32> = (0..nsub).map(|s| (s as i16 % 7 + k as i16) as f32).collect();
|
||||
(1_000_000_000 + k * 50_000_000, synth_nexmon_header(-58 - k as i16, chanspec, nsub, k as u16 + 1), i, q)
|
||||
})
|
||||
.collect();
|
||||
rvcsi_adapter_nexmon::synthetic_nexmon_pcap(&frames, 5500).expect("build pcap")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_nexmon_pcap_yields_validated_frames() {
|
||||
let pcap = synth_nexmon_pcap_bytes();
|
||||
let frames = decode_nexmon_pcap(&pcap, "nexmon-pcap", 7, None).unwrap();
|
||||
assert_eq!(frames.len(), 4);
|
||||
for f in &frames {
|
||||
assert!(f.is_exposable());
|
||||
assert_eq!(f.adapter_kind, AdapterKind::Nexmon);
|
||||
assert_eq!(f.channel, 36);
|
||||
assert_eq!(f.bandwidth_mhz, 80);
|
||||
assert_eq!(f.subcarrier_count, 256);
|
||||
}
|
||||
assert_eq!(frames[0].timestamp_ns, 1_000_000_000);
|
||||
assert_eq!(frames[3].timestamp_ns, 1_000_000_000 + 3 * 50_000_000);
|
||||
// explicit-port form works too
|
||||
assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(5500)).unwrap().len(), 4);
|
||||
assert_eq!(decode_nexmon_pcap(&pcap, "s", 0, Some(9999)).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_nexmon_pcap_reports_metadata() {
|
||||
let pcap = synth_nexmon_pcap_bytes();
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(tmp.path(), &pcap).unwrap();
|
||||
let s = summarize_nexmon_pcap(tmp.path().to_str().unwrap(), None).unwrap();
|
||||
assert_eq!(s.link_type, rvcsi_adapter_nexmon::LINKTYPE_ETHERNET);
|
||||
assert_eq!(s.csi_frame_count, 4);
|
||||
assert_eq!(s.channels, vec![36]);
|
||||
assert_eq!(s.bandwidths_mhz, vec![80]);
|
||||
assert_eq!(s.subcarrier_counts, vec![256]);
|
||||
assert_eq!(s.chip_versions, vec![0x0142]);
|
||||
assert_eq!(s.rssi_dbm_range, Some((-61, -58)));
|
||||
assert_eq!(s.first_timestamp_ns, 1_000_000_000);
|
||||
assert!(s.last_timestamp_ns > s.first_timestamp_ns);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user