From 50a59cd5abdbeb0e9c44bbeea048494dfb710a68 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 9 Jun 2026 13:43:09 -0400 Subject: [PATCH] feat(calibrate-serve): native ADR-018 v6 CSI source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ADR-018 v6 (0xC5110006) as a second supported UDP wire format for `calibrate-serve`, alongside the existing ESP32 v1 (0xC5110001) format. v6 is what the Cognitum V0 appliance's `cog-csi-nexmon-adapter` emits, so the appliance's own nexmon CSI stream can drive empty-room baseline calibration directly — no external transcoder needed. - New `parse_csi_packet_adr018v6()` parser in calibrate.rs. Same CsiFrame return shape as `parse_csi_packet`, so the recorder path is unchanged. 20-byte LE header ( Verified: `cargo test -p wifi-densepose-cli --no-default-features` (20/20) and aarch64 cross-compile (`cross build --release --no-default-features --target aarch64-unknown-linux-gnu -p wifi-densepose-cli`) both pass. Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-cli/src/calibrate.rs | 139 ++++++++++++++++++ .../wifi-densepose-cli/src/calibrate_api.rs | 66 ++++++++- 2 files changed, 200 insertions(+), 5 deletions(-) diff --git a/v2/crates/wifi-densepose-cli/src/calibrate.rs b/v2/crates/wifi-densepose-cli/src/calibrate.rs index 17d50b273..c614bb5d1 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate.rs @@ -309,6 +309,96 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option { Some(CsiFrame::new(meta, data)) } +/// Parse a single UDP datagram in ADR-018 v6 (`0xC511_0006`) wire format and +/// return a `CsiFrame` ready for `CalibrationRecorder::record()`. Same return +/// shape as [`parse_csi_packet`], so the recorder path is identical regardless +/// of source format. Returns `None` on any parse failure (no panic on +/// truncated or malformed input). +/// +/// This is the format emitted by the Cognitum V0 appliance's +/// `cog-csi-nexmon-adapter`, letting the appliance's own nexmon CSI stream +/// drive calibration without an external transcoder. +/// +/// Layout (little-endian, ` Option { + if buf.len() < 20 { + return None; + } + let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + if magic != 0xC511_0006 { + return None; + } + + let node_id = buf[4]; + let n_antennas = buf[5] as usize; + let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize; + let channel = buf[8]; + let rssi = buf[9] as i8; + let noise_floor = buf[10] as i8; + let _ts_us = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); + + // v6 carries n_subcarriers*2 IQ bytes (single antenna's worth on the wire). + let iq_start = 20usize; + if buf.len() < iq_start + n_subcarriers * 2 { + return None; + } + + // Build an ndarray Array2 shaped [n_antennas, n_subcarriers]. + // The single on-wire IQ row is replicated across the declared antennas so + // the recorder sees a consistent [n_ant, n_sub] frame. + let mut data = Array2::::zeros((n_antennas.max(1), n_subcarriers.max(1))); + for k in 0..n_subcarriers { + // i8 high byte of an i16 → sign-extend ×256 to recover magnitude. + let i_val = (buf[iq_start + k * 2] as i8 as f64) * 256.0; + let q_val = (buf[iq_start + k * 2 + 1] as i8 as f64) * 256.0; + for s in 0..n_antennas.max(1) { + data[[s, k]] = Complex64::new(i_val, q_val); + } + } + + // v6 carries a channel number, not a centre frequency. Infer the band from + // the channel (1..=14 → 2.4 GHz, otherwise 5 GHz). + let band = if channel <= 14 { + FrequencyBand::Band2_4GHz + } else { + FrequencyBand::Band5GHz + }; + let bw = tier_to_bw_mhz(tier); + + let mut meta = CsiMetadata::new( + DeviceId::new(format!("nexmon-node{}", node_id)), + band, + channel, + ); + meta.bandwidth_mhz = bw; + meta.rssi_dbm = rssi; + meta.noise_floor_dbm = noise_floor; + meta.antenna_config = AntennaConfig { + tx_antennas: 1, + rx_antennas: n_antennas.max(1) as u8, + spacing_mm: None, + }; + meta.timestamp = Timestamp::now(); + + Some(CsiFrame::new(meta, data)) +} + /// Map a tier string to a bandwidth in MHz. fn tier_to_bw_mhz(tier: &str) -> u16 { match tier.to_ascii_lowercase().as_str() { @@ -434,6 +524,55 @@ mod tests { assert_eq!(f.num_subcarriers(), 2); } + #[test] + fn test_parse_csi_packet_adr018v6_bad_magic() { + // A v1-magic frame must NOT parse as v6. + let mut buf = vec![0u8; 24]; + buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5; // 0xC5110001 + assert!(parse_csi_packet_adr018v6(&buf, "ht20").is_none()); + } + + #[test] + fn test_parse_csi_packet_adr018v6_too_short() { + let buf = vec![0u8; 10]; + assert!(parse_csi_packet_adr018v6(&buf, "ht20").is_none()); + } + + #[test] + fn test_parse_csi_packet_adr018v6_truncated_payload() { + // Header claims 4 subcarriers (=> 8 IQ bytes) but only 4 are present. + let mut buf = vec![0u8; 24]; // 20-byte header + 4 IQ bytes + buf[0] = 0x06; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5; // 0xC5110006 + buf[5] = 1; // n_antennas + buf[6] = 4; buf[7] = 0; // n_subcarriers = 4 (LE u16) → needs 8 IQ bytes + assert!(parse_csi_packet_adr018v6(&buf, "ht20").is_none()); + } + + #[test] + fn test_parse_csi_packet_adr018v6_valid() { + // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers). + let mut buf = vec![0u8; 24]; + // Magic 0xC511_0006 LE + buf[0] = 0x06; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5; + buf[4] = 7; // node_id + buf[5] = 1; // n_antennas + buf[6] = 2; buf[7] = 0; // n_subcarriers = 2 (LE u16) + buf[8] = 6; // channel 6 (2.4 GHz) + buf[9] = (-42i8) as u8; // rssi + buf[10] = (-90i8) as u8; // noise_floor + // ts_us at offset 16 + buf[16] = 0x01; + // IQ high bytes at offset 20: (10, 20), (−5, 15) + buf[20] = 10i8 as u8; buf[21] = 20i8 as u8; + buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8; + + let frame = parse_csi_packet_adr018v6(&buf, "ht20"); + assert!(frame.is_some()); + let f = frame.unwrap(); + assert_eq!(f.num_spatial_streams(), 1); + assert_eq!(f.num_subcarriers(), 2); + } + #[test] fn test_freq_to_channel_24ghz() { assert_eq!(freq_mhz_to_channel(2437), 6); diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index ca0b0e22b..9b5d496c1 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -31,17 +31,41 @@ use axum::{ routing::{get, post}, Json, Router, }; -use clap::Args; +use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use tokio::net::UdpSocket; use tokio::sync::{mpsc, oneshot, RwLock}; use tower_http::cors::CorsLayer; +use wifi_densepose_core::types::CsiFrame; use wifi_densepose_signal::{BaselineCalibration, CalibrationRecorder}; -use crate::calibrate::{parse_csi_packet, tier_config}; +use crate::calibrate::{parse_csi_packet, parse_csi_packet_adr018v6, tier_config}; const RECV_BUF: usize = 2048; +/// Supported CSI UDP wire formats for the ingest socket. +#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SourceFormat { + /// ESP32 `0xC5110001` frames (default — original behaviour). + #[default] + Esp32, + /// ADR-018 v6 `0xC5110006` frames, as emitted by the Cognitum V0 + /// appliance's `cog-csi-nexmon-adapter`. Lets the appliance's own nexmon + /// CSI stream drive calibration directly, without an external transcoder. + Adr018v6, +} + +impl SourceFormat { + /// Dispatch to the parser for this wire format. Both parsers return the + /// same `CsiFrame` shape so the recorder path is identical. + fn parse(self, buf: &[u8], tier: &str) -> Option { + match self { + SourceFormat::Esp32 => parse_csi_packet(buf, tier), + SourceFormat::Adr018v6 => parse_csi_packet_adr018v6(buf, tier), + } + } +} + // --------------------------------------------------------------------------- // CLI arguments // --------------------------------------------------------------------------- @@ -66,6 +90,14 @@ pub struct CalibrateServeArgs { #[arg(long, default_value = "0.0.0.0")] pub udp_bind: String, + /// CSI UDP wire format on the ingest socket. `esp32` (default) parses + /// `0xC5110001` ESP32 frames; `adr018v6` parses `0xC5110006` ADR-018 v6 + /// frames as emitted by the Cognitum V0 appliance's + /// `cog-csi-nexmon-adapter` — letting the appliance's own nexmon stream + /// drive calibration directly. + #[arg(long, value_enum, default_value_t = SourceFormat::Esp32)] + pub source_format: SourceFormat, + /// Default PHY tier when a start request omits one (ht20 / ht40 / he20 / he40). #[arg(long, default_value = "ht20")] pub tier: String, @@ -219,7 +251,10 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> { let socket = UdpSocket::bind(&udp_addr) .await .map_err(|e| anyhow::anyhow!("cannot bind UDP socket on {udp_addr}: {e}"))?; - eprintln!("[calibrate-serve] CSI ingest on udp://{udp_addr}"); + eprintln!( + "[calibrate-serve] CSI ingest on udp://{udp_addr} (source-format={:?})", + args.source_format + ); let status = Arc::new(RwLock::new(SharedStatus { udp_port: args.udp_port, @@ -235,8 +270,9 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> { let status = status.clone(); let default_tier = args.tier.clone(); let output_dir = args.output_dir.clone(); + let source_format = args.source_format; tokio::spawn(async move { - ingest_loop(socket, cmd_rx, status, default_tier, output_dir).await; + ingest_loop(socket, cmd_rx, status, default_tier, output_dir, source_format).await; }); } @@ -297,6 +333,7 @@ async fn ingest_loop( status: Arc>, default_tier: String, output_dir: String, + source_format: SourceFormat, ) { let mut buf = vec![0u8; RECV_BUF]; let mut active: Option = None; @@ -366,7 +403,7 @@ async fn ingest_loop( last_frame_ms = unix_ms(); if let Some(sess) = active.as_mut() { let tier = sess.tier.clone(); - if let Some(frame) = parse_csi_packet(&buf[..n], &tier) { + if let Some(frame) = source_format.parse(&buf[..n], &tier) { if let Ok(score) = sess.recorder.record(&frame) { sess.z_median = score.amplitude_z_median; sess.z_max = score.amplitude_z_max; @@ -656,9 +693,28 @@ mod tests { tier: "ht20".into(), output_dir: "./baselines".into(), token: None, + source_format: SourceFormat::Esp32, }; assert_eq!(a.http_port, 8090); assert_eq!(a.udp_port, 5005); + assert_eq!(a.source_format, SourceFormat::Esp32); + } + + #[test] + fn source_format_dispatch() { + // ESP32 frame (0xC5110001): only the esp32 parser accepts it. + let mut esp = vec![0u8; 24]; + esp[0] = 0x01; esp[1] = 0x00; esp[2] = 0x11; esp[3] = 0xC5; + esp[5] = 1; esp[6] = 2; // 1 antenna, 2 subcarriers + assert!(SourceFormat::Esp32.parse(&esp, "ht20").is_some()); + assert!(SourceFormat::Adr018v6.parse(&esp, "ht20").is_none()); + + // ADR-018 v6 frame (0xC5110006): only the adr018v6 parser accepts it. + let mut v6 = vec![0u8; 24]; + v6[0] = 0x06; v6[1] = 0x00; v6[2] = 0x11; v6[3] = 0xC5; + v6[5] = 1; v6[6] = 2; v6[7] = 0; // 1 antenna, n_subcarriers=2 (LE u16) + assert!(SourceFormat::Adr018v6.parse(&v6, "ht20").is_some()); + assert!(SourceFormat::Esp32.parse(&v6, "ht20").is_none()); } #[test]