From 76e80f1d845163fda4988801c5bccada2cc9a81a Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 9 Jun 2026 10:20:31 -0400 Subject: [PATCH] feat(cli): calibration HTTP API for UI-driven baseline capture (ADR-135/151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `wifi-densepose calibrate-serve` — an Axum HTTP API that wraps the ADR-135 CalibrationRecorder so a UI (or any client) can drive an empty-room baseline capture remotely. Stage 1 ("teach the room") of the ADR-151 room calibration & training pipeline. A single background task owns the UDP socket (ESP32 0xC511_0001 frames) and the optional active recorder; HTTP handlers talk to it over an mpsc command channel and read a shared status snapshot, keeping the &mut recorder lock-free. CORS permissive so a browser UI can call it. Endpoints (/api/v1/calibration/*): GET /health liveness + UDP ingest stats (frames_seen, streaming) POST /start { tier?, duration_s?, room_id?, min_frames? } GET /status live progress (state, frames, progress, z, eta) — poll for UI POST /stop finalize the current session early GET /result finalized baseline summary (amp/phase-dispersion averages) GET /baselines list persisted baseline .bin files Reuses the existing calibrate.rs ESP32 wire parser (made pub(crate)); honest abort when <10 frames arrive in the window (e.g. ESP32 not streaming). Verified end-to-end over loopback: start -> 300 replayed HT20 frames -> state=complete, 52-subcarrier baseline, phase_dispersion_avg=0.00096 (concentrated/valid), persisted to disk; all 6 endpoints exercised. CLI: 19 tests pass; crate builds clean. Co-Authored-By: claude-flow --- v2/.gitignore | 1 + v2/Cargo.lock | 2 + v2/crates/wifi-densepose-cli/Cargo.toml | 4 + v2/crates/wifi-densepose-cli/src/calibrate.rs | 4 +- .../wifi-densepose-cli/src/calibrate_api.rs | 595 ++++++++++++++++++ v2/crates/wifi-densepose-cli/src/lib.rs | 6 + v2/crates/wifi-densepose-cli/src/main.rs | 3 + 7 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 v2/.gitignore create mode 100644 v2/crates/wifi-densepose-cli/src/calibrate_api.rs diff --git a/v2/.gitignore b/v2/.gitignore new file mode 100644 index 00000000..c34db80e --- /dev/null +++ b/v2/.gitignore @@ -0,0 +1 @@ +baselines/ diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 4ca88ca1..fe7ff2bb 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -10817,6 +10817,7 @@ version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", + "axum", "chrono", "clap", "colored", @@ -10832,6 +10833,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tower-http", "tracing", "tracing-subscriber", "uuid", diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index c365a266..215df58b 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -41,6 +41,10 @@ console = "0.16" # Async runtime tokio = { version = "1.35", features = ["full"] } +# HTTP API server (calibrate-serve subcommand — drives a future UI) +axum = { workspace = true } +tower-http = { version = "0.6", features = ["cors", "trace"] } + # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/v2/crates/wifi-densepose-cli/src/calibrate.rs b/v2/crates/wifi-densepose-cli/src/calibrate.rs index 6fbd2100..17d50b27 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate.rs @@ -232,7 +232,7 @@ fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()> // Tier helper // --------------------------------------------------------------------------- -fn tier_config(tier: &str) -> CalibrationConfig { +pub(crate) fn tier_config(tier: &str) -> CalibrationConfig { match tier.to_ascii_lowercase().as_str() { "ht40" => CalibrationConfig::ht40(), "he20" => CalibrationConfig::he20(), @@ -250,7 +250,7 @@ fn tier_config(tier: &str) -> CalibrationConfig { /// Parse a single UDP datagram and return a `CsiFrame` ready for /// `CalibrationRecorder::record()`. Returns `None` on any parse failure. -fn parse_csi_packet(buf: &[u8], tier: &str) -> Option { +pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option { if buf.len() < 20 { return None; } diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs new file mode 100644 index 00000000..49f8aac7 --- /dev/null +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -0,0 +1,595 @@ +//! `wifi-densepose calibrate-serve` — HTTP API around ADR-135 baseline calibration. +//! +//! Wraps the same [`wifi_densepose_signal::CalibrationRecorder`] used by the +//! `calibrate` subcommand in a small Axum server so a UI (or any client) can +//! drive an empty-room baseline capture remotely: +//! +//! | Method | Path | Purpose | +//! |--------|-----------------------------------|-------------------------------------------| +//! | GET | `/` | API descriptor (discovery) | +//! | GET | `/api/v1/calibration/health` | liveness + UDP ingest stats | +//! | POST | `/api/v1/calibration/start` | begin a baseline capture session | +//! | GET | `/api/v1/calibration/status` | live session progress (poll this for UI) | +//! | POST | `/api/v1/calibration/stop` | finalize the current session early | +//! | GET | `/api/v1/calibration/result` | summary of the last finalized baseline | +//! | GET | `/api/v1/calibration/baselines` | list persisted baseline files | +//! +//! A single background task owns the UDP socket (ESP32 `0xC511_0001` frames) and +//! the optional active recorder; the HTTP handlers communicate with it over an +//! mpsc command channel and read a shared status snapshot. This keeps the +//! `&mut` recorder lock-free and the API non-blocking. CORS is permissive so a +//! browser UI served from any origin can call it during development. + +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use clap::Args; +use serde::{Deserialize, Serialize}; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, oneshot, RwLock}; +use tower_http::cors::CorsLayer; +use wifi_densepose_signal::{BaselineCalibration, CalibrationRecorder}; + +use crate::calibrate::{parse_csi_packet, tier_config}; + +const RECV_BUF: usize = 2048; + +// --------------------------------------------------------------------------- +// CLI arguments +// --------------------------------------------------------------------------- + +/// Arguments for the `calibrate-serve` subcommand. +#[derive(Args, Debug, Clone)] +pub struct CalibrateServeArgs { + /// TCP port for the HTTP API. + #[arg(long, default_value_t = 8090)] + pub http_port: u16, + + /// Bind address for the HTTP API. Default 127.0.0.1 (localhost only); + /// use 0.0.0.0 to expose the API to the LAN for a remote UI. + #[arg(long, default_value = "127.0.0.1")] + pub http_bind: String, + + /// UDP port to receive CSI frames from the ESP32 (must match provisioned target-port). + #[arg(long, default_value_t = 5005)] + pub udp_port: u16, + + /// Bind address for the UDP CSI socket. + #[arg(long, default_value = "0.0.0.0")] + pub udp_bind: String, + + /// Default PHY tier when a start request omits one (ht20 / ht40 / he20 / he40). + #[arg(long, default_value = "ht20")] + pub tier: String, + + /// Directory where finalized baseline `.bin` files are written. + #[arg(long, default_value = "./baselines")] + pub output_dir: String, +} + +// --------------------------------------------------------------------------- +// Wire types (request / response bodies) +// --------------------------------------------------------------------------- + +/// Body for `POST /start`. All fields optional — sensible defaults applied. +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct StartParams { + /// PHY tier override (falls back to the server default). + pub tier: Option, + /// Capture duration in seconds (also bounded by the tier's min-frame target). + pub duration_s: u32, + /// Optional room label, used in the persisted filename and status. + pub room_id: Option, + /// Override the tier's minimum frame count (0 = use tier default). + pub min_frames: u32, +} + +impl Default for StartParams { + fn default() -> Self { + Self { tier: None, duration_s: 30, room_id: None, min_frames: 0 } + } +} + +/// Live per-session status snapshot returned by `GET /status`. +#[derive(Debug, Clone, Serialize)] +pub struct SessionStatus { + /// `recording` | `finalizing` | `complete` | `aborted`. + pub state: String, + pub room_id: String, + pub tier: String, + pub frames_recorded: usize, + pub target_frames: usize, + /// 0.0..=1.0 capture progress. + pub progress: f32, + pub z_median: f32, + pub z_max: f32, + pub motion_flagged: bool, + pub elapsed_s: f32, + pub eta_s: f32, + /// Optional human-readable note (e.g. abort reason). + pub note: Option, +} + +/// Summary of a finalized baseline, returned by `GET /result` and `POST /stop`. +#[derive(Debug, Clone, Serialize)] +pub struct ResultSummary { + pub calibration_id: String, + pub room_id: String, + pub tier: String, + pub frame_count: u64, + pub subcarriers: usize, + pub captured_at_unix_s: i64, + pub amp_mean_avg: f32, + pub amp_variance_avg: f32, + pub phase_dispersion_avg: f32, + pub output_path: String, + pub saved_bytes: usize, +} + +/// Shared status the HTTP handlers read. +#[derive(Default)] +struct SharedStatus { + udp_port: u16, + default_tier: String, + output_dir: String, + frames_seen: u64, + last_frame_unix_ms: u64, + session: Option, + last_result: Option, +} + +/// Commands sent from HTTP handlers to the ingest task. +enum CalCommand { + Start { params: StartParams, reply: oneshot::Sender> }, + Stop { reply: oneshot::Sender> }, +} + +#[derive(Clone)] +struct ApiState { + cmd_tx: mpsc::Sender, + status: Arc>, +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run the calibration HTTP API server (blocks until Ctrl-C). +pub async fn execute(args: CalibrateServeArgs) -> Result<()> { + std::fs::create_dir_all(&args.output_dir) + .map_err(|e| anyhow::anyhow!("cannot create output dir {}: {e}", args.output_dir))?; + + let udp_addr = format!("{}:{}", args.udp_bind, args.udp_port); + 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}"); + + let status = Arc::new(RwLock::new(SharedStatus { + udp_port: args.udp_port, + default_tier: args.tier.clone(), + output_dir: args.output_dir.clone(), + ..Default::default() + })); + + let (cmd_tx, cmd_rx) = mpsc::channel::(8); + + // Background ingest task owns the socket + recorder. + { + let status = status.clone(); + let default_tier = args.tier.clone(); + let output_dir = args.output_dir.clone(); + tokio::spawn(async move { + ingest_loop(socket, cmd_rx, status, default_tier, output_dir).await; + }); + } + + let state = ApiState { cmd_tx, status }; + let app = Router::new() + .route("/", get(descriptor)) + .route("/api/v1/calibration/health", get(health)) + .route("/api/v1/calibration/start", post(start)) + .route("/api/v1/calibration/status", get(status_handler)) + .route("/api/v1/calibration/stop", post(stop)) + .route("/api/v1/calibration/result", get(result)) + .route("/api/v1/calibration/baselines", get(baselines)) + .layer(CorsLayer::permissive()) + .with_state(state); + + let http_addr = format!("{}:{}", args.http_bind, args.http_port); + let listener = tokio::net::TcpListener::bind(&http_addr) + .await + .map_err(|e| anyhow::anyhow!("cannot bind HTTP listener on {http_addr}: {e}"))?; + eprintln!("[calibrate-serve] HTTP API on http://{http_addr} (GET / for the route list)"); + + axum::serve(listener, app) + .await + .map_err(|e| anyhow::anyhow!("HTTP server error: {e}"))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Ingest task — owns the UDP socket and the optional active recorder +// --------------------------------------------------------------------------- + +struct ActiveSession { + recorder: CalibrationRecorder, + room_id: String, + tier: String, + started: Instant, + deadline: Instant, + target_frames: usize, + z_median: f32, + z_max: f32, + motion_flagged: bool, +} + +async fn ingest_loop( + socket: UdpSocket, + mut cmd_rx: mpsc::Receiver, + status: Arc>, + default_tier: String, + output_dir: String, +) { + let mut buf = vec![0u8; RECV_BUF]; + let mut active: Option = None; + let mut tick = tokio::time::interval(Duration::from_millis(200)); + + loop { + tokio::select! { + // --- incoming command --- + Some(cmd) = cmd_rx.recv() => match cmd { + CalCommand::Start { params, reply } => { + if active.is_some() { + let _ = reply.send(Err("a calibration session is already running".into())); + continue; + } + let tier = params.tier.unwrap_or_else(|| default_tier.clone()); + if !["ht20", "ht40", "he20", "he40"].contains(&tier.to_ascii_lowercase().as_str()) { + let _ = reply.send(Err(format!("invalid tier {tier:?}"))); + continue; + } + let mut config = tier_config(&tier); + if params.min_frames > 0 { + config.min_frames = params.min_frames; + } + let target_frames = config.min_frames as usize; + let dur = params.duration_s.max(1) as u64; + let room_id = params.room_id.unwrap_or_else(|| "default".into()); + let sess = ActiveSession { + recorder: CalibrationRecorder::new(config), + room_id: room_id.clone(), + tier: tier.clone(), + started: Instant::now(), + deadline: Instant::now() + Duration::from_secs(dur), + target_frames, + z_median: 0.0, + z_max: 0.0, + motion_flagged: false, + }; + let snap = session_snapshot(&sess, "recording", None); + active = Some(sess); + { + let mut s = status.write().await; + s.session = Some(snap.clone()); + s.last_result = None; + } + eprintln!("[calibrate-serve] session start room={room_id} tier={tier} target={target_frames}"); + let _ = reply.send(Ok(snap)); + } + CalCommand::Stop { reply } => { + match active.take() { + Some(sess) => { + let res = finalize(sess, &output_dir, &status).await; + let _ = reply.send(res); + } + None => { let _ = reply.send(Err("no active calibration session".into())); } + } + } + }, + + // --- incoming CSI frame --- + Ok(n) = socket.recv(&mut buf) => { + let now_ms = unix_ms(); + { + let mut s = status.write().await; + s.frames_seen += 1; + s.last_frame_unix_ms = now_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 Ok(score) = sess.recorder.record(&frame) { + sess.z_median = score.amplitude_z_median; + sess.z_max = score.amplitude_z_max; + sess.motion_flagged = score.motion_flagged; + } + let frames = sess.recorder.frames_recorded() as usize; + let snap = session_snapshot(sess, "recording", None); + status.write().await.session = Some(snap); + if frames >= sess.target_frames { + if let Some(done) = active.take() { + let _ = finalize(done, &output_dir, &status).await; + } + } + } + } + }, + + // --- periodic deadline check (fires even if frames stop arriving) --- + _ = tick.tick() => { + if let Some(sess) = active.as_ref() { + if Instant::now() >= sess.deadline { + let frames = sess.recorder.frames_recorded() as usize; + if frames >= 10 { + if let Some(done) = active.take() { + let _ = finalize(done, &output_dir, &status).await; + } + } else if let Some(mut done) = active.take() { + // not enough frames — abort honestly rather than emit a bad baseline + done.motion_flagged = false; + let note = format!( + "aborted: only {frames} frames in the time window (need >=10) — \ + is the ESP32 streaming to udp:{}? ", + status.read().await.udp_port + ); + let snap = session_snapshot(&done, "aborted", Some(note.clone())); + status.write().await.session = Some(snap); + eprintln!("[calibrate-serve] {note}"); + } + } + } + }, + } + } +} + +/// Finalize a session: persist the baseline and publish the result summary. +async fn finalize( + sess: ActiveSession, + output_dir: &str, + status: &Arc>, +) -> Result { + let room_id = sess.room_id.clone(); + let tier = sess.tier.clone(); + // mark finalizing + { + let snap = session_snapshot(&sess, "finalizing", None); + status.write().await.session = Some(snap); + } + + let baseline: BaselineCalibration = sess + .recorder + .finalize() + .map_err(|e| format!("finalize failed: {e}"))?; + + let (amp_mean_avg, amp_var_avg, disp_avg) = baseline_averages(&baseline); + let uuid = baseline.calibration_uuid().to_string(); + let path = format!("{output_dir}/{room_id}-{uuid}.bin"); + let bytes = baseline.to_bytes(); + std::fs::write(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?; + + let summary = ResultSummary { + calibration_id: uuid, + room_id: room_id.clone(), + tier, + frame_count: baseline.frame_count, + subcarriers: baseline.subcarriers.len(), + captured_at_unix_s: baseline.captured_at_unix_s, + amp_mean_avg, + amp_variance_avg: amp_var_avg, + phase_dispersion_avg: disp_avg, + output_path: path.clone(), + saved_bytes: bytes.len(), + }; + + { + let mut s = status.write().await; + // reflect completion in the session snapshot, then store the result + if let Some(sess_status) = s.session.as_mut() { + sess_status.state = "complete".into(); + sess_status.progress = 1.0; + } + s.last_result = Some(summary.clone()); + } + eprintln!( + "[calibrate-serve] session complete room={room_id} frames={} -> {path} ({} bytes)", + summary.frame_count, summary.saved_bytes + ); + Ok(summary) +} + +// --------------------------------------------------------------------------- +// HTTP handlers +// --------------------------------------------------------------------------- + +async fn descriptor() -> impl IntoResponse { + Json(serde_json::json!({ + "service": "wifi-densepose calibration API", + "adr": "ADR-135 (baseline) / ADR-151 (room calibration & training)", + "endpoints": { + "GET /api/v1/calibration/health": "liveness + UDP ingest stats", + "POST /api/v1/calibration/start": "{ tier?, duration_s?, room_id?, min_frames? }", + "GET /api/v1/calibration/status": "live session progress (poll for UI)", + "POST /api/v1/calibration/stop": "finalize current session early", + "GET /api/v1/calibration/result": "last finalized baseline summary", + "GET /api/v1/calibration/baselines": "list persisted baseline files" + } + })) +} + +async fn health(State(st): State) -> impl IntoResponse { + let s = st.status.read().await; + let age = if s.last_frame_unix_ms == 0 { None } else { Some(unix_ms().saturating_sub(s.last_frame_unix_ms)) }; + Json(serde_json::json!({ + "status": "ok", + "udp_port": s.udp_port, + "frames_seen": s.frames_seen, + "last_frame_age_ms": age, + "streaming": age.map(|a| a < 2000).unwrap_or(false), + "default_tier": s.default_tier, + "output_dir": s.output_dir, + "session_active": s.session.as_ref().map(|x| x.state == "recording").unwrap_or(false), + })) +} + +async fn start(State(st): State, Json(params): Json) -> impl IntoResponse { + let (tx, rx) = oneshot::channel(); + if st.cmd_tx.send(CalCommand::Start { params, reply: tx }).await.is_err() { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"ingest task unavailable"}))).into_response(); + } + match rx.await { + Ok(Ok(snap)) => (StatusCode::ACCEPTED, Json(serde_json::to_value(snap).unwrap())).into_response(), + Ok(Err(e)) => (StatusCode::CONFLICT, Json(serde_json::json!({"error": e}))).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"no reply"}))).into_response(), + } +} + +async fn status_handler(State(st): State) -> impl IntoResponse { + let s = st.status.read().await; + match &s.session { + Some(sess) => (StatusCode::OK, Json(serde_json::to_value(sess).unwrap())).into_response(), + None => (StatusCode::OK, Json(serde_json::json!({"state":"idle"}))).into_response(), + } +} + +async fn stop(State(st): State) -> impl IntoResponse { + let (tx, rx) = oneshot::channel(); + if st.cmd_tx.send(CalCommand::Stop { reply: tx }).await.is_err() { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"ingest task unavailable"}))).into_response(); + } + match rx.await { + Ok(Ok(summary)) => (StatusCode::OK, Json(serde_json::to_value(summary).unwrap())).into_response(), + Ok(Err(e)) => (StatusCode::CONFLICT, Json(serde_json::json!({"error": e}))).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"no reply"}))).into_response(), + } +} + +async fn result(State(st): State) -> impl IntoResponse { + let s = st.status.read().await; + match &s.last_result { + Some(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())).into_response(), + None => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"no finalized baseline yet"}))).into_response(), + } +} + +async fn baselines(State(st): State) -> impl IntoResponse { + let dir = { st.status.read().await.output_dir.clone() }; + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(&dir) { + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("bin") { + let bytes = entry.metadata().map(|m| m.len()).unwrap_or(0); + out.push(serde_json::json!({ + "file": path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + "path": path.to_string_lossy(), + "bytes": bytes, + })); + } + } + } + Json(serde_json::json!({ "dir": dir, "baselines": out })) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn session_snapshot(sess: &ActiveSession, state: &str, note: Option) -> SessionStatus { + let frames = sess.recorder.frames_recorded() as usize; + let progress = if sess.target_frames == 0 { + 0.0 + } else { + (frames as f32 / sess.target_frames as f32).clamp(0.0, 1.0) + }; + let elapsed = sess.started.elapsed().as_secs_f32(); + let eta = if frames == 0 { + sess.deadline.saturating_duration_since(Instant::now()).as_secs_f32() + } else { + let per = elapsed / frames as f32; + (per * (sess.target_frames.saturating_sub(frames)) as f32).max(0.0) + }; + SessionStatus { + state: state.into(), + room_id: sess.room_id.clone(), + tier: sess.tier.clone(), + frames_recorded: frames, + target_frames: sess.target_frames, + progress, + z_median: sess.z_median, + z_max: sess.z_max, + motion_flagged: sess.motion_flagged, + elapsed_s: elapsed, + eta_s: eta, + note, + } +} + +fn baseline_averages(b: &BaselineCalibration) -> (f32, f32, f32) { + let n = b.subcarriers.len().max(1) as f32; + let mut amp = 0.0f32; + let mut var = 0.0f32; + let mut disp = 0.0f32; + for s in &b.subcarriers { + amp += s.amp_mean; + var += s.amp_variance; + disp += s.phase_dispersion; + } + (amp / n, var / n, disp / n) +} + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_params_defaults() { + let p = StartParams::default(); + assert_eq!(p.duration_s, 30); + assert_eq!(p.min_frames, 0); + assert!(p.tier.is_none()); + } + + #[test] + fn start_params_partial_json() { + let p: StartParams = serde_json::from_str(r#"{"room_id":"living-room","tier":"he20"}"#).unwrap(); + assert_eq!(p.room_id.as_deref(), Some("living-room")); + assert_eq!(p.tier.as_deref(), Some("he20")); + assert_eq!(p.duration_s, 30); // default applied + } + + #[test] + fn args_defaults() { + let a = CalibrateServeArgs { + http_port: 8090, + http_bind: "127.0.0.1".into(), + udp_port: 5005, + udp_bind: "0.0.0.0".into(), + tier: "ht20".into(), + output_dir: "./baselines".into(), + }; + assert_eq!(a.http_port, 8090); + assert_eq!(a.udp_port, 5005); + } +} diff --git a/v2/crates/wifi-densepose-cli/src/lib.rs b/v2/crates/wifi-densepose-cli/src/lib.rs index 6d9b2c0d..bb043f8d 100644 --- a/v2/crates/wifi-densepose-cli/src/lib.rs +++ b/v2/crates/wifi-densepose-cli/src/lib.rs @@ -27,6 +27,7 @@ use clap::{Parser, Subcommand}; pub mod calibrate; +pub mod calibrate_api; pub mod mat; /// WiFi-DensePose Command Line Interface @@ -52,6 +53,11 @@ pub enum Commands { /// baseline used for real-time motion z-scoring and CIR reference. Calibrate(calibrate::CalibrateArgs), + /// Run the calibration HTTP API (ADR-135/151) for a UI to drive. + /// Receives ESP32 CSI over UDP and exposes start/status/stop/result + /// endpoints at `/api/v1/calibration/*` (CORS-enabled). + CalibrateServe(calibrate_api::CalibrateServeArgs), + /// Mass Casualty Assessment Tool commands #[command(subcommand)] Mat(mat::MatCommand), diff --git a/v2/crates/wifi-densepose-cli/src/main.rs b/v2/crates/wifi-densepose-cli/src/main.rs index ccb4f027..806bf586 100644 --- a/v2/crates/wifi-densepose-cli/src/main.rs +++ b/v2/crates/wifi-densepose-cli/src/main.rs @@ -21,6 +21,9 @@ async fn main() -> anyhow::Result<()> { Commands::Calibrate(args) => { wifi_densepose_cli::calibrate::execute(args).await?; } + Commands::CalibrateServe(args) => { + wifi_densepose_cli::calibrate_api::execute(args).await?; + } Commands::Mat(mat_cmd) => { wifi_densepose_cli::mat::execute(mat_cmd).await?; }