mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-23 15:13:03 -05:00
feat(calibrate-serve): native ADR-018 v6 CSI source
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 (<IBBHBbb5sI): magic, node_id, n_antennas,
n_subcarriers(u16), channel, rssi, noise, reserved(5), ts_us; then
n_subcarriers*2 IQ bytes. IQ bytes are the high byte of an i16 —
sign-extended ×256 to recover magnitude, matching the cognitum reference
parser. Bounds-checked exactly like the v1 parser (len<20 guard, magic
check, len<20+n_sub*2 guard before indexing IQ); no panic on truncated
or malformed input.
- New `--source-format {esp32|adr018v6}` clap ValueEnum on
CalibrateServeArgs (esp32 = default, preserves current behaviour). The
UDP ingest task dispatches to the right parser via SourceFormat::parse.
- Unit tests for the v6 parser (valid / truncated / wrong-magic) mirroring
the existing v1 tests, plus a SourceFormat dispatch test.
Usage: calibrate-serve --source-format adr018v6 --udp-port <p>
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 <ruv@ruv.net>
This commit is contained in:
1 parent
0f8c5c919b
commit
50a59cd5ab
2 files changed
+200
-5
No files matched your search
@@ -309,6 +309,96 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
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, `<IBBHBbb5sI`): 20-byte header then IQ.
|
||||
///
|
||||
/// Offset Size Field
|
||||
/// ────── ──── ─────────────────────────────────────────────────────────────
|
||||
/// 0 4 Magic: 0xC511_0006 (LE u32)
|
||||
/// 4 1 node_id (u8)
|
||||
/// 5 1 n_antennas (u8)
|
||||
/// 6 2 n_subcarriers (LE u16)
|
||||
/// 8 1 channel (u8)
|
||||
/// 9 1 rssi (i8)
|
||||
/// 10 1 noise_floor (i8)
|
||||
/// 11 5 (reserved)
|
||||
/// 16 4 ts_us (LE u32)
|
||||
/// 20 2 × n_subcarriers IQ pairs: I-high (i8), Q-high (i8)
|
||||
///
|
||||
/// The IQ bytes are the high byte of an i16, so they are sign-extended ×256 to
|
||||
/// recover magnitude — matching the cognitum reference parser.
|
||||
pub(crate) fn parse_csi_packet_adr018v6(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
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<Complex64> 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::<Complex64>::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);
|
||||
|
||||
@@ -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<CsiFrame> {
|
||||
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<RwLock<SharedStatus>>,
|
||||
default_tier: String,
|
||||
output_dir: String,
|
||||
source_format: SourceFormat,
|
||||
) {
|
||||
let mut buf = vec![0u8; RECV_BUF];
|
||||
let mut active: Option<ActiveSession> = 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]
|
||||
|
||||
Reference in new issue
Block a user