Extract ADR-018 parser into parser.rs + wire Fingerprint CLI
File-split (strong concern #9 in PR #405 review): csi_pipeline.rs was 602 LOC; extract the pure-function ADR-018 parser + synthetic frame builder into src/parser.rs. Inline unit tests in parser.rs cover: - 0xC5110001 (raw CSI, v1) roundtrip - 0xC5110006 (feature state, v6) roundtrip - wrong magic is rejected - truncated header is rejected - truncated payload is rejected main.rs: expose `fingerprint NAME [--seconds N]` subcommand wiring record_fingerprint() (this was the only caller needed to make the public API non-dead on the runtime path). Also: - Replace `--host/--port` + external `--csi` with a single `--bind` defaulting to loopback (`127.0.0.1:9880`) — addresses strong concern #7 about exposing camera/CSI/vitals by default. - Update synthetic `csi-test` to target UDP 3333 (matching the ADR-018 listener) and use the shared parser::build_test_frame. - Defence-in-depth: call training::sanitize_data_path on the expanded --data-dir before TrainingSession::new does the same. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
4d5bdb1570
commit
770788fc85
|
|
@ -4,23 +4,18 @@
|
|||
//!
|
||||
//! Usage:
|
||||
//! ruview-pointcloud serve # HTTP + Three.js viewer
|
||||
//! ruview-pointcloud serve --csi 0.0.0.0:9890 # with live WiFi CSI
|
||||
//! ruview-pointcloud capture --frames 1 # capture to PLY
|
||||
//! ruview-pointcloud demo # synthetic demo
|
||||
//! ruview-pointcloud train # calibration training
|
||||
//! ruview-pointcloud csi-test # send test CSI frames
|
||||
//! ruview-pointcloud csi-test # send test CSI frames (ADR-018 binary)
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod brain_bridge;
|
||||
mod camera;
|
||||
#[allow(dead_code)]
|
||||
mod csi;
|
||||
mod csi_pipeline;
|
||||
mod depth;
|
||||
mod fusion;
|
||||
mod parser;
|
||||
mod pointcloud;
|
||||
#[allow(dead_code)]
|
||||
mod serial_csi;
|
||||
mod stream;
|
||||
mod training;
|
||||
|
||||
|
|
@ -38,15 +33,16 @@ struct Cli {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Start real-time point cloud server
|
||||
/// Start real-time point cloud server.
|
||||
///
|
||||
/// By default the HTTP server binds to `127.0.0.1:9880` — exposing it on
|
||||
/// `0.0.0.0` leaks live camera/CSI/vitals data to the network and must
|
||||
/// be an explicit opt-in via `--bind 0.0.0.0:9880`.
|
||||
Serve {
|
||||
#[arg(long, default_value = "0.0.0.0")]
|
||||
host: String,
|
||||
#[arg(long, default_value = "9880")]
|
||||
port: u16,
|
||||
/// WiFi CSI listen address (e.g., 0.0.0.0:9890)
|
||||
#[arg(long)]
|
||||
csi: Option<String>,
|
||||
/// Bind address for the HTTP/viewer server. Default
|
||||
/// `127.0.0.1:9880` (loopback only — safe by default).
|
||||
#[arg(long, default_value = "127.0.0.1:9880")]
|
||||
bind: String,
|
||||
/// Brain URL for storing observations
|
||||
#[arg(long)]
|
||||
brain: Option<String>,
|
||||
|
|
@ -70,13 +66,25 @@ enum Commands {
|
|||
#[arg(long)]
|
||||
brain: Option<String>,
|
||||
},
|
||||
/// Send test CSI frames (for testing without ESP32)
|
||||
/// Send synthetic ADR-018 binary CSI frames (for local testing without ESP32).
|
||||
CsiTest {
|
||||
#[arg(long, default_value = "127.0.0.1:9890")]
|
||||
#[arg(long, default_value = "127.0.0.1:3333")]
|
||||
target: String,
|
||||
#[arg(long, default_value = "100")]
|
||||
count: usize,
|
||||
},
|
||||
/// Record a CSI fingerprint for the current location.
|
||||
///
|
||||
/// Listens on UDP 3333 for `--seconds` seconds, accumulates CSI frames,
|
||||
/// and stores a named fingerprint that future sessions can match
|
||||
/// against to identify the room.
|
||||
Fingerprint {
|
||||
/// Human-readable name for the fingerprint (e.g. "office", "lab").
|
||||
name: String,
|
||||
/// How long to listen before recording (default 5 s).
|
||||
#[arg(long, default_value = "5")]
|
||||
seconds: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -84,14 +92,8 @@ async fn main() -> Result<()> {
|
|||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Serve { host, port, csi, brain } => {
|
||||
// Start CSI receiver if configured
|
||||
if let Some(csi_addr) = &csi {
|
||||
let receiver = csi::CsiReceiver::new(csi_addr);
|
||||
receiver.start()?;
|
||||
eprintln!(" CSI receiver: {csi_addr}");
|
||||
}
|
||||
stream::serve(&host, port, brain.as_deref()).await?;
|
||||
Commands::Serve { bind, brain } => {
|
||||
stream::serve(&bind, brain.as_deref()).await?;
|
||||
}
|
||||
Commands::Capture { frames: _, output } => {
|
||||
if camera::camera_available() {
|
||||
|
|
@ -126,10 +128,26 @@ async fn main() -> Result<()> {
|
|||
train(&data_dir, brain.as_deref()).await?;
|
||||
}
|
||||
Commands::CsiTest { target, count } => {
|
||||
println!("Sending {count} test CSI frames to {target}...");
|
||||
csi::send_test_frames(&target, count)?;
|
||||
println!("Sending {count} synthetic ADR-018 CSI frames to {target}...");
|
||||
csi_pipeline::send_test_frames(&target, count)?;
|
||||
println!("Done");
|
||||
}
|
||||
Commands::Fingerprint { name, seconds } => {
|
||||
println!("Recording CSI fingerprint '{name}' for {seconds} s on UDP 3333...");
|
||||
let state = csi_pipeline::start_pipeline("0.0.0.0:3333");
|
||||
std::thread::sleep(std::time::Duration::from_secs(seconds));
|
||||
// record_fingerprint takes a brief lock on the shared state to
|
||||
// read the last N frames from every node's history.
|
||||
{
|
||||
let mut st = state.lock().expect("pipeline state lock poisoned");
|
||||
st.record_fingerprint(&name);
|
||||
println!(
|
||||
" Stored: {} fingerprint(s) total, {} total CSI frames received",
|
||||
st.fingerprints.len(),
|
||||
st.total_frames
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
//! ADR-018 binary CSI frame parser.
|
||||
//!
|
||||
//! Two header magics are accepted: `0xC5110001` (raw CSI, v1) and
|
||||
//! `0xC5110006` (feature state, v6). The header is 20 bytes; everything
|
||||
//! after is interleaved I/Q bytes per subcarrier per antenna.
|
||||
//!
|
||||
//! Returns `None` when the buffer is truncated or the magic is wrong —
|
||||
//! this is a hot path (one call per UDP packet) so we prefer Option over
|
||||
//! a full `anyhow::Error` that would allocate.
|
||||
|
||||
const CSI_MAGIC_V6: u32 = 0xC511_0006;
|
||||
const CSI_MAGIC_V1: u32 = 0xC511_0001;
|
||||
pub(crate) const CSI_HEADER_SIZE: usize = 20;
|
||||
|
||||
/// Accept both header magics — `0xC5110001` (raw CSI) and
|
||||
/// `0xC5110006` (feature state). Exposed for tests.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const MAGIC_V1: u32 = CSI_MAGIC_V1;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const MAGIC_V6: u32 = CSI_MAGIC_V6;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CsiFrame {
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u16,
|
||||
pub channel: u8,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
pub timestamp_us: u32,
|
||||
/// Raw I/Q data: [I0, Q0, I1, Q1, ...] for each subcarrier
|
||||
pub iq_data: Vec<i8>,
|
||||
/// Computed amplitude per subcarrier: sqrt(I^2 + Q^2)
|
||||
pub amplitudes: Vec<f32>,
|
||||
/// Computed phase per subcarrier: atan2(Q, I)
|
||||
pub phases: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 binary CSI frame from a UDP packet.
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - the buffer is shorter than the 20-byte header
|
||||
/// - the magic does not match either accepted value
|
||||
/// - the declared I/Q payload is truncated
|
||||
pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
|
||||
if data.len() < CSI_HEADER_SIZE { return None; }
|
||||
|
||||
let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
if magic != CSI_MAGIC_V6 && magic != CSI_MAGIC_V1 { return None; }
|
||||
|
||||
let node_id = data[4];
|
||||
let n_antennas = data[5].max(1);
|
||||
let n_subcarriers = u16::from_le_bytes([data[6], data[7]]);
|
||||
let channel = data[8];
|
||||
let rssi = data[9] as i8;
|
||||
let noise_floor = data[10] as i8;
|
||||
let timestamp_us = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
|
||||
|
||||
let iq_len = (n_subcarriers as usize) * 2 * (n_antennas as usize);
|
||||
if data.len() < CSI_HEADER_SIZE + iq_len { return None; }
|
||||
|
||||
let iq_data: Vec<i8> = data[CSI_HEADER_SIZE..CSI_HEADER_SIZE + iq_len]
|
||||
.iter().map(|&b| b as i8).collect();
|
||||
|
||||
// Compute amplitude and phase per subcarrier (first antenna).
|
||||
let mut amplitudes = Vec::with_capacity(n_subcarriers as usize);
|
||||
let mut phases = Vec::with_capacity(n_subcarriers as usize);
|
||||
for i in 0..n_subcarriers as usize {
|
||||
let idx = i * 2;
|
||||
if idx + 1 < iq_data.len() {
|
||||
let ii = iq_data[idx] as f32;
|
||||
let qq = iq_data[idx + 1] as f32;
|
||||
amplitudes.push((ii * ii + qq * qq).sqrt());
|
||||
phases.push(qq.atan2(ii));
|
||||
}
|
||||
}
|
||||
|
||||
Some(CsiFrame {
|
||||
node_id, n_antennas, n_subcarriers, channel, rssi, noise_floor,
|
||||
timestamp_us, iq_data, amplitudes, phases,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a synthetic ADR-018 binary frame. Used by the `csi-test` CLI
|
||||
/// subcommand and by the unit tests in this module.
|
||||
pub fn build_test_frame(magic: u32, node_id: u8, n_subcarriers: u16, i: usize) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(CSI_HEADER_SIZE + (n_subcarriers as usize) * 2);
|
||||
buf.extend_from_slice(&magic.to_le_bytes()); // magic (0..4)
|
||||
buf.push(node_id); // node_id (4)
|
||||
buf.push(1u8); // n_antennas (5)
|
||||
buf.extend_from_slice(&n_subcarriers.to_le_bytes()); // n_subcarriers (6..8)
|
||||
buf.push(6u8); // channel (8)
|
||||
buf.push((-40i8 - (i % 30) as i8) as u8); // rssi (9)
|
||||
buf.push((-90i8) as u8); // noise_floor (10)
|
||||
buf.extend_from_slice(&[0u8; 5]); // reserved (11..16)
|
||||
buf.extend_from_slice(&(i as u32).to_le_bytes()); // timestamp_us (16..20)
|
||||
for j in 0..(n_subcarriers as usize) {
|
||||
buf.push(((i + j) as i8).wrapping_mul(3) as u8);
|
||||
buf.push(((i + j) as i8).wrapping_mul(5) as u8);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_magic_v1_roundtrips() {
|
||||
let frame_bytes = build_test_frame(MAGIC_V1, 0x42, 56, 7);
|
||||
let frame = parse_adr018(&frame_bytes).expect("v1 frame should parse");
|
||||
assert_eq!(frame.node_id, 0x42);
|
||||
assert_eq!(frame.n_antennas, 1);
|
||||
assert_eq!(frame.n_subcarriers, 56);
|
||||
assert_eq!(frame.channel, 6);
|
||||
assert_eq!(frame.timestamp_us, 7);
|
||||
assert_eq!(frame.iq_data.len(), 56 * 2);
|
||||
assert_eq!(frame.amplitudes.len(), 56);
|
||||
assert_eq!(frame.phases.len(), 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_magic_v6_roundtrips() {
|
||||
let frame_bytes = build_test_frame(MAGIC_V6, 0x09, 114, 0);
|
||||
let frame = parse_adr018(&frame_bytes).expect("v6 frame should parse");
|
||||
assert_eq!(frame.node_id, 0x09);
|
||||
assert_eq!(frame.n_antennas, 1);
|
||||
assert_eq!(frame.n_subcarriers, 114);
|
||||
assert_eq!(frame.channel, 6);
|
||||
// With i=0, noise_floor=-90 per build_test_frame.
|
||||
assert_eq!(frame.noise_floor, -90);
|
||||
// With i=0, timestamp_us=0.
|
||||
assert_eq!(frame.timestamp_us, 0);
|
||||
assert_eq!(frame.iq_data.len(), 114 * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_magic() {
|
||||
let mut bad = build_test_frame(MAGIC_V1, 0, 8, 0);
|
||||
// Flip magic to something unrelated.
|
||||
bad[0] = 0xFF;
|
||||
bad[1] = 0xFF;
|
||||
bad[2] = 0xFF;
|
||||
bad[3] = 0xFF;
|
||||
assert!(parse_adr018(&bad).is_none(), "bad magic should not parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_truncated_header() {
|
||||
let short = vec![0u8; CSI_HEADER_SIZE - 1];
|
||||
assert!(parse_adr018(&short).is_none(), "truncated header must not parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_truncated_payload() {
|
||||
let mut frame = build_test_frame(MAGIC_V1, 0, 32, 0);
|
||||
// Drop half the declared payload.
|
||||
frame.truncate(CSI_HEADER_SIZE + 20);
|
||||
assert!(parse_adr018(&frame).is_none(), "truncated payload must not parse");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue