feat(adr-110): surface NodeSyncSnapshot in WebSocket sensing_update JSON
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<NodeSyncSnapshot>` 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 <ruv@ruv.net>