diff --git a/docs/integration/calibration-appliance-integration.md b/docs/integration/calibration-appliance-integration.md index a7c32588..00e9dcdb 100644 --- a/docs/integration/calibration-appliance-integration.md +++ b/docs/integration/calibration-appliance-integration.md @@ -88,7 +88,7 @@ UUID (`calibration_uuid()`) is the `baseline_id` referenced by enrollments and b "session": { "room_id": "...", "baseline_id": "...", "events": [ /* event-sourced audit log */ ] } } ``` -Anchor labels (fixed sequence): `empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture`. +Anchor labels (fixed sequence, **JSON wire = snake_case**, test-enforced): `empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture`. ### 3.4 Specialist bank — JSON (`train-room` → `room-watch` / runtime) @@ -136,6 +136,7 @@ Anchor labels (fixed sequence): `empty, stand_still, sit, lie_down, breathe_slow | GET | `/api/v1/calibration/result` | last finalized baseline summary | | GET | `/api/v1/calibration/baselines` | list persisted `.bin` baselines | | GET | `/api/v1/room/state?bank=` | **live RoomState** (mixture-of-specialists over the CSI window; bank resolved as a sanitized name under `output_dir`) | +| POST | `/api/v1/room/train` | `{ room_id, baseline_id, anchors[] }` → train + persist a specialist bank as `/.json` (read back via `/room/state?bank=`) | A single background task owns the UDP socket + recorder (handlers talk to it over an mpsc channel + shared status snapshot), so the API is non-blocking. Enrollment/train/room-state are CLI today diff --git a/v2/crates/wifi-densepose-calibration/src/anchor.rs b/v2/crates/wifi-densepose-calibration/src/anchor.rs index 868fc8e8..ae91ecfb 100644 --- a/v2/crates/wifi-densepose-calibration/src/anchor.rs +++ b/v2/crates/wifi-densepose-calibration/src/anchor.rs @@ -20,7 +20,11 @@ pub enum Posture { } /// The fixed guided-anchor sequence (ADR-151 §2.2). +/// +/// Serializes as snake_case (`empty`, `stand_still`, …) to match +/// [`AnchorLabel::as_str`] and the documented JSON contract. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum AnchorLabel { /// Empty room reference (reuses the ADR-135 baseline). Empty, @@ -284,6 +288,17 @@ mod tests { assert_eq!(AnchorLabel::from_str("nope"), None); } + #[test] + fn label_serde_is_snake_case_matching_as_str() { + // The JSON wire format must equal as_str() (the documented contract). + for l in AnchorLabel::SEQUENCE { + let json = serde_json::to_string(&l).unwrap(); + assert_eq!(json, format!("\"{}\"", l.as_str())); + let back: AnchorLabel = serde_json::from_str(&json).unwrap(); + assert_eq!(back, l); + } + } + #[test] fn sequence_order_and_next() { let mut s = EnrollmentSession::new("living-room", "base-1", 0); diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index 5522e91f..43034a3d 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize}; use tokio::net::UdpSocket; use tokio::sync::{mpsc, oneshot, RwLock}; use tower_http::cors::CorsLayer; -use wifi_densepose_calibration::extract::Features; +use wifi_densepose_calibration::extract::{AnchorFeature, Features}; use wifi_densepose_calibration::{MixtureOfSpecialists, SpecialistBank}; use wifi_densepose_core::types::CsiFrame; use wifi_densepose_signal::{BaselineCalibration, CalibrationRecorder}; @@ -274,6 +274,7 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> { .route("/api/v1/calibration/result", get(result)) .route("/api/v1/calibration/baselines", get(baselines)) .route("/api/v1/room/state", get(room_state)) + .route("/api/v1/room/train", post(train_room)) .layer(CorsLayer::permissive()) .with_state(state); @@ -528,7 +529,8 @@ async fn descriptor() -> impl IntoResponse { "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", - "GET /api/v1/room/state?bank=": "live mixture-of-specialists RoomState over the CSI window" + "GET /api/v1/room/state?bank=": "live mixture-of-specialists RoomState over the CSI window", + "POST /api/v1/room/train": "{ room_id, baseline_id, anchors[] } → train + persist a specialist bank" } })) } @@ -588,6 +590,47 @@ async fn result(State(st): State) -> impl IntoResponse { } } +/// Body for `POST /api/v1/room/train` — an enrollment (CLI `enroll` output or +/// any client that gathered labelled anchor features). +#[derive(Deserialize)] +struct TrainRequest { + room_id: String, + baseline_id: String, + #[serde(default)] + anchors: Vec, +} + +/// Train a per-room specialist bank from posted anchors and persist it as +/// `/.json` (the name `room-state` reads back). +async fn train_room(State(st): State, Json(req): Json) -> impl IntoResponse { + if req.anchors.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"no anchors in request"}))).into_response(); + } + let at = (unix_ms() / 1000) as i64; + let bank = match SpecialistBank::train(&req.room_id, &req.baseline_id, &req.anchors, at) { + Ok(b) => b, + Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("training failed: {e}")}))).into_response(), + }; + let name = sanitize_room_id(&req.room_id); + let dir = { st.status.read().await.output_dir.clone() }; + let path = format!("{dir}/{name}.json"); + let json = match bank.to_json() { + Ok(j) => j, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("serialize: {e}")}))).into_response(), + }; + if let Err(e) = tokio::fs::write(&path, json).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("cannot write {path}: {e}")}))).into_response(); + } + let kinds: Vec = bank.trained_kinds().iter().map(|k| format!("{k:?}")).collect(); + (StatusCode::OK, Json(serde_json::json!({ + "room_id": bank.room_id, + "bank": name, // pass as ?bank= to /room/state + "anchor_count": bank.anchor_count, + "specialists": kinds, + "path": path, + }))).into_response() +} + /// Query for `GET /api/v1/room/state`. #[derive(Deserialize)] struct RoomStateQuery {