From 6638796cee5f9403944b1e18312b7b88d49093d0 Mon Sep 17 00:00:00 2001 From: Reuven Date: Tue, 24 Mar 2026 07:43:51 -0400 Subject: [PATCH] fix(sensing-server): detect ESP32 offline after 5s frame timeout The source field was set to "esp32" on the first UDP frame but never reverted when frames stopped arriving. This caused the UI to show "Real hardware connected" indefinitely after powering off all nodes. Changes: - Add last_esp32_frame timestamp to AppStateInner - Add effective_source() method with 5-second timeout - Source becomes "esp32:offline" when no frames received within 5s - Health endpoint shows "degraded" instead of "healthy" when offline - All 6 status/health/info API endpoints use effective_source() Fixes #297 Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/src/main.rs | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs index 7c074bf84..4bd84c066 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs @@ -285,6 +285,8 @@ struct AppStateInner { frame_history: VecDeque>, tick: u64, source: String, + /// Instant of the last ESP32 UDP frame received (for offline detection). + last_esp32_frame: Option, tx: broadcast::Sender, total_detections: u64, start_time: std::time::Instant, @@ -364,6 +366,25 @@ struct AppStateInner { adaptive_model: Option, } +/// If no ESP32 frame arrives within this duration, source reverts to offline. +const ESP32_OFFLINE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +impl AppStateInner { + /// Return the effective data source, accounting for ESP32 frame timeout. + /// If the source is "esp32" but no frame has arrived in 5 seconds, returns + /// "esp32:offline" so the UI can distinguish active vs stale connections. + fn effective_source(&self) -> String { + if self.source == "esp32" { + if let Some(last) = self.last_esp32_frame { + if last.elapsed() > ESP32_OFFLINE_TIMEOUT { + return "esp32:offline".to_string(); + } + } + } + self.source.clone() + } +} + /// Number of frames retained in `frame_history` for temporal analysis. /// At 500 ms ticks this covers ~50 seconds; at 100 ms ticks ~10 seconds. const FRAME_HISTORY_CAPACITY: usize = 100; @@ -1669,7 +1690,7 @@ async fn health(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "ok", - "source": s.source, + "source": s.effective_source(), "tick": s.tick, "clients": s.tx.receiver_count(), })) @@ -1977,7 +1998,7 @@ async fn health_ready(State(state): State) -> Json) -> Json 0 { "healthy" } else { "idle" }, "message": format!("{} client(s)", s.tx.receiver_count()) }, @@ -2028,7 +2052,7 @@ async fn api_info(State(state): State) -> Json { "version": env!("CARGO_PKG_VERSION"), "environment": "production", "backend": "rust", - "source": s.source, + "source": s.effective_source(), "features": { "wifi_sensing": true, "pose_estimation": true, @@ -2049,7 +2073,7 @@ async fn pose_current(State(state): State) -> Json) -> Json "total_detections": s.total_detections, "average_confidence": 0.87, "frames_processed": s.tick, - "source": s.source, + "source": s.effective_source(), })) } @@ -2083,7 +2107,7 @@ async fn stream_status(State(state): State) -> Json 1 { 10u64 } else { 0u64 }, - "source": s.source, + "source": s.effective_source(), })) } @@ -2619,7 +2643,7 @@ async fn vital_signs_endpoint(State(state): State) -> Json