From bc5af07c15b64254c38ff7e34ae9659238bb5188 Mon Sep 17 00:00:00 2001 From: ruv Date: Fri, 27 Mar 2026 17:07:55 -0400 Subject: [PATCH] fix(firmware,server): watchdog crash on busy LANs + no detection from edge vitals (#321, #323) **Firmware (#321):** edge_dsp task now batch-limits frame processing to 4 frames before a 10ms yield. On corporate LANs with high CSI frame rates, the previous 1-tick-per-frame yield wasn't enough to prevent IDLE1 starvation and task watchdog triggers. **Sensing server (#323):** When ESP32 runs the edge DSP pipeline (Tier 2+), it sends vitals packets (magic 0xC5110002) instead of raw CSI frames. Previously, the server broadcast these as raw edge_vitals but never generated a sensing_update, so the UI showed "connected" but "0 persons". Now synthesizes a full sensing_update from vitals data including classification, person count, and pose generation. Closes #321 Closes #323 Co-Authored-By: claude-flow --- .../esp32-csi-node/main/edge_processing.c | 26 ++++-- .../wifi-densepose-sensing-server/src/main.rs | 84 +++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index 0911f383..ba28ef00 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -831,18 +831,30 @@ static void edge_task(void *arg) edge_ring_slot_t slot; + /* Maximum frames to process before a longer yield. On busy LANs + * (corporate networks, many APs), the ring buffer fills continuously. + * Without a batch limit the task processes frames back-to-back with + * only 1-tick yields, which on high frame rates can still starve + * IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */ + const uint8_t BATCH_LIMIT = 4; + while (1) { - if (ring_pop(&slot)) { + uint8_t processed = 0; + + while (processed < BATCH_LIMIT && ring_pop(&slot)) { process_frame(&slot); - /* Yield after every frame to feed the Core 1 watchdog. - * process_frame() is CPU-intensive (biquad filters, Welford stats, - * BPM estimation, multi-person vitals) and can take several ms. - * Without this yield, edge_dsp at priority 5 starves IDLE1 at - * priority 0, triggering the task watchdog. See issue #266. */ + processed++; + /* 1-tick yield between frames within a batch. */ vTaskDelay(1); + } + + if (processed > 0) { + /* Longer yield after each batch so IDLE1 can run and feed + * the Core 1 watchdog even under sustained load. */ + vTaskDelay(pdMS_TO_TICKS(10)); } else { /* No frames available — yield briefly. */ - vTaskDelay(pdMS_TO_TICKS(1)); + vTaskDelay(pdMS_TO_TICKS(5)); } } } 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 4bd84c06..78a9604c 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 @@ -2820,6 +2820,90 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { })) { let _ = s.tx.send(json); } + + // Issue #323: Also emit a sensing_update so the UI renders + // detections for ESP32 nodes running the edge DSP pipeline + // (Tier 2+). Without this, vitals arrive but the UI shows + // "no detection" because it only renders sensing_update msgs. + s.source = "esp32".to_string(); + s.last_esp32_frame = Some(std::time::Instant::now()); + s.tick += 1; + let tick = s.tick; + + let motion_level = if vitals.motion { "present_moving" } + else if vitals.presence { "present_still" } + else { "absent" }; + let motion_score = if vitals.motion { 0.8 } + else if vitals.presence { 0.3 } + else { 0.05 }; + let est_persons = if vitals.presence { + (vitals.n_persons as usize).max(1) + } else { + 0 + }; + + let features = FeatureInfo { + mean_rssi: vitals.rssi as f64, + variance: vitals.motion_energy as f64, + motion_band_power: vitals.motion_energy as f64, + breathing_band_power: 0.0, + dominant_freq_hz: vitals.breathing_rate_bpm / 60.0, + change_points: 0, + spectral_power: vitals.motion_energy as f64, + }; + let classification = ClassificationInfo { + motion_level: motion_level.to_string(), + presence: vitals.presence, + confidence: vitals.presence_score as f64, + }; + let signal_field = generate_signal_field( + vitals.rssi as f64, motion_score, vitals.breathing_rate_bpm / 60.0, + (vitals.presence_score as f64).min(1.0), &[], + ); + + let mut update = SensingUpdate { + msg_type: "sensing_update".to_string(), + timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, + source: "esp32".to_string(), + tick, + nodes: vec![NodeInfo { + node_id: vitals.node_id, + rssi_dbm: vitals.rssi as f64, + position: [2.0, 0.0, 1.5], + amplitude: vec![], + subcarrier_count: 0, + }], + features: features.clone(), + classification, + signal_field, + vital_signs: Some(VitalSigns { + breathing_rate_bpm: if vitals.breathing_rate_bpm > 0.0 { Some(vitals.breathing_rate_bpm) } else { None }, + heart_rate_bpm: if vitals.heartrate_bpm > 0.0 { Some(vitals.heartrate_bpm) } else { None }, + breathing_confidence: if vitals.presence { 0.7 } else { 0.0 }, + heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 }, + signal_quality: vitals.presence_score as f64, + }), + enhanced_motion: None, + enhanced_breathing: None, + posture: None, + signal_quality_score: None, + quality_verdict: None, + bssid_count: None, + pose_keypoints: None, + model_status: None, + persons: None, + estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, + }; + + let persons = derive_pose_from_sensing(&update); + if !persons.is_empty() { + update.persons = Some(persons); + } + + if let Ok(json) = serde_json::to_string(&update) { + let _ = s.tx.send(json); + } + s.latest_update = Some(update); s.edge_vitals = Some(vitals); continue; }