From 40f19622af9109127e3ca894f2141ee8613e0bef Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 27 Mar 2026 17:31:06 -0400 Subject: [PATCH] fix(firmware,server): watchdog crash + no detection from edge vitals (#321, #323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(firmware): address review findings — idle busy-spin and observability - Fix pdMS_TO_TICKS(5)==0 at 100Hz causing busy-spin in idle path (use vTaskDelay(1) instead) - Post-batch yield now 2 ticks (20ms) for genuinely longer pause - Add s_ring_drops counter to ring_push for diagnosing frame drops - Expose drop count in periodic vitals log line Co-Authored-By: claude-flow * fix(server): set breathing_band_power for skeleton animation from vitals When presence is detected via edge vitals, set breathing_band_power to 0.5 so the UI's torso breathing animation works. Previously hardcoded to 0.0 which made the skeleton appear static even when breathing rate was being reported. Co-Authored-By: claude-flow --- .../esp32-csi-node/main/edge_processing.c | 37 +++++--- .../wifi-densepose-sensing-server/src/main.rs | 84 +++++++++++++++++++ 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index 0911f383..1cd74a65 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -41,12 +41,14 @@ static const char *TAG = "edge_proc"; * ====================================================================== */ static edge_ring_buf_t s_ring; +static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */ static inline bool ring_push(const uint8_t *iq, uint16_t len, int8_t rssi, uint8_t channel) { uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS; if (next == s_ring.tail) { + s_ring_drops++; return false; /* Full — drop frame. */ } @@ -788,12 +790,13 @@ static void process_frame(const edge_ring_slot_t *slot) if ((s_frame_count % 200) == 0) { ESP_LOGI(TAG, "Vitals: br=%.1f hr=%.1f motion=%.4f pres=%s " - "fall=%s persons=%u frames=%lu", + "fall=%s persons=%u frames=%lu drops=%lu", s_breathing_bpm, s_heartrate_bpm, s_motion_energy, s_presence_detected ? "YES" : "no", s_fall_detected ? "YES" : "no", (unsigned)s_latest_pkt.n_persons, - (unsigned long)s_frame_count); + (unsigned long)s_frame_count, + (unsigned long)s_ring_drops); } } @@ -831,18 +834,32 @@ 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) { + /* Post-batch yield: 2 ticks (~20 ms at 100 Hz) so IDLE1 can + * run and feed the Core 1 watchdog even under sustained load. + * This is intentionally longer than the 1-tick inter-frame yield. */ + vTaskDelay(2); } else { - /* No frames available — yield briefly. */ - vTaskDelay(pdMS_TO_TICKS(1)); + /* No frames available — sleep one full tick. + * NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */ + vTaskDelay(1); } } } 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..1ae12c87 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: if vitals.presence { 0.5 } else { 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; }