mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
feat(rvcsi): Raspberry Pi 5 (BCM43455c0) + Nexmon chip registry
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
This commit is contained in:
@@ -58,10 +58,13 @@ pub fn record_from_nexmon(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `rvcsi record --source nexmon-pcap --in <csi.pcap> --out <cap.rvcsi>` —
|
||||
/// `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).
|
||||
/// 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,
|
||||
@@ -69,21 +72,72 @@ pub fn record_from_nexmon_pcap(
|
||||
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(&bytes, source_id, session_id, port)
|
||||
let frames = runtime::decode_nexmon_pcap_for(&bytes, source_id, session_id, port, chip)
|
||||
.with_context(|| format!("parsing nexmon pcap {pcap_path}"))?;
|
||||
let header = CaptureHeader::new(
|
||||
SessionId(session_id),
|
||||
SourceId::from(source_id),
|
||||
AdapterProfile::nexmon_default(),
|
||||
);
|
||||
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()?;
|
||||
writeln!(out, "recorded {} frame(s) from {pcap_path} to {out_path}", frames.len())?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -115,6 +169,7 @@ pub fn inspect_nexmon(out: &mut dyn Write, pcap_path: &str, port: Option<u16>, j
|
||||
" 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)")?,
|
||||
@@ -166,6 +221,9 @@ pub fn inspect(out: &mut dyn Write, path: &str, json: bool) -> Result<()> {
|
||||
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,
|
||||
@@ -510,7 +568,7 @@ mod tests {
|
||||
core: 0,
|
||||
spatial_stream: 0,
|
||||
chanspec,
|
||||
chip_ver: 0x0142,
|
||||
chip_ver: 0x4345,
|
||||
channel: 0,
|
||||
bandwidth_mhz: 0,
|
||||
is_5ghz: false,
|
||||
@@ -526,25 +584,55 @@ mod tests {
|
||||
std::fs::write(pcap_file.path(), &pcap_bytes).unwrap();
|
||||
let pcap_path = pcap_file.path().to_str().unwrap();
|
||||
|
||||
// inspect-nexmon (human + json)
|
||||
// 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("0x0142"));
|
||||
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 -> .rvcsi, then the normal commands work on it
|
||||
// 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));
|
||||
assert!(out.contains("recorded 8 frame(s)"), "{out}");
|
||||
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]
|
||||
@@ -573,7 +661,7 @@ mod tests {
|
||||
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).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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,17 @@ enum Command {
|
||||
/// CSI UDP port (for `--source nexmon-pcap`; defaults to 5500).
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
/// Validate against a specific chip / Raspberry Pi model — e.g. `pi5`,
|
||||
/// `pi4`, `pi3b+`, `pizero2w`, `bcm43455c0`, `bcm4366c0` — dropping
|
||||
/// frames that don't fit it. Default: permissive (any subcarrier count).
|
||||
#[arg(long)]
|
||||
chip: Option<String>,
|
||||
},
|
||||
/// List the Broadcom/Cypress chips nexmon_csi runs on + the Raspberry Pi models (incl. Pi 5).
|
||||
NexmonChips {
|
||||
/// Emit JSON instead of a human listing.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Summarize a nexmon_csi `.pcap` file (link type, CSI frames, channels, ...).
|
||||
InspectNexmon {
|
||||
@@ -153,13 +164,14 @@ fn main() -> anyhow::Result<()> {
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
match cli.command {
|
||||
Command::Record { source, input, output, source_id, session, port } => match source.as_str() {
|
||||
Command::Record { source, input, output, source_id, session, port, chip } => match source.as_str() {
|
||||
"nexmon" => commands::record_from_nexmon(&mut out, &input, &output, &source_id, session)?,
|
||||
"nexmon-pcap" => {
|
||||
commands::record_from_nexmon_pcap(&mut out, &input, &output, &source_id, session, port)?
|
||||
}
|
||||
"nexmon-pcap" => commands::record_from_nexmon_pcap(
|
||||
&mut out, &input, &output, &source_id, session, port, chip.as_deref(),
|
||||
)?,
|
||||
other => anyhow::bail!("unknown --source `{other}` (expected `nexmon` or `nexmon-pcap`)"),
|
||||
},
|
||||
Command::NexmonChips { json } => commands::nexmon_chips_cmd(&mut out, json)?,
|
||||
Command::InspectNexmon { path, port, json } => commands::inspect_nexmon(&mut out, &path, port, json)?,
|
||||
Command::DecodeChanspec { chanspec, json } => commands::decode_chanspec_cmd(&mut out, &chanspec, json)?,
|
||||
Command::Inspect { path, json } => commands::inspect(&mut out, &path, json)?,
|
||||
|
||||
Reference in New Issue
Block a user