@@ -285,6 +285,8 @@ struct AppStateInner {
285285 frame_history : VecDeque < Vec < f64 > > ,
286286 tick : u64 ,
287287 source : String ,
288+ /// Instant of the last ESP32 UDP frame received (for offline detection).
289+ last_esp32_frame : Option < std:: time:: Instant > ,
288290 tx : broadcast:: Sender < String > ,
289291 total_detections : u64 ,
290292 start_time : std:: time:: Instant ,
@@ -364,6 +366,25 @@ struct AppStateInner {
364366 adaptive_model : Option < adaptive_classifier:: AdaptiveModel > ,
365367}
366368
369+ /// If no ESP32 frame arrives within this duration, source reverts to offline.
370+ const ESP32_OFFLINE_TIMEOUT : std:: time:: Duration = std:: time:: Duration :: from_secs ( 5 ) ;
371+
372+ impl AppStateInner {
373+ /// Return the effective data source, accounting for ESP32 frame timeout.
374+ /// If the source is "esp32" but no frame has arrived in 5 seconds, returns
375+ /// "esp32:offline" so the UI can distinguish active vs stale connections.
376+ fn effective_source ( & self ) -> String {
377+ if self . source == "esp32" {
378+ if let Some ( last) = self . last_esp32_frame {
379+ if last. elapsed ( ) > ESP32_OFFLINE_TIMEOUT {
380+ return "esp32:offline" . to_string ( ) ;
381+ }
382+ }
383+ }
384+ self . source . clone ( )
385+ }
386+ }
387+
367388/// Number of frames retained in `frame_history` for temporal analysis.
368389/// At 500 ms ticks this covers ~50 seconds; at 100 ms ticks ~10 seconds.
369390const FRAME_HISTORY_CAPACITY : usize = 100 ;
@@ -1669,7 +1690,7 @@ async fn health(State(state): State<SharedState>) -> Json<serde_json::Value> {
16691690 let s = state. read ( ) . await ;
16701691 Json ( serde_json:: json!( {
16711692 "status" : "ok" ,
1672- "source" : s. source ,
1693+ "source" : s. effective_source ( ) ,
16731694 "tick" : s. tick,
16741695 "clients" : s. tx. receiver_count( ) ,
16751696 } ) )
@@ -1977,7 +1998,7 @@ async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Valu
19771998 let s = state. read ( ) . await ;
19781999 Json ( serde_json:: json!( {
19792000 "status" : "ready" ,
1980- "source" : s. source ,
2001+ "source" : s. effective_source ( ) ,
19812002 } ) )
19822003}
19832004
@@ -1988,7 +2009,10 @@ async fn health_system(State(state): State<SharedState>) -> Json<serde_json::Val
19882009 "status" : "healthy" ,
19892010 "components" : {
19902011 "api" : { "status" : "healthy" , "message" : "Rust Axum server" } ,
1991- "hardware" : { "status" : "healthy" , "message" : format!( "Source: {}" , s. source) } ,
2012+ "hardware" : {
2013+ "status" : if s. effective_source( ) . ends_with( ":offline" ) { "degraded" } else { "healthy" } ,
2014+ "message" : format!( "Source: {}" , s. effective_source( ) )
2015+ } ,
19922016 "pose" : { "status" : "healthy" , "message" : "WiFi-derived pose estimation" } ,
19932017 "stream" : { "status" : if s. tx. receiver_count( ) > 0 { "healthy" } else { "idle" } ,
19942018 "message" : format!( "{} client(s)" , s. tx. receiver_count( ) ) } ,
@@ -2028,7 +2052,7 @@ async fn api_info(State(state): State<SharedState>) -> Json<serde_json::Value> {
20282052 "version" : env!( "CARGO_PKG_VERSION" ) ,
20292053 "environment" : "production" ,
20302054 "backend" : "rust" ,
2031- "source" : s. source ,
2055+ "source" : s. effective_source ( ) ,
20322056 "features" : {
20332057 "wifi_sensing" : true ,
20342058 "pose_estimation" : true ,
@@ -2049,7 +2073,7 @@ async fn pose_current(State(state): State<SharedState>) -> Json<serde_json::Valu
20492073 "timestamp" : chrono:: Utc :: now( ) . timestamp_millis( ) as f64 / 1000.0 ,
20502074 "persons" : persons,
20512075 "total_persons" : persons. len( ) ,
2052- "source" : s. source ,
2076+ "source" : s. effective_source ( ) ,
20532077 } ) )
20542078}
20552079
@@ -2059,7 +2083,7 @@ async fn pose_stats(State(state): State<SharedState>) -> Json<serde_json::Value>
20592083 "total_detections" : s. total_detections,
20602084 "average_confidence" : 0.87 ,
20612085 "frames_processed" : s. tick,
2062- "source" : s. source ,
2086+ "source" : s. effective_source ( ) ,
20632087 } ) )
20642088}
20652089
@@ -2083,7 +2107,7 @@ async fn stream_status(State(state): State<SharedState>) -> Json<serde_json::Val
20832107 "active" : true ,
20842108 "clients" : s. tx. receiver_count( ) ,
20852109 "fps" : if s. tick > 1 { 10u64 } else { 0u64 } ,
2086- "source" : s. source ,
2110+ "source" : s. effective_source ( ) ,
20872111 } ) )
20882112}
20892113
@@ -2619,7 +2643,7 @@ async fn vital_signs_endpoint(State(state): State<SharedState>) -> Json<serde_js
26192643 "heartbeat_samples" : hb_len,
26202644 "heartbeat_capacity" : hb_cap,
26212645 } ,
2622- "source" : s. source ,
2646+ "source" : s. effective_source ( ) ,
26232647 "tick" : s. tick,
26242648 } ) )
26252649}
@@ -2825,6 +2849,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
28252849
28262850 let mut s = state. write ( ) . await ;
28272851 s. source = "esp32" . to_string ( ) ;
2852+ s. last_esp32_frame = Some ( std:: time:: Instant :: now ( ) ) ;
28282853
28292854 // Append current amplitudes to history before extracting features so
28302855 // that temporal analysis includes the most recent frame.
@@ -3607,6 +3632,7 @@ async fn main() {
36073632 frame_history : VecDeque :: new ( ) ,
36083633 tick : 0 ,
36093634 source : source. into ( ) ,
3635+ last_esp32_frame : None ,
36103636 tx,
36113637 total_detections : 0 ,
36123638 start_time : std:: time:: Instant :: now ( ) ,
@@ -3781,7 +3807,7 @@ async fn main() {
37813807 "WiFi DensePose sensing model state" ,
37823808 ) ;
37833809 builder. add_metadata ( & serde_json:: json!( {
3784- "source" : s. source ,
3810+ "source" : s. effective_source ( ) ,
37853811 "total_ticks" : s. tick,
37863812 "total_detections" : s. total_detections,
37873813 "uptime_secs" : s. start_time. elapsed( ) . as_secs( ) ,
0 commit comments