Skip to main content

wifi_densepose_cli/
calibrate.rs

1//! `wifi-densepose calibrate` — empty-room baseline calibration subcommand.
2//!
3//! Reads CSI frames from a UDP socket (ESP32 0xC511_0001 wire format), feeds
4//! them through [`wifi_densepose_signal::CalibrationRecorder`], prints a
5//! real-time deviation banner (ADR-135 §risk 1), and serialises the finished
6//! [`wifi_densepose_signal::BaselineCalibration`] to disk in the compact
7//! little-endian binary format defined in ADR-135 §2.4.
8//!
9//! # Wire format parsed here (option b — local parser, no cross-crate dep)
10//!
11//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
12//!
13//! Offset  Size  Field
14//! ──────  ────  ─────────────────────────────────────────────────────────────
15//!  0      4     Magic: 0xC511_0001 (LE u32)
16//!  4      1     node_id (u8)
17//!  5      1     n_antennas (u8)
18//!  6      2     n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
19//!  8      4     freq_mhz (LE u32)
20//! 12      4     sequence (LE u32)
21//! 16      1     rssi (i8)
22//! 17      1     noise_floor (i8)
23//! 18      1     PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
24//! 19      1     flags (ADR-110: bit0 bw40, bit4 time-sync valid)
25//! 20      2 × n_antennas × n_subcarriers   IQ pairs: i_val (i8), q_val (i8)
26//!
27//! This parser mirrors `parse_esp32_frame` in
28//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
29
30use anyhow::{bail, Result};
31use clap::Args;
32use ndarray::Array2;
33use num_complex::Complex64;
34use std::time::{Duration, Instant};
35use tokio::net::UdpSocket;
36use wifi_densepose_core::types::{
37    AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand, Timestamp,
38};
39use wifi_densepose_signal::{
40    BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationRecorder,
41};
42
43// ---------------------------------------------------------------------------
44// Arguments
45// ---------------------------------------------------------------------------
46
47/// Arguments for the `calibrate` subcommand.
48#[derive(Args, Debug, Clone)]
49pub struct CalibrateArgs {
50    /// UDP port to listen on for CSI frames from the ESP32.
51    /// Must match the target-port written into NVS by provision.py (default 5005).
52    #[arg(long, default_value_t = 5005)]
53    pub udp_port: u16,
54
55    /// Bind address for the UDP socket.
56    /// Default 0.0.0.0 receives from any device on the LAN.
57    #[arg(long, default_value = "0.0.0.0")]
58    pub bind: String,
59
60    /// Calibration duration in seconds.
61    /// ADR-135 default is 30 s at 20 Hz = 600 frames.
62    /// Minimum 10; values above 300 emit a warning.
63    #[arg(long, default_value_t = 30)]
64    pub duration_s: u32,
65
66    /// Output path for the binary baseline file (ADR-135 §2.4 format).
67    #[arg(long, default_value = "./baseline.bin")]
68    pub output: String,
69
70    /// PHY tier matching the ESP32 configuration.
71    /// Valid: ht20 / ht40 / he20 / he40.
72    #[arg(long, default_value = "ht20")]
73    pub tier: String,
74
75    /// Print a deviation banner to stderr every N frames during capture.
76    /// 0 disables banners. Default 20 = once per second at 20 Hz.
77    #[arg(long, default_value_t = 20)]
78    pub banner_every: u32,
79
80    /// Abort if the per-frame amplitude z-score median exceeds this value
81    /// for 20 consecutive banner intervals. 0.0 disables the abort guard.
82    #[arg(long, default_value_t = 2.0)]
83    pub abort_z_threshold: f32,
84
85    /// Override the ADR-135 minimum frame count for the tier. 0 = use the
86    /// tier default (600 for HT20 at 20 Hz = 30 s). Useful for debugging or
87    /// low-traffic environments where the firmware emits CSI far below 20 Hz.
88    /// Production deployments should leave this at 0.
89    #[arg(long, default_value_t = 0)]
90    pub min_frames: u32,
91}
92
93// ---------------------------------------------------------------------------
94// Constants
95// ---------------------------------------------------------------------------
96
97/// Maximum UDP receive buffer.  HT20 CSI frame is well under 1 500 bytes.
98const RECV_BUF: usize = 2048;
99
100/// Number of banner intervals in the high-z abort sliding window.
101const ABORT_WINDOW_INTERVALS: u32 = 20;
102
103// ---------------------------------------------------------------------------
104// Public entry point
105// ---------------------------------------------------------------------------
106
107/// Execute the `calibrate` subcommand (async).
108pub async fn execute(args: CalibrateArgs) -> Result<()> {
109    validate_args(&args)?;
110
111    let mut config = tier_config(&args.tier);
112    if args.min_frames > 0 {
113        config.min_frames = args.min_frames;
114        eprintln!(
115            "[calibrate] WARN: --min-frames={} overrides ADR-135 tier default ({} for {}). \
116             This relaxes the phase-concentration guarantee; do not use in production.",
117            args.min_frames, tier_config(&args.tier).min_frames, args.tier
118        );
119    }
120    let target_frames = config.min_frames as usize;
121
122    let addr = format!("{}:{}", args.bind, args.udp_port);
123    let socket = UdpSocket::bind(&addr).await
124        .map_err(|e| anyhow::anyhow!("cannot bind UDP socket on {addr}: {e}"))?;
125
126    eprintln!("[calibrate] listening on udp://{addr}");
127    eprintln!(
128        "[calibrate] capturing {} frames (~{} s, tier={}) — ensure room is empty",
129        target_frames, args.duration_s, args.tier
130    );
131
132    let mut recorder = CalibrationRecorder::new(config);
133    let mut buf = vec![0u8; RECV_BUF];
134    let mut high_z_count: u32 = 0;
135    let deadline = Instant::now() + Duration::from_secs(args.duration_s as u64);
136
137    loop {
138        let remaining = deadline.saturating_duration_since(Instant::now());
139        if remaining.is_zero() {
140            break;
141        }
142
143        let timeout = remaining.min(Duration::from_millis(500));
144        let recv = tokio::time::timeout(timeout, socket.recv(&mut buf)).await;
145
146        let n = match recv {
147            Ok(Ok(n)) => n,
148            Ok(Err(e)) => { eprintln!("[calibrate] recv error: {e}"); continue; }
149            Err(_) => continue, // timeout — recheck deadline
150        };
151
152        let Some(csi_frame) = parse_csi_packet(&buf[..n], &args.tier) else {
153            continue;
154        };
155
156        let score: CalibrationDeviationScore = match recorder.record(&csi_frame) {
157            Ok(s) => s,
158            Err(e) => { eprintln!("[calibrate] WARN frame skipped: {e}"); continue; }
159        };
160
161        let frames = recorder.frames_recorded() as usize;
162
163        if args.banner_every > 0 && (frames as u32) % args.banner_every == 0 {
164            print_banner(frames, target_frames, &score);
165
166            if args.abort_z_threshold > 0.0 && score.amplitude_z_median > args.abort_z_threshold {
167                high_z_count += 1;
168                if high_z_count >= ABORT_WINDOW_INTERVALS {
169                    bail!(
170                        "aborted: amplitude_z_median={:.2} exceeded threshold={:.2} for {} \
171                         consecutive banner intervals — ensure the room is empty and retry",
172                        score.amplitude_z_median, args.abort_z_threshold, high_z_count
173                    );
174                }
175            } else {
176                high_z_count = 0;
177            }
178        }
179
180        if frames >= target_frames {
181            break;
182        }
183    }
184
185    finalise_and_save(recorder, &args.output)
186}
187
188// ---------------------------------------------------------------------------
189// Banner printer
190// ---------------------------------------------------------------------------
191
192fn print_banner(frames: usize, target: usize, score: &CalibrationDeviationScore) {
193    let motion_str = if score.motion_flagged {
194        "YES \u{2190} operator should be still"
195    } else {
196        "no"
197    };
198    eprintln!(
199        "[calibrate] {}/{} frames | z_med={:.2} z_max={:.2} | motion: {}",
200        frames, target, score.amplitude_z_median, score.amplitude_z_max, motion_str
201    );
202}
203
204// ---------------------------------------------------------------------------
205// Finalise + persist
206// ---------------------------------------------------------------------------
207
208fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()> {
209    let frames = recorder.frames_recorded();
210    eprintln!("[calibrate] finalising baseline from {frames} frames…");
211
212    let baseline: BaselineCalibration = recorder
213        .finalize()
214        .map_err(|e| anyhow::anyhow!("calibration failed: {e}"))?;
215
216    let bytes = baseline.to_bytes();
217    std::fs::write(output, &bytes)
218        .map_err(|e| anyhow::anyhow!("cannot write {output}: {e}"))?;
219
220    eprintln!(
221        "[calibrate] baseline saved to {output} ({} bytes)",
222        bytes.len()
223    );
224    eprintln!(
225        "[calibrate] summary: frames={} tier={:?} subcarriers={}",
226        baseline.frame_count,
227        baseline.tier,
228        baseline.subcarriers.len(),
229    );
230    Ok(())
231}
232
233// ---------------------------------------------------------------------------
234// Tier helper
235// ---------------------------------------------------------------------------
236
237pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
238    match tier.to_ascii_lowercase().as_str() {
239        "ht40" => CalibrationConfig::ht40(),
240        "he20" => CalibrationConfig::he20(),
241        "he40" => CalibrationConfig::he40(),
242        _      => CalibrationConfig::ht20(), // ht20 or unknown → safe default
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Local UDP packet parser (option b)
248//
249// Mirrors parse_esp32_frame in wifi-densepose-sensing-server/src/csi.rs.
250// Magic 0xC511_0001, 20-byte header, IQ bytes follow.
251// ---------------------------------------------------------------------------
252
253/// Parse a single UDP datagram and return a `CsiFrame` ready for
254/// `CalibrationRecorder::record()`.  Returns `None` on any parse failure.
255pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
256    if buf.len() < 20 {
257        return None;
258    }
259    let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
260    if magic != 0xC511_0001 {
261        return None;
262    }
263
264    let node_id       = buf[4];
265    let n_antennas    = buf[5] as usize;
266    // u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
267    // (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
268    let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
269    let freq_mhz      = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
270    let freq_mhz      = u16::try_from(freq_mhz).unwrap_or(0);
271    let _sequence     = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
272    let rssi          = buf[16] as i8;
273    let noise_floor   = buf[17] as i8;
274    let _ppdu_type    = buf[18]; // ADR-110; baseline tier gating is by count
275
276    let n_pairs = n_antennas * n_subcarriers;
277    let iq_start = 20usize;
278    if buf.len() < iq_start + n_pairs * 2 {
279        return None;
280    }
281
282    // Build an ndarray Array2<Complex64> shaped [n_antennas, n_subcarriers].
283    let mut data = Array2::<Complex64>::zeros((n_antennas.max(1), n_subcarriers.max(1)));
284    for s in 0..n_antennas {
285        for k in 0..n_subcarriers {
286            let idx = s * n_subcarriers + k;
287            let i_val = buf[iq_start + idx * 2]     as i8 as f64;
288            let q_val = buf[iq_start + idx * 2 + 1] as i8 as f64;
289            data[[s, k]] = Complex64::new(i_val, q_val);
290        }
291    }
292
293    let band = if freq_mhz >= 5000 {
294        FrequencyBand::Band5GHz
295    } else {
296        FrequencyBand::Band2_4GHz
297    };
298    let bw = tier_to_bw_mhz(tier);
299
300    let mut meta = CsiMetadata::new(
301        DeviceId::new(format!("esp32-node{}", node_id)),
302        band,
303        freq_mhz_to_channel(freq_mhz),
304    );
305    meta.bandwidth_mhz = bw;
306    meta.rssi_dbm = rssi;
307    meta.noise_floor_dbm = noise_floor;
308    meta.antenna_config = AntennaConfig {
309        tx_antennas: 1,
310        rx_antennas: n_antennas as u8,
311        spacing_mm: None,
312    };
313    meta.timestamp = Timestamp::now();
314
315    Some(CsiFrame::new(meta, data))
316}
317
318/// Map a tier string to a bandwidth in MHz.
319fn tier_to_bw_mhz(tier: &str) -> u16 {
320    match tier.to_ascii_lowercase().as_str() {
321        "ht40" | "he40" => 40,
322        _ => 20,
323    }
324}
325
326/// Rough 802.11 channel from centre frequency.
327fn freq_mhz_to_channel(freq_mhz: u16) -> u8 {
328    // 2.4 GHz: ch = (freq - 2407) / 5
329    if freq_mhz < 3000 {
330        ((freq_mhz.saturating_sub(2407)) / 5) as u8
331    } else {
332        // 5 GHz: ch = (freq - 5000) / 5
333        ((freq_mhz.saturating_sub(5000)) / 5) as u8
334    }
335}
336
337// ---------------------------------------------------------------------------
338// Input validation
339// ---------------------------------------------------------------------------
340
341fn validate_args(args: &CalibrateArgs) -> Result<()> {
342    if args.duration_s < 10 {
343        bail!(
344            "--duration-s must be at least 10 s (got {}). \
345             Fewer frames produce unreliable phase-concentration estimates (ADR-135 §2.3).",
346            args.duration_s
347        );
348    }
349    if args.duration_s > 300 {
350        eprintln!(
351            "[calibrate] WARN: --duration-s={} exceeds 300 s; this is unusual.",
352            args.duration_s
353        );
354    }
355    let valid = ["ht20", "ht40", "he20", "he40"];
356    if !valid.contains(&args.tier.to_ascii_lowercase().as_str()) {
357        bail!(
358            "--tier must be one of {:?} (got {:?})",
359            valid, args.tier
360        );
361    }
362    Ok(())
363}
364
365// ---------------------------------------------------------------------------
366// Unit tests
367// ---------------------------------------------------------------------------
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_validate_args_min_duration() {
375        let mut args = default_args();
376        args.duration_s = 5;
377        assert!(validate_args(&args).is_err());
378    }
379
380    #[test]
381    fn test_validate_args_ok() {
382        let args = default_args();
383        assert!(validate_args(&args).is_ok());
384    }
385
386    #[test]
387    fn test_validate_args_bad_tier() {
388        let mut args = default_args();
389        args.tier = "ht80".into();
390        assert!(validate_args(&args).is_err());
391    }
392
393    #[test]
394    fn test_tier_config_ht20() {
395        let cfg = tier_config("ht20");
396        assert_eq!(cfg.num_active, 52);
397    }
398
399    #[test]
400    fn test_tier_config_ht40() {
401        let cfg = tier_config("ht40");
402        assert_eq!(cfg.num_active, 114);
403    }
404
405    #[test]
406    fn test_tier_config_he20() {
407        let cfg = tier_config("he20");
408        // Issue #1009 §1b: HE20 baseline records all 256 delivered bins
409        // (no tone map in the recorder), not the 242 active tones.
410        assert_eq!(cfg.num_active, 256);
411    }
412
413    #[test]
414    fn test_parse_csi_packet_bad_magic() {
415        let buf = vec![0u8; 32];
416        assert!(parse_csi_packet(&buf, "ht20").is_none());
417    }
418
419    #[test]
420    fn test_parse_csi_packet_too_short() {
421        let buf = vec![0u8; 10];
422        assert!(parse_csi_packet(&buf, "ht20").is_none());
423    }
424
425    /// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
426    fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
427        let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
428        buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
429        buf[4] = 12; // node_id
430        buf[5] = 1; // n_antennas
431        buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
432        buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
433        buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
434        buf[16] = (-40i8) as u8; // rssi
435        buf[17] = (-87i8) as u8; // noise floor
436        buf[18] = ppdu;
437        buf[19] = 0x10; // time-sync valid
438        for k in 0..n_subcarriers as usize {
439            buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
440            buf[20 + k * 2 + 1] = (k % 50) as u8;
441        }
442        buf
443    }
444
445    #[test]
446    fn test_parse_csi_packet_valid() {
447        let buf = build_frame(2, 0);
448        let frame = parse_csi_packet(&buf, "ht20");
449        assert!(frame.is_some());
450        let f = frame.unwrap();
451        assert_eq!(f.num_spatial_streams(), 1);
452        assert_eq!(f.num_subcarriers(), 2);
453        assert_eq!(f.metadata.rssi_dbm, -40);
454        assert_eq!(f.metadata.noise_floor_dbm, -87);
455    }
456
457    #[test]
458    fn test_parse_csi_packet_he_su_256_bins() {
459        // ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
460        // The pre-#1005 single-byte read decoded this as 0 subcarriers.
461        let buf = build_frame(256, 1);
462        assert_eq!(buf.len(), 532); // matches the live wire size
463        let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
464        assert_eq!(f.num_subcarriers(), 256);
465        assert_eq!(f.metadata.rssi_dbm, -40);
466        // A 256-bin frame is accepted by the he20 recorder (num_subcarriers
467        // tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
468        let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
469        assert!(he.record(&f).is_ok());
470        let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
471        assert!(ht.record(&f).is_err());
472    }
473
474    #[test]
475    fn test_freq_to_channel_24ghz() {
476        assert_eq!(freq_mhz_to_channel(2437), 6);
477    }
478
479    #[test]
480    fn test_freq_to_channel_5ghz() {
481        assert_eq!(freq_mhz_to_channel(5180), 36);
482    }
483
484    fn default_args() -> CalibrateArgs {
485        CalibrateArgs {
486            udp_port: 5005,
487            bind: "0.0.0.0".into(),
488            duration_s: 30,
489            output: "./baseline.bin".into(),
490            tier: "ht20".into(),
491            banner_every: 20,
492            abort_z_threshold: 2.0,
493            min_frames: 0,
494        }
495    }
496}