From 41f28ae85e5715197ac6dcd43a0a4ff6f2781366 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 14:03:22 -0400 Subject: [PATCH] feat(adr-110): surface NodeSyncSnapshot in WebSocket sensing_update JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 23 — converts the iter 1-21 firmware-side mesh substrate from "works internally" to "visible to UI clients". WebSocket sensing_update broadcasts now carry a per-node optional `sync` object exposing the mesh state the iter 15-22 wire and storage capture: { "type": "sensing_update", ... "nodes": [ { "node_id": 9, ... "sync": { "offset_us": 1163565, // §A0.10's measured 1.16 s "is_leader": false, "is_valid": true, "smoothed": true, // EMA seeded "sequence": 20, // §A0.12 pairing key "csi_fps_ema": 10.0, // iter 18 measured rate "csi_fps_samples": 47 // ≥5 means trust csi_fps_ema } } ], ... } `sync` is `Option` with `#[serde(skip_serializing_if = "Option::is_none")]` so non-mesh paths (multi-BSSID scan / synthetic RSSI / simulation) emit no `sync` key — preserves backwards compatibility with existing UI clients. Plumbed into all four NodeInfo construction sites: 1. multi-BSSID scan path → sync: None 2. synthetic-RSSI fallback → sync: None 3. simulated frame path → sync: None 4. real ESP32 CSI path (line 4528) → sync: snapshot from NodeState 5. ADR-039 vitals-only path (line 4207) → sync: snapshot from NodeState cargo check -p wifi-densepose-sensing-server --no-default-features → green. UI clients (viz.html, future Tauri desktop, downstream automation) can now render leader/follower badges, jitter histograms, and the §A0.10 clock-skew trajectory without any further firmware or aggregator work. Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/src/main.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index ec8e23e6..83321dc8 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -293,6 +293,38 @@ struct NodeInfo { position: [f64; 3], amplitude: Vec, subcarrier_count: usize, + /// ADR-110 iter 23 — cross-board sync snapshot for this node. + /// `None` when no fresh sync packet has been observed (no mesh peer + /// reachable, or this node is a singleton). Populated from + /// `NodeState::latest_sync` and the iter 18 fps EMA. + #[serde(skip_serializing_if = "Option::is_none")] + sync: Option, +} + +/// ADR-110 iter 23 — per-node mesh-sync snapshot embedded in NodeInfo. +/// Surfaces what was previously only visible in the debug log so UI clients +/// can render leader / follower / offset / measured-fps live. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct NodeSyncSnapshot { + /// Smoothed local-vs-mesh offset in µs (negative when this node's clock + /// is behind the leader's — see §A0.10's measured -1.16 s on the bench). + offset_us: i64, + /// True when this node is the elected mesh leader. + is_leader: bool, + /// True when this node has heard a fresh leader beacon within the + /// firmware's VALID_WINDOW_MS gate (3 s). + is_valid: bool, + /// True once the EMA-smoothed offset has seeded (one full beacon round-trip). + smoothed: bool, + /// Sync packet's sequence high-water — used by the host to pair CSI + /// frames against this snapshot for §A0.12 mesh-time recovery. + sequence: u32, + /// Per-node measured CSI frame rate (iter 18 EMA). 20.0 until the + /// EMA has at least 5 samples; the actually-observed rate after that. + csi_fps_ema: f64, + /// How many CSI frames have contributed to `csi_fps_ema`. Clients can + /// treat <5 as "not yet trustworthy" and fall back to 20 Hz. + csi_fps_samples: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2034,6 +2066,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { position: [0.0, 0.0, 0.0], amplitude: multi_ap_frame.amplitudes, subcarrier_count: obs_count, + sync: None, // multi-BSSID scan path — no mesh peer }], features, classification, @@ -2178,6 +2211,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { position: [0.0, 0.0, 0.0], amplitude: vec![signal_pct], subcarrier_count: 1, + sync: None, // synthetic-RSSI fallback path — no mesh peer }], features, classification, @@ -4178,6 +4212,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { position: [2.0, 0.0, 1.5], amplitude: vec![], subcarrier_count: 0, + // Vitals-only path; still expose the sync snapshot + // if the node also speaks ESP-NOW. + sync: n.latest_sync.as_ref().map(|s| NodeSyncSnapshot { + offset_us: s.local_minus_epoch_us(), + is_leader: s.flags.is_leader, + is_valid: s.flags.is_valid, + smoothed: s.flags.smoothed_used, + sequence: s.sequence, + csi_fps_ema: n.csi_fps_ema, + csi_fps_samples: n.csi_fps_samples, + }), }) .collect(); @@ -4501,6 +4546,16 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { .map(|a| a.iter().take(56).cloned().collect()) .unwrap_or_default(), subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()), + // ADR-110 iter 23: snapshot the latest mesh sync. + sync: n.latest_sync.as_ref().map(|s| NodeSyncSnapshot { + offset_us: s.local_minus_epoch_us(), + is_leader: s.flags.is_leader, + is_valid: s.flags.is_valid, + smoothed: s.flags.smoothed_used, + sequence: s.sequence, + csi_fps_ema: n.csi_fps_ema, + csi_fps_samples: n.csi_fps_samples, + }), }) .collect(); @@ -4646,6 +4701,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { position: [2.0, 0.0, 1.5], amplitude: frame_amplitudes, subcarrier_count: frame_n_sub as usize, + sync: None, // simulated frame path — no mesh peer }], features: features.clone(), classification,