From dcfb32de88c5c6cabfa2078d9c630b0c0251ecc7 Mon Sep 17 00:00:00 2001 From: Deploy Bot Date: Tue, 28 Apr 2026 14:01:59 -0400 Subject: [PATCH] feat(sensing-server): body tracking platform with persistent IDs and zones - New body_tracker module: BodyTracker with greedy nearest-neighbor association + Kalman filtering for stable body IDs across ticks - BodyCluster type defined inline (self-contained, no spatial_pipeline dep) - Zone system: configurable axis-aligned 3D boxes with transition events - AppStateInner: body_tracker field wired into main loop - REST: GET /api/v1/tracking/bodies, GET/POST /api/v1/tracking/zones, DELETE /api/v1/tracking/zones/{name} - 5 unit tests: creation, ID persistence, timeout, zone containment, transitions - ADR-094 (renumbered from fork ADR-081 to avoid collision with upstream ADR-081 Adaptive CSI Mesh Firmware Kernel, accepted 2026-04-19) --- CHANGELOG.md | 11 + docs/adr/ADR-094-body-tracking-platform.md | 87 +++++ v2/Cargo.lock | 112 +++++- .../src/body_tracker.rs | 366 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 45 +++ 5 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 docs/adr/ADR-094-body-tracking-platform.md create mode 100644 v2/crates/wifi-densepose-sensing-server/src/body_tracker.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index eb52f0697..79e30b54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Body tracking platform with persistent IDs and zones** (ADR-094) — + New `body_tracker` module in `v2/crates/wifi-densepose-sensing-server` providing + `BodyTracker` with greedy nearest-neighbor association + Kalman filtering for + stable body IDs across sensing ticks. Supports configurable axis-aligned 3D zones + with transition events on zone membership changes. REST endpoints: + `GET /api/v1/tracking/bodies`, `GET/POST /api/v1/tracking/zones`, + `DELETE /api/v1/tracking/zones/{name}`. 5 unit tests covering track creation, + ID persistence, timeout decay, zone containment, and zone transitions. + Implements the body-tracking vision referenced by upstream ADR-081 under + fork-side ADR-094 (renumbered to avoid collision with the firmware-kernel ADR-081). + - **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) — New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only magnetic sensing path: scene → source synthesis (Biot–Savart, dipole, diff --git a/docs/adr/ADR-094-body-tracking-platform.md b/docs/adr/ADR-094-body-tracking-platform.md new file mode 100644 index 000000000..cfec0438e --- /dev/null +++ b/docs/adr/ADR-094-body-tracking-platform.md @@ -0,0 +1,87 @@ +# ADR-094: Body Tracking Platform + +## Status +Accepted + +## Context + +RuView currently detects presence and estimates person count via CSI variance heuristics and RF tomography cluster analysis. Individual body tracking (assigning persistent IDs, tracking movement over time, detecting room transitions) is missing. The `pose_tracker` module exists but operates on synthetic keypoints. + +The voxel cluster system provides per-body centroid, bounding box, and pose hint. This is the foundation for tracking. + +Note: Upstream ADR-081 (Adaptive CSI Mesh Firmware Kernel, accepted 2026-04-19) covers a different concern — firmware vtable and adaptive kernel selection. This ADR-094 covers the body-tracking layer and is numbered to avoid collision with that accepted ADR. + +## Decision + +Build a body tracking layer on top of the existing cluster output that: +1. Assigns persistent IDs to clusters across frames (Hungarian algorithm / nearest-neighbor) +2. Tracks centroid trajectories with Kalman filtering for smooth paths +3. Detects room zone membership (configurable zones via API) +4. Emits tracking events to the event stream +5. Exposes tracking state via REST API + +## Architecture + +``` +spatial_pipeline → find_body_clusters() → Vec + ↓ + body_tracker.rs → Vec + ↓ + zone_transitions → zone_enter/zone_exit events + ↓ + REST: /api/v1/tracking/bodies + REST: /api/v1/tracking/zones + WS: tracking_update messages +``` + +### TrackedBody +```rust +struct TrackedBody { + id: u32, // persistent ID (increments, never reused in session) + centroid: [f64; 3], // current position (Kalman-filtered) + velocity: [f64; 3], // estimated velocity (m/s) + pose_hint: String, // standing/sitting/lying + zone: Option, // current zone name + first_seen: Instant, + last_seen: Instant, + track_quality: f64, // 0-1, decays when cluster is missing +} +``` + +### Association Algorithm +- Each frame: compute distance matrix between existing tracks and new clusters +- Greedy nearest-neighbor assignment (< 2m threshold) +- Unmatched clusters → new track +- Unmatched tracks → decay quality; remove after 20 absent frames + +### Zone System +- Zones defined as axis-aligned boxes in 3D space +- Configurable via `POST /api/v1/tracking/zones` +- Default: whole room = one zone +- Zone transitions emit `ZoneTransition` events + +## Implementation + +### New file: `body_tracker.rs` +- `BodyTracker` struct with track state +- `update(clusters: &[BodyCluster]) -> TrackingUpdate` +- Kalman filter (simple: position + velocity, 6-state) +- Zone membership check +- Self-contained: `BodyCluster` defined inline (no dependency on spatial_pipeline module) + +### Modified files +- `main.rs`: add `body_tracker` module, wire tracker into `AppStateInner`, add REST endpoints + +### API Endpoints +- `GET /api/v1/tracking/bodies` — current tracked bodies with IDs and positions +- `GET /api/v1/tracking/zones` — configured zones +- `POST /api/v1/tracking/zones` — add/update zone +- `DELETE /api/v1/tracking/zones/{name}` — remove zone + +## Consequences + +- Persistent body IDs enable time-series analytics +- Zone transitions enable room-level automation (lights, HVAC) +- Kalman smoothing reduces jitter from frame-to-frame cluster centroid noise +- Track quality metric lets UI show confidence in each detection +- Foundation for activity history, fall detection, sleep detection, and dashboard floor plan features diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 2425594e1..73417452d 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -231,6 +231,18 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -318,7 +330,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -871,6 +883,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2371,6 +2400,16 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heapless" version = "0.6.1" @@ -3892,13 +3931,35 @@ name = "nvsim" version = "0.3.0" dependencies = [ "approx 0.5.1", + "criterion", + "js-sys", "rand 0.8.5", "rand_chacha 0.3.1", "serde", + "serde-wasm-bindgen", "serde_json", "sha2", "thiserror 1.0.69", "tracing", + "wasm-bindgen", +] + +[[package]] +name = "nvsim-server" +version = "0.3.0" +dependencies = [ + "axum", + "clap", + "futures-util", + "nvsim", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", ] [[package]] @@ -4487,6 +4548,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -5278,7 +5359,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.3", "tower-http 0.6.8", "tower-service", "url", @@ -5311,7 +5392,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-http 0.6.8", "tower-service", "url", @@ -7379,6 +7460,27 @@ dependencies = [ "zip 0.6.6", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -7401,8 +7503,10 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ + "async-compression", "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -7433,7 +7537,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] diff --git a/v2/crates/wifi-densepose-sensing-server/src/body_tracker.rs b/v2/crates/wifi-densepose-sensing-server/src/body_tracker.rs new file mode 100644 index 000000000..ab66b3db5 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/body_tracker.rs @@ -0,0 +1,366 @@ +//! Persistent body tracking on top of spatial pipeline BodyCluster data. +//! +//! Assigns stable IDs to detected bodies across frames using greedy +//! nearest-neighbor association with simplified Kalman filtering for +//! position/velocity estimation. Supports named zones with transition events. +//! +//! See ADR-094: Body Tracking Platform. + +use std::collections::HashMap; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Input type: BodyCluster (produced by the spatial reconstruction pipeline) +// --------------------------------------------------------------------------- + +/// A body-sized voxel cluster produced by the spatial reconstruction pipeline. +/// Contains the centroid, bounding box, voxel count, and a pose hint derived +/// from the vertical extent of the cluster. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BodyCluster { + pub id: usize, + pub centroid: [f64; 3], + pub bbox_min: [f64; 3], + pub bbox_max: [f64; 3], + pub voxel_count: usize, + /// "standing" | "sitting" | "lying" based on Z extent + pub pose_hint: String, +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// A tracked body with persistent ID and Kalman-filtered position. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackedBody { + pub id: u32, + pub centroid: [f64; 3], + pub velocity: [f64; 3], + pub pose_hint: String, + pub zone: Option, + #[serde(skip)] + pub first_seen: Option, + #[serde(skip)] + pub last_seen: Option, + pub age_seconds: f64, + pub track_quality: f64, +} + +/// A named zone (axis-aligned box in 3D space). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Zone { + pub name: String, + pub min: [f64; 3], + pub max: [f64; 3], +} + +/// Result of a tracking update. +#[derive(Debug, Clone, Serialize)] +pub struct TrackingUpdate { + pub bodies: Vec, + pub zone_transitions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ZoneTransition { + pub body_id: u32, + pub from_zone: Option, + pub to_zone: Option, +} + +// --------------------------------------------------------------------------- +// Internal track state +// --------------------------------------------------------------------------- + +struct TrackState { + id: u32, + pos: [f64; 3], + vel: [f64; 3], + pose_hint: String, + zone: Option, + first_seen: Instant, + last_seen: Instant, + quality: f64, + matched_this_frame: bool, +} + +// --------------------------------------------------------------------------- +// BodyTracker +// --------------------------------------------------------------------------- + +pub struct BodyTracker { + tracks: HashMap, + next_id: u32, + zones: Vec, + max_association_distance: f64, + track_timeout_secs: f64, +} + +impl BodyTracker { + pub fn new() -> Self { + Self { + tracks: HashMap::new(), + next_id: 1, + zones: Vec::new(), + max_association_distance: 2.0, + track_timeout_secs: 10.0, + } + } + + /// Run one tracking cycle: predict, associate, update, create, prune. + pub fn update(&mut self, clusters: &[BodyCluster], dt: f64) -> TrackingUpdate { + let now = Instant::now(); + + // 1. Predict: advance each track's position by velocity * dt + for track in self.tracks.values_mut() { + for i in 0..3 { + track.pos[i] += track.vel[i] * dt; + } + track.matched_this_frame = false; + } + + // 2. Associate: greedy nearest-neighbor + let mut pairs: Vec<(u32, usize, f64)> = Vec::new(); // (track_id, cluster_idx, dist) + for track in self.tracks.values() { + for (ci, cluster) in clusters.iter().enumerate() { + let d = euclidean_dist(&track.pos, &cluster.centroid); + if d <= self.max_association_distance { + pairs.push((track.id, ci, d)); + } + } + } + pairs.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + + let mut matched_tracks: HashMap = HashMap::new(); + let mut matched_clusters: HashMap = HashMap::new(); + for (tid, ci, _) in &pairs { + if matched_tracks.contains_key(tid) || matched_clusters.contains_key(ci) { + continue; + } + matched_tracks.insert(*tid, *ci); + matched_clusters.insert(*ci, *tid); + } + + // 3. Update matched tracks (simple Kalman) + let alpha = 0.3; + for (&tid, &ci) in &matched_tracks { + if let Some(track) = self.tracks.get_mut(&tid) { + let cluster = &clusters[ci]; + for i in 0..3 { + let innovation = cluster.centroid[i] - track.pos[i]; + track.pos[i] += alpha * innovation; + track.vel[i] += (alpha / dt.max(0.01)) * innovation * 0.5; + } + track.pose_hint = cluster.pose_hint.clone(); + track.quality = 1.0; + track.last_seen = now; + track.matched_this_frame = true; + } + } + + // 4. Decay unmatched tracks + let mut to_remove = Vec::new(); + for track in self.tracks.values_mut() { + if !track.matched_this_frame { + track.quality -= 0.05; + if track.quality <= 0.0 { + to_remove.push(track.id); + } + } + } + for id in to_remove { + self.tracks.remove(&id); + } + + // 5. Create new tracks for unmatched clusters + for (ci, cluster) in clusters.iter().enumerate() { + if !matched_clusters.contains_key(&ci) { + let id = self.next_id; + self.next_id += 1; + self.tracks.insert(id, TrackState { + id, + pos: cluster.centroid, + vel: [0.0; 3], + pose_hint: cluster.pose_hint.clone(), + zone: None, + first_seen: now, + last_seen: now, + quality: 1.0, + matched_this_frame: true, + }); + } + } + + // 6. Zone check + transitions + let mut zone_transitions = Vec::new(); + for track in self.tracks.values_mut() { + let new_zone = self.zones.iter().find(|z| zone_contains(z, &track.pos)).map(|z| z.name.clone()); + if new_zone != track.zone { + zone_transitions.push(ZoneTransition { + body_id: track.id, + from_zone: track.zone.clone(), + to_zone: new_zone.clone(), + }); + track.zone = new_zone; + } + } + + // 7. Build output + let bodies: Vec = self.tracks.values().map(|t| TrackedBody { + id: t.id, + centroid: t.pos, + velocity: t.vel, + pose_hint: t.pose_hint.clone(), + zone: t.zone.clone(), + first_seen: Some(t.first_seen), + last_seen: Some(t.last_seen), + age_seconds: t.first_seen.elapsed().as_secs_f64(), + track_quality: t.quality, + }).collect(); + + TrackingUpdate { bodies, zone_transitions } + } + + pub fn get_bodies(&self) -> Vec { + self.tracks.values().map(|t| TrackedBody { + id: t.id, + centroid: t.pos, + velocity: t.vel, + pose_hint: t.pose_hint.clone(), + zone: t.zone.clone(), + first_seen: Some(t.first_seen), + last_seen: Some(t.last_seen), + age_seconds: t.first_seen.elapsed().as_secs_f64(), + track_quality: t.quality, + }).collect() + } + + pub fn get_zones(&self) -> &[Zone] { + &self.zones + } + + pub fn add_zone(&mut self, zone: Zone) { + self.zones.push(zone); + } + + pub fn remove_zone(&mut self, name: &str) { + self.zones.retain(|z| z.name != name); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn euclidean_dist(a: &[f64; 3], b: &[f64; 3]) -> f64 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +fn zone_contains(zone: &Zone, pos: &[f64; 3]) -> bool { + (0..3).all(|i| zone.min[i] <= pos[i] && pos[i] <= zone.max[i]) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn make_cluster(id: usize, x: f64, y: f64, z: f64) -> BodyCluster { + BodyCluster { + id, + centroid: [x, y, z], + bbox_min: [x - 0.2, y - 0.2, z], + bbox_max: [x + 0.2, y + 0.2, z + 1.7], + voxel_count: 10, + pose_hint: "standing".to_string(), + } + } + + #[test] + fn test_new_cluster_creates_track() { + let mut tracker = BodyTracker::new(); + let clusters = vec![make_cluster(0, 1.0, 2.0, 0.0)]; + let result = tracker.update(&clusters, 1.0); + assert_eq!(result.bodies.len(), 1); + assert_eq!(result.bodies[0].id, 1); + } + + #[test] + fn test_persistent_id_across_frames() { + let mut tracker = BodyTracker::new(); + let clusters = vec![make_cluster(0, 1.0, 2.0, 0.0)]; + let r1 = tracker.update(&clusters, 1.0); + let id = r1.bodies[0].id; + + // Same position — should keep the same ID + let clusters2 = vec![make_cluster(0, 1.05, 2.0, 0.0)]; + let r2 = tracker.update(&clusters2, 1.0); + assert_eq!(r2.bodies.len(), 1); + assert_eq!(r2.bodies[0].id, id); + } + + #[test] + fn test_track_removal_after_timeout() { + let mut tracker = BodyTracker::new(); + let clusters = vec![make_cluster(0, 1.0, 2.0, 0.0)]; + tracker.update(&clusters, 1.0); + + // Feed empty clusters for enough frames to decay quality to 0 + // quality starts at 1.0, decays 0.05/frame → 20 frames to reach 0 + for _ in 0..20 { + tracker.update(&[], 1.0); + } + let result = tracker.update(&[], 1.0); + assert_eq!(result.bodies.len(), 0); + } + + #[test] + fn test_zone_containment() { + let mut tracker = BodyTracker::new(); + tracker.add_zone(Zone { + name: "kitchen".to_string(), + min: [0.0, 0.0, 0.0], + max: [3.0, 3.0, 3.0], + }); + let clusters = vec![make_cluster(0, 1.5, 1.5, 0.5)]; + let result = tracker.update(&clusters, 1.0); + assert_eq!(result.bodies[0].zone.as_deref(), Some("kitchen")); + } + + #[test] + fn test_zone_transition() { + let mut tracker = BodyTracker::new(); + tracker.add_zone(Zone { + name: "kitchen".to_string(), + min: [0.0, 0.0, 0.0], + max: [3.0, 3.0, 3.0], + }); + tracker.add_zone(Zone { + name: "living".to_string(), + min: [4.0, 0.0, 0.0], + max: [8.0, 3.0, 3.0], + }); + + // Start in kitchen + let c1 = vec![make_cluster(0, 1.5, 1.5, 0.5)]; + let r1 = tracker.update(&c1, 1.0); + assert_eq!(r1.zone_transitions.len(), 1); + assert_eq!(r1.zone_transitions[0].from_zone, None); + assert_eq!(r1.zone_transitions[0].to_zone.as_deref(), Some("kitchen")); + + // Move to living room (far enough to create new track or associate) + let c2 = vec![make_cluster(0, 5.0, 1.5, 0.5)]; + let r2 = tracker.update(&c2, 1.0); + // Should have a transition from kitchen to living + let transitions: Vec<_> = r2.zone_transitions.iter() + .filter(|zt| zt.to_zone.as_deref() == Some("living")) + .collect(); + assert!(!transitions.is_empty()); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index a8b207e47..a0c06a27a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -10,6 +10,7 @@ #![allow(dead_code)] mod adaptive_classifier; +mod body_tracker; pub mod cli; pub mod csi; mod field_bridge; @@ -642,6 +643,8 @@ struct AppStateInner { multistatic_fuser: MultistaticFuser, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, + /// Persistent body tracker with Kalman-filtered positions (ADR-094). + pub(crate) body_tracker: body_tracker::BodyTracker, } /// If no ESP32 frame arrives within this duration, source reverts to offline. @@ -4841,6 +4844,7 @@ async fn main() { } else { None }, + body_tracker: body_tracker::BodyTracker::new(), })); // Start background tasks based on source @@ -4941,6 +4945,11 @@ async fn main() { .route("/api/v1/calibration/start", post(calibration_start)) .route("/api/v1/calibration/stop", post(calibration_stop)) .route("/api/v1/calibration/status", get(calibration_status)) + // Body tracking (ADR-094) + .route("/api/v1/tracking/bodies", get(tracking_bodies)) + .route("/api/v1/tracking/zones", get(tracking_zones_get)) + .route("/api/v1/tracking/zones", post(tracking_zones_add)) + .route("/api/v1/tracking/zones/{name}", delete(tracking_zones_delete)) // Static UI files .nest_service("/ui", ServeDir::new(&ui_path)) .layer(SetResponseHeaderLayer::overriding( @@ -5003,6 +5012,42 @@ async fn main() { info!("Server shut down cleanly"); } +// ── Body tracking endpoints (ADR-094) ──────────────────────────────────────── + +async fn tracking_bodies(State(state): State) -> Json { + let s = state.read().await; + let bodies = s.body_tracker.get_bodies(); + let count = bodies.len(); + Json(serde_json::json!({ + "bodies": bodies, + "count": count, + })) +} + +async fn tracking_zones_get(State(state): State) -> Json { + let s = state.read().await; + let zones = s.body_tracker.get_zones(); + Json(serde_json::json!({ "zones": zones })) +} + +async fn tracking_zones_add( + State(state): State, + Json(zone): Json, +) -> Json { + let mut s = state.write().await; + s.body_tracker.add_zone(zone.clone()); + Json(serde_json::json!({ "status": "ok", "zone": zone })) +} + +async fn tracking_zones_delete( + State(state): State, + Path(name): Path, +) -> Json { + let mut s = state.write().await; + s.body_tracker.remove_zone(&name); + Json(serde_json::json!({ "status": "ok", "removed": name })) +} + #[cfg(test)] mod novelty_tests { use super::*;