mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
ADR-110: ESP32-C6 firmware extension (#764)
Closes the firmware-side ADR-110 design at v0.7.0-esp32 after a 38-iter /loop SOTA sprint. Headline (bench, COM9+COM12 ESP32-C6): - 99.56% cross-board RX, 104.1 µs smoothed offset stdev (≤100 µs §2.4 target met) - 3.95× EMA suppression, 1.4 ppm crystal skew preserved 4 firmware releases: v0.6.7 / v0.6.8 / v0.6.9 / v0.7.0-esp32. 42 ADR-110 unit tests, 1761 v2 workspace tests, full Firmware CI + QEMU green.
This commit is contained in:
@@ -102,4 +102,216 @@ pub struct Args {
|
||||
/// Start field model calibration on boot (empty room required)
|
||||
#[arg(long)]
|
||||
pub calibrate: bool,
|
||||
|
||||
// ─── ADR-115 §3.8 — MQTT publisher (HA-DISCO) ──────────────────────────
|
||||
/// Enable MQTT publisher with HA auto-discovery
|
||||
#[arg(long, env = "RUVIEW_MQTT")]
|
||||
pub mqtt: bool,
|
||||
|
||||
/// MQTT broker host
|
||||
#[arg(long, env = "RUVIEW_MQTT_HOST", default_value = "localhost")]
|
||||
pub mqtt_host: String,
|
||||
|
||||
/// MQTT broker port (defaults: 1883 plain / 8883 with TLS)
|
||||
#[arg(long, env = "RUVIEW_MQTT_PORT")]
|
||||
pub mqtt_port: Option<u16>,
|
||||
|
||||
/// MQTT username
|
||||
#[arg(long, env = "RUVIEW_MQTT_USERNAME")]
|
||||
pub mqtt_username: Option<String>,
|
||||
|
||||
/// Environment variable holding the MQTT password
|
||||
#[arg(long, default_value = "MQTT_PASSWORD")]
|
||||
pub mqtt_password_env: String,
|
||||
|
||||
/// MQTT client ID (default: wifi-densepose-<hostname>)
|
||||
#[arg(long, env = "RUVIEW_MQTT_CLIENT_ID")]
|
||||
pub mqtt_client_id: Option<String>,
|
||||
|
||||
/// Discovery topic prefix (ADR-115 §9.2 — accepted: `homeassistant`)
|
||||
#[arg(long, env = "RUVIEW_MQTT_PREFIX", default_value = "homeassistant")]
|
||||
pub mqtt_prefix: String,
|
||||
|
||||
/// Enable TLS to the broker
|
||||
#[arg(long, env = "RUVIEW_MQTT_TLS")]
|
||||
pub mqtt_tls: bool,
|
||||
|
||||
/// CA bundle for TLS
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub mqtt_ca_file: Option<PathBuf>,
|
||||
|
||||
/// Client certificate for mTLS
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub mqtt_client_cert: Option<PathBuf>,
|
||||
|
||||
/// Client key for mTLS
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub mqtt_client_key: Option<PathBuf>,
|
||||
|
||||
/// Discovery refresh interval (seconds)
|
||||
#[arg(long, default_value = "600")]
|
||||
pub mqtt_refresh_secs: u64,
|
||||
|
||||
/// Vitals publish rate (Hz) — HR/BR
|
||||
#[arg(long, default_value = "0.2")]
|
||||
pub mqtt_rate_vitals: f64,
|
||||
|
||||
/// Motion publish rate (Hz)
|
||||
#[arg(long, default_value = "1.0")]
|
||||
pub mqtt_rate_motion: f64,
|
||||
|
||||
/// Person count publish rate (Hz)
|
||||
#[arg(long, default_value = "1.0")]
|
||||
pub mqtt_rate_count: f64,
|
||||
|
||||
/// RSSI publish rate (Hz)
|
||||
#[arg(long, default_value = "0.1")]
|
||||
pub mqtt_rate_rssi: f64,
|
||||
|
||||
/// Publish pose keypoints over MQTT (off by default for bandwidth)
|
||||
#[arg(long)]
|
||||
pub mqtt_publish_pose: bool,
|
||||
|
||||
/// Pose publish rate (Hz) when --mqtt-publish-pose is set
|
||||
#[arg(long, default_value = "1.0")]
|
||||
pub mqtt_rate_pose: f64,
|
||||
|
||||
// ─── ADR-115 §3.10 — Privacy mode ──────────────────────────────────────
|
||||
/// Strip biometrics (HR/BR/pose) before any MQTT or Matter publish.
|
||||
/// Discovery for those entities is suppressed entirely — the controller
|
||||
/// never sees them exist. Implements the ADR-106 primitive-isolation
|
||||
/// contract at the integration boundary.
|
||||
#[arg(long, env = "RUVIEW_PRIVACY_MODE")]
|
||||
pub privacy_mode: bool,
|
||||
|
||||
// ─── ADR-115 §3.11 — Matter Bridge (HA-FABRIC) ─────────────────────────
|
||||
/// Enable Matter Bridge
|
||||
#[arg(long, env = "RUVIEW_MATTER")]
|
||||
pub matter: bool,
|
||||
|
||||
/// Write Matter setup code + QR string to this file on first start
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub matter_setup_file: Option<PathBuf>,
|
||||
|
||||
/// Wipe stored Matter fabric credentials before starting
|
||||
#[arg(long)]
|
||||
pub matter_reset: bool,
|
||||
|
||||
/// Matter vendor ID (default: dev VID 0xFFF1 per ADR-115 §9.9)
|
||||
#[arg(long, default_value = "0xFFF1")]
|
||||
pub matter_vendor_id: String,
|
||||
|
||||
/// Matter product ID (default: 0x8001)
|
||||
#[arg(long, default_value = "0x8001")]
|
||||
pub matter_product_id: String,
|
||||
|
||||
// ─── ADR-115 §3.12 — Semantic Inference (HA-MIND) ─────────────────────
|
||||
/// Enable semantic inference layer (sleeping/distress/room-active/etc).
|
||||
/// Default ON — primitives are the primary product surface.
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub semantic: bool,
|
||||
|
||||
/// Per-primitive thresholds file
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub semantic_thresholds_file: Option<PathBuf>,
|
||||
|
||||
/// Zone-tag map (e.g. {"bathroom": ["zone_3"]})
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub semantic_zones_file: Option<PathBuf>,
|
||||
|
||||
/// Days of history for personalised baselines
|
||||
#[arg(long, default_value = "14")]
|
||||
pub semantic_baseline_window_days: u32,
|
||||
|
||||
/// Disable a specific semantic primitive (e.g. `sleeping`); repeatable.
|
||||
/// Valid names: sleeping, distress, room_active, elderly_anomaly,
|
||||
/// meeting, bathroom, fall_risk, bed_exit, no_movement, multi_room.
|
||||
#[arg(long = "no-semantic", value_name = "PRIMITIVE")]
|
||||
pub no_semantic: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
/// MQTT flags default safely (disabled).
|
||||
#[test]
|
||||
fn mqtt_defaults_disabled() {
|
||||
let args = Args::parse_from(["sensing-server"]);
|
||||
assert!(!args.mqtt, "--mqtt must default to false");
|
||||
assert_eq!(args.mqtt_host, "localhost");
|
||||
assert_eq!(args.mqtt_prefix, "homeassistant");
|
||||
assert_eq!(args.mqtt_refresh_secs, 600);
|
||||
assert_eq!(args.mqtt_rate_vitals, 0.2);
|
||||
assert_eq!(args.mqtt_rate_motion, 1.0);
|
||||
assert_eq!(args.mqtt_rate_count, 1.0);
|
||||
assert_eq!(args.mqtt_rate_rssi, 0.1);
|
||||
assert!(!args.mqtt_publish_pose);
|
||||
assert_eq!(args.mqtt_rate_pose, 1.0);
|
||||
assert!(!args.mqtt_tls);
|
||||
assert!(args.mqtt_username.is_none());
|
||||
assert!(args.mqtt_port.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_mode_defaults_off() {
|
||||
let args = Args::parse_from(["sensing-server"]);
|
||||
assert!(!args.privacy_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matter_defaults_off_dev_vid() {
|
||||
let args = Args::parse_from(["sensing-server"]);
|
||||
assert!(!args.matter);
|
||||
assert_eq!(args.matter_vendor_id, "0xFFF1");
|
||||
assert_eq!(args.matter_product_id, "0x8001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_defaults_on() {
|
||||
let args = Args::parse_from(["sensing-server"]);
|
||||
assert!(args.semantic);
|
||||
assert!(args.no_semantic.is_empty());
|
||||
assert_eq!(args.semantic_baseline_window_days, 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mqtt_all_flags_compose() {
|
||||
let args = Args::parse_from([
|
||||
"sensing-server",
|
||||
"--mqtt",
|
||||
"--mqtt-host", "broker.example.com",
|
||||
"--mqtt-port", "8883",
|
||||
"--mqtt-username", "ruview",
|
||||
"--mqtt-prefix", "homeassistant",
|
||||
"--mqtt-tls",
|
||||
"--mqtt-refresh-secs", "300",
|
||||
"--mqtt-rate-vitals", "0.5",
|
||||
"--mqtt-publish-pose",
|
||||
"--mqtt-rate-pose", "2.0",
|
||||
"--privacy-mode",
|
||||
]);
|
||||
assert!(args.mqtt);
|
||||
assert_eq!(args.mqtt_host, "broker.example.com");
|
||||
assert_eq!(args.mqtt_port, Some(8883));
|
||||
assert_eq!(args.mqtt_username.as_deref(), Some("ruview"));
|
||||
assert!(args.mqtt_tls);
|
||||
assert_eq!(args.mqtt_refresh_secs, 300);
|
||||
assert_eq!(args.mqtt_rate_vitals, 0.5);
|
||||
assert!(args.mqtt_publish_pose);
|
||||
assert_eq!(args.mqtt_rate_pose, 2.0);
|
||||
assert!(args.privacy_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_semantic_repeatable() {
|
||||
let args = Args::parse_from([
|
||||
"sensing-server",
|
||||
"--no-semantic", "sleeping",
|
||||
"--no-semantic", "meeting",
|
||||
"--no-semantic", "fall_risk",
|
||||
]);
|
||||
assert_eq!(args.no_semantic, vec!["sleeping", "meeting", "fall_risk"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,46 @@ struct NodeInfo {
|
||||
position: [f64; 3],
|
||||
amplitude: Vec<f64>,
|
||||
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<NodeSyncSnapshot>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// ADR-110 iter 34 — milliseconds since the host last received a sync
|
||||
/// packet from this node. Lets UI dashboards render sync-age decay
|
||||
/// (badge fades after 5 s, drops off after the 9 s mesh_aligned_us
|
||||
/// staleness gate). `None` only when the host never had Instant data
|
||||
/// for this node, which shouldn't happen in normal flow but is
|
||||
/// modeled defensively.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
staleness_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -366,6 +406,19 @@ struct NodeState {
|
||||
latest_vitals: VitalSigns,
|
||||
pub(crate) last_frame_time: Option<std::time::Instant>,
|
||||
edge_vitals: Option<Esp32VitalsPacket>,
|
||||
/// ADR-110 §A0.12: Latest sync packet received from this node. When a
|
||||
/// CSI frame arrives with byte 19 bit 4 set (`adr018_flags.ieee802154_sync_valid`),
|
||||
/// the host can recover a mesh-aligned timestamp via
|
||||
/// `latest_sync.epoch_us + (now_local - latest_sync.local_us)`.
|
||||
latest_sync: Option<wifi_densepose_hardware::SyncPacket>,
|
||||
/// Last time a sync packet from this node was received (for staleness).
|
||||
latest_sync_at: Option<std::time::Instant>,
|
||||
/// ADR-110 iter 18: EMA-tracked CSI frame rate for this node.
|
||||
/// Replaces the hardcoded 20 Hz fallback in
|
||||
/// `mesh_aligned_us_for_csi_frame` once `csi_fps_samples ≥ 5`.
|
||||
csi_fps_ema: f64,
|
||||
/// Number of inter-frame deltas observed (need ≥5 before trusting EMA).
|
||||
csi_fps_samples: u32,
|
||||
/// Latest extracted features for cross-node fusion.
|
||||
latest_features: Option<FeatureInfo>,
|
||||
// ── RuVector Phase 2: Temporal smoothing & coherence gating ──
|
||||
@@ -406,7 +459,149 @@ const NOVELTY_HISTORY_CAPACITY: usize = 64;
|
||||
/// subcarrier ordering / normalisation so banks reject stale data.
|
||||
const NOVELTY_SKETCH_VERSION: u16 = 1;
|
||||
|
||||
/// ADR-110 iter 18 — EMA update for per-node CSI fps tracking.
|
||||
///
|
||||
/// Returns the new EMA value, or `None` if the delta is implausible
|
||||
/// (≤ 0, or > 1 second — likely a connection gap, not a real frame
|
||||
/// rate sample). α = 1/8 fixed shift, ~8-sample effective window,
|
||||
/// matching the firmware-side ESP-NOW offset smoother in §A0.10.
|
||||
///
|
||||
/// Free function for testability — every transformation that doesn't
|
||||
/// touch the rest of `NodeState` lives outside the `impl` block.
|
||||
pub(crate) fn update_csi_fps_ema(prev_fps: f64, dt_sec: f64) -> Option<f64> {
|
||||
if !(dt_sec > 0.0 && dt_sec < 1.0) {
|
||||
return None;
|
||||
}
|
||||
let instantaneous = 1.0 / dt_sec;
|
||||
// y[n] = y[n-1] + (x - y[n-1]) / 8
|
||||
Some(prev_fps + (instantaneous - prev_fps) / 8.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fps_ema_tests {
|
||||
use super::update_csi_fps_ema;
|
||||
|
||||
#[test]
|
||||
fn steady_10hz_converges_toward_10() {
|
||||
let mut fps = 20.0;
|
||||
for _ in 0..40 {
|
||||
fps = update_csi_fps_ema(fps, 0.100).unwrap();
|
||||
}
|
||||
assert!((fps - 10.0).abs() < 0.1,
|
||||
"expected ~10 Hz after 40 samples at 100 ms intervals, got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_20hz_stays_near_20() {
|
||||
let mut fps = 20.0;
|
||||
for _ in 0..20 {
|
||||
fps = update_csi_fps_ema(fps, 0.050).unwrap();
|
||||
}
|
||||
assert!((fps - 20.0).abs() < 0.05, "expected ~20 Hz, got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonpositive_dt_rejected() {
|
||||
assert!(update_csi_fps_ema(15.0, 0.0).is_none());
|
||||
assert!(update_csi_fps_ema(15.0, -0.1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_gap_rejected_as_implausible() {
|
||||
assert!(update_csi_fps_ema(20.0, 2.0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeState {
|
||||
/// ADR-110 §A0.12 timestamp recovery: given a CSI frame's node-local
|
||||
/// `esp_timer_get_time()` snapshot, return the mesh-aligned epoch
|
||||
/// computed from this node's most recent sync packet — or `None`
|
||||
/// if no sync has been received yet, or the last one is too stale
|
||||
/// (older than 3 × VALID_WINDOW_MS = 9 s, matching the firmware's own
|
||||
/// staleness gate).
|
||||
pub(crate) fn mesh_aligned_us(&self, local_at_frame_us: u64) -> Option<u64> {
|
||||
let sync = self.latest_sync.as_ref()?;
|
||||
let seen_at = self.latest_sync_at?;
|
||||
// Drop stale syncs — firmware emits at ~0.5 Hz default, anything
|
||||
// older than 9 s likely means the mesh transport dropped.
|
||||
if seen_at.elapsed() > std::time::Duration::from_secs(9) {
|
||||
return None;
|
||||
}
|
||||
Some(sync.apply_to_local(local_at_frame_us))
|
||||
}
|
||||
|
||||
/// ADR-110 §A0.12 sequence-based mesh-time recovery for an in-flight
|
||||
/// ADR-018 CSI frame. The frame carries no `local_us` (the wire
|
||||
/// format has no slot), but it carries a sequence number that the
|
||||
/// sync packet's `sequence` high-water can be paired against. Uses
|
||||
/// 20 Hz as the default CSI rate (the firmware's
|
||||
/// `CSI_MIN_SEND_INTERVAL_US`-implied ceiling). Returns `None` if
|
||||
/// no fresh sync has been observed for this node.
|
||||
pub(crate) fn mesh_aligned_us_for_csi_frame(&self, frame_sequence: u32) -> Option<u64> {
|
||||
let sync = self.latest_sync.as_ref()?;
|
||||
let seen_at = self.latest_sync_at?;
|
||||
if seen_at.elapsed() > std::time::Duration::from_secs(9) {
|
||||
return None;
|
||||
}
|
||||
// Iter 18: use the measured per-node fps once we have ≥5 inter-frame
|
||||
// samples; until then fall back to the 20 Hz firmware ceiling. The
|
||||
// §A0.12 capture showed real bench fps ≈ 10, so the measured value
|
||||
// is significantly more accurate than the constant fallback.
|
||||
let fps = if self.csi_fps_samples >= 5 { self.csi_fps_ema } else { 20.0 };
|
||||
Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps))
|
||||
}
|
||||
|
||||
/// ADR-110 iter 18 — update the per-node observed-fps EMA from a fresh
|
||||
/// CSI frame arrival. Call once per accepted CSI frame from
|
||||
/// `udp_receiver_task`. Uses `last_frame_time` as the previous-frame
|
||||
/// anchor; the first frame after init seeds the timer without producing
|
||||
/// a sample (no prior dt to measure).
|
||||
/// ADR-110 iter 32 — apply a freshly-decoded sync packet to this node.
|
||||
/// Overwrites `latest_sync` with the new packet and stamps
|
||||
/// `latest_sync_at` so the staleness gate in `mesh_aligned_us_for_csi_frame`
|
||||
/// can age it out after 9 s. Used by `udp_receiver_task` on every
|
||||
/// successful magic-dispatched sync datagram; extracted so the dispatch
|
||||
/// path is testable without spinning up the tokio UDP socket.
|
||||
pub(crate) fn apply_sync_packet(
|
||||
&mut self,
|
||||
pkt: wifi_densepose_hardware::SyncPacket,
|
||||
now: std::time::Instant,
|
||||
) {
|
||||
self.latest_sync = Some(pkt);
|
||||
self.latest_sync_at = Some(now);
|
||||
}
|
||||
|
||||
/// ADR-110 iter 30 — pure snapshot of this node's mesh-sync state.
|
||||
/// Returns `None` when no sync packet has been observed. Used by both
|
||||
/// the WebSocket broadcaster (iter 23) and the REST handlers (iter 29);
|
||||
/// extracted here so tests can build a `NodeState`, populate
|
||||
/// `latest_sync`, and assert the snapshot shape without spinning up
|
||||
/// the axum router.
|
||||
pub(crate) fn sync_snapshot(&self) -> Option<NodeSyncSnapshot> {
|
||||
let sync = self.latest_sync.as_ref()?;
|
||||
Some(NodeSyncSnapshot {
|
||||
offset_us: sync.local_minus_epoch_us(),
|
||||
is_leader: sync.flags.is_leader,
|
||||
is_valid: sync.flags.is_valid,
|
||||
smoothed: sync.flags.smoothed_used,
|
||||
sequence: sync.sequence,
|
||||
csi_fps_ema: self.csi_fps_ema,
|
||||
csi_fps_samples: self.csi_fps_samples,
|
||||
staleness_ms: self.latest_sync_at.map(|t| t.elapsed().as_millis() as u64),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn observe_csi_frame_arrival(&mut self, now: std::time::Instant) {
|
||||
if let Some(prev) = self.last_frame_time {
|
||||
let dt = now.duration_since(prev).as_secs_f64();
|
||||
if let Some(new_ema) = update_csi_fps_ema(self.csi_fps_ema, dt) {
|
||||
self.csi_fps_ema = new_ema;
|
||||
self.csi_fps_samples = self.csi_fps_samples.saturating_add(1);
|
||||
}
|
||||
}
|
||||
self.last_frame_time = Some(now);
|
||||
}
|
||||
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
frame_history: VecDeque::new(),
|
||||
@@ -429,6 +624,10 @@ impl NodeState {
|
||||
latest_vitals: VitalSigns::default(),
|
||||
last_frame_time: None,
|
||||
edge_vitals: None,
|
||||
latest_sync: None,
|
||||
latest_sync_at: None,
|
||||
csi_fps_ema: 20.0,
|
||||
csi_fps_samples: 0,
|
||||
latest_features: None,
|
||||
prev_keypoints: None,
|
||||
motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW),
|
||||
@@ -2007,6 +2206,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,
|
||||
@@ -2162,6 +2362,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,
|
||||
@@ -4127,6 +4328,145 @@ async fn sona_activate(
|
||||
}
|
||||
|
||||
/// GET /api/v1/nodes — per-node health and feature info.
|
||||
/// ADR-110 iter 29 — per-node mesh sync snapshot via HTTP.
|
||||
///
|
||||
/// GET /api/v1/nodes/:id/sync
|
||||
/// 200 → Json(NodeSyncSnapshot) when latest_sync is present
|
||||
/// 404 → {"error": "no_sync", "node_id": N} otherwise
|
||||
///
|
||||
/// Complements the WebSocket `sync` field (iter 23) for clients that
|
||||
/// can't hold a streaming connection (curl scripts, Home Assistant REST
|
||||
/// sensors, automation rule probes).
|
||||
async fn node_sync_endpoint(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<u8>,
|
||||
) -> Result<Json<NodeSyncSnapshot>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let s = state.read().await;
|
||||
let ns = s.node_states.get(&id).ok_or_else(|| {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({
|
||||
"error": "unknown_node", "node_id": id,
|
||||
})))
|
||||
})?;
|
||||
ns.sync_snapshot().map(Json).ok_or_else(|| {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({
|
||||
"error": "no_sync", "node_id": id,
|
||||
"hint": "node hasn't emitted a sync packet yet (no mesh peer or not v0.6.9+)",
|
||||
})))
|
||||
})
|
||||
}
|
||||
|
||||
/// ADR-110 iter 29 — fleet-wide mesh state via HTTP.
|
||||
///
|
||||
/// GET /api/v1/mesh
|
||||
/// 200 → { "nodes": { "<id>": NodeSyncSnapshot, ... }, "total": N }
|
||||
/// Nodes without a recent sync are omitted from the map; an empty
|
||||
/// `nodes` object means no mesh peers reachable.
|
||||
/// ADR-110 iter 36 — Prometheus exposition format for mesh state.
|
||||
///
|
||||
/// GET /api/v1/mesh/metrics → text/plain
|
||||
/// wifi_densepose_mesh_offset_us{node="N"} <signed-int>
|
||||
/// wifi_densepose_mesh_is_leader{node="N"} 0|1
|
||||
/// wifi_densepose_mesh_is_valid{node="N"} 0|1
|
||||
/// wifi_densepose_mesh_smoothed{node="N"} 0|1
|
||||
/// wifi_densepose_mesh_sequence{node="N"} <u32>
|
||||
/// wifi_densepose_mesh_csi_fps{node="N"} <float>
|
||||
/// wifi_densepose_mesh_csi_fps_samples{node="N"} <u32>
|
||||
/// wifi_densepose_mesh_staleness_ms{node="N"} <u64>
|
||||
///
|
||||
/// Spec: <https://prometheus.io/docs/instrumenting/exposition_formats/>.
|
||||
/// Each metric is a gauge labeled by node_id. Nodes without a fresh sync
|
||||
/// are simply absent from the output (Prometheus handles missing series
|
||||
/// natively — the scrape just reports them as stale after the configured
|
||||
/// staleness duration).
|
||||
async fn mesh_metrics_endpoint(State(state): State<SharedState>) -> impl IntoResponse {
|
||||
use std::fmt::Write;
|
||||
let s = state.read().await;
|
||||
let mut body = String::with_capacity(1024);
|
||||
|
||||
// Each metric: HELP + TYPE header + one line per node that has a snapshot.
|
||||
let metrics: &[(&str, &str, &str)] = &[
|
||||
("wifi_densepose_mesh_offset_us",
|
||||
"Cross-board mesh-aligned offset, microseconds (signed)", "gauge"),
|
||||
("wifi_densepose_mesh_is_leader",
|
||||
"1 if this node is the elected mesh leader, else 0", "gauge"),
|
||||
("wifi_densepose_mesh_is_valid",
|
||||
"1 if this node has heard a fresh leader beacon, else 0", "gauge"),
|
||||
("wifi_densepose_mesh_smoothed",
|
||||
"1 once the firmware-side EMA filter has seeded, else 0", "gauge"),
|
||||
("wifi_densepose_mesh_sequence",
|
||||
"High-water CSI sequence at sync emit time", "gauge"),
|
||||
("wifi_densepose_mesh_csi_fps",
|
||||
"Per-node measured CSI frame rate (Hz)", "gauge"),
|
||||
("wifi_densepose_mesh_csi_fps_samples",
|
||||
"How many inter-frame deltas the fps EMA has seen", "gauge"),
|
||||
("wifi_densepose_mesh_staleness_ms",
|
||||
"Milliseconds since the host last received this node's sync packet", "gauge"),
|
||||
];
|
||||
|
||||
// Collect (id, snapshot) pairs once so each metric loop reads the same set.
|
||||
let snaps: Vec<(u8, NodeSyncSnapshot)> = s.node_states.iter()
|
||||
.filter_map(|(&id, ns)| ns.sync_snapshot().map(|snap| (id, snap)))
|
||||
.collect();
|
||||
|
||||
// Iter 37: fleet cardinality summary — Ops dashboards want the
|
||||
// "how many leaders / followers / no-sync" tally at a glance
|
||||
// without scraping every per-node series and counting.
|
||||
let (leaders, followers) = fleet_role_counts(&snaps);
|
||||
let no_sync = s.node_states.len().saturating_sub(snaps.len()) as u64;
|
||||
let _ = writeln!(body,
|
||||
"# HELP wifi_densepose_mesh_node_total Per-state node count across the fleet");
|
||||
let _ = writeln!(body, "# TYPE wifi_densepose_mesh_node_total gauge");
|
||||
let _ = writeln!(body, "wifi_densepose_mesh_node_total{{state=\"leader\"}} {leaders}");
|
||||
let _ = writeln!(body, "wifi_densepose_mesh_node_total{{state=\"follower\"}} {followers}");
|
||||
let _ = writeln!(body, "wifi_densepose_mesh_node_total{{state=\"no_sync\"}} {no_sync}");
|
||||
|
||||
for (name, help, kind) in metrics {
|
||||
let _ = writeln!(body, "# HELP {name} {help}");
|
||||
let _ = writeln!(body, "# TYPE {name} {kind}");
|
||||
for (id, snap) in &snaps {
|
||||
let value = match *name {
|
||||
"wifi_densepose_mesh_offset_us" => snap.offset_us.to_string(),
|
||||
"wifi_densepose_mesh_is_leader" => bool_metric(snap.is_leader),
|
||||
"wifi_densepose_mesh_is_valid" => bool_metric(snap.is_valid),
|
||||
"wifi_densepose_mesh_smoothed" => bool_metric(snap.smoothed),
|
||||
"wifi_densepose_mesh_sequence" => snap.sequence.to_string(),
|
||||
"wifi_densepose_mesh_csi_fps" => format!("{:.3}", snap.csi_fps_ema),
|
||||
"wifi_densepose_mesh_csi_fps_samples" => snap.csi_fps_samples.to_string(),
|
||||
"wifi_densepose_mesh_staleness_ms" =>
|
||||
snap.staleness_ms.map(|n| n.to_string()).unwrap_or_else(|| "0".into()),
|
||||
_ => continue,
|
||||
};
|
||||
let _ = writeln!(body, "{name}{{node=\"{id}\"}} {value}");
|
||||
}
|
||||
}
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], body)
|
||||
}
|
||||
|
||||
fn bool_metric(b: bool) -> String { (if b { 1 } else { 0 }).to_string() }
|
||||
|
||||
/// ADR-110 iter 37 — count (leaders, followers) in a populated snapshot set.
|
||||
/// Free function for testability — same pattern as iter 18's `update_csi_fps_ema`.
|
||||
pub(crate) fn fleet_role_counts(snaps: &[(u8, NodeSyncSnapshot)]) -> (u64, u64) {
|
||||
let leaders = snaps.iter().filter(|(_, s)| s.is_leader).count() as u64;
|
||||
let followers = (snaps.len() as u64).saturating_sub(leaders);
|
||||
(leaders, followers)
|
||||
}
|
||||
|
||||
async fn mesh_endpoint(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
let mut nodes = serde_json::Map::new();
|
||||
for (&id, ns) in s.node_states.iter() {
|
||||
if let Some(snap) = ns.sync_snapshot() {
|
||||
nodes.insert(id.to_string(), serde_json::to_value(snap).unwrap());
|
||||
}
|
||||
}
|
||||
let total = nodes.len();
|
||||
Json(serde_json::json!({
|
||||
"nodes": serde_json::Value::Object(nodes),
|
||||
"total": total,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
let now = std::time::Instant::now();
|
||||
@@ -4316,6 +4656,9 @@ 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.sync_snapshot(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -4432,6 +4775,37 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADR-110 §A0.12: Try sync packet (magic 0xC511_A110).
|
||||
// A 32-byte UDP datagram carrying mesh-aligned epoch + sequence
|
||||
// high-water from the node's c6_sync_espnow EMA-smoothed offset.
|
||||
// Stored per-node so subsequent CSI frames with byte 19 bit 4
|
||||
// set can have an aligned timestamp recovered downstream.
|
||||
if len >= wifi_densepose_hardware::SYNC_PACKET_SIZE {
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic == wifi_densepose_hardware::SYNC_PACKET_MAGIC {
|
||||
match wifi_densepose_hardware::SyncPacket::from_bytes(&buf[..len]) {
|
||||
Ok(sync) => {
|
||||
debug!("ESP32 sync from {src}: node={} leader={} valid={} smoothed={} \
|
||||
seq={} offset_us={}",
|
||||
sync.node_id, sync.flags.is_leader, sync.flags.is_valid,
|
||||
sync.flags.smoothed_used, sync.sequence,
|
||||
sync.local_minus_epoch_us());
|
||||
let mut s = state.write().await;
|
||||
let node_id = sync.node_id;
|
||||
let ns = s.node_states.entry(node_id)
|
||||
.or_insert_with(NodeState::new);
|
||||
ns.apply_sync_packet(sync, std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Sync packet decode error from {src}: {e}");
|
||||
// Fall through — magic matched but decode failed; not a CSI frame.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0004).
|
||||
if let Some(wasm_output) = parse_wasm_output(&buf[..len]) {
|
||||
debug!(
|
||||
@@ -4506,7 +4880,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
let adaptive_model_clone = s.adaptive_model.clone();
|
||||
|
||||
let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new);
|
||||
ns.last_frame_time = Some(std::time::Instant::now());
|
||||
// ADR-110 iter 19 — feed the per-node fps EMA from real
|
||||
// CSI arrivals. The helper sets `last_frame_time` as a
|
||||
// side effect, so the previous bare assignment is gone.
|
||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
||||
|
||||
// ADR-084 Pass 3: cluster-Pi novelty sensor.
|
||||
// Score this frame's feature vector against the per-node
|
||||
@@ -4659,6 +5036,8 @@ 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 / iter 30 — single source of truth.
|
||||
sync: n.sync_snapshot(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -4821,6 +5200,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,
|
||||
@@ -5800,6 +6180,10 @@ async fn main() {
|
||||
.route("/api/v1/sensing/latest", get(latest))
|
||||
// Per-node health endpoint
|
||||
.route("/api/v1/nodes", get(nodes_endpoint))
|
||||
// ADR-110 iter 29 — per-node mesh sync state for HTTP clients.
|
||||
.route("/api/v1/nodes/:id/sync", get(node_sync_endpoint))
|
||||
.route("/api/v1/mesh", get(mesh_endpoint))
|
||||
.route("/api/v1/mesh/metrics", get(mesh_metrics_endpoint))
|
||||
// Vital sign endpoints
|
||||
.route("/api/v1/vital-signs", get(vital_signs_endpoint))
|
||||
.route("/api/v1/edge-vitals", get(edge_vitals_endpoint))
|
||||
@@ -5946,6 +6330,272 @@ async fn main() {
|
||||
info!("Server shut down cleanly");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod node_sync_snapshot_serialization_tests {
|
||||
//! ADR-110 iter 24 — JSON public-API contract for the iter 23
|
||||
//! NodeSyncSnapshot field. Any future rename / removal here must be
|
||||
//! intentional and update both Rust + UI/automation consumers.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_sync() -> NodeSyncSnapshot {
|
||||
NodeSyncSnapshot {
|
||||
offset_us: 1_163_565,
|
||||
is_leader: false,
|
||||
is_valid: true,
|
||||
smoothed: true,
|
||||
sequence: 20,
|
||||
csi_fps_ema: 10.0,
|
||||
csi_fps_samples: 47,
|
||||
staleness_ms: Some(120),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_node(sync: Option<NodeSyncSnapshot>) -> NodeInfo {
|
||||
NodeInfo {
|
||||
node_id: 9,
|
||||
rssi_dbm: -38.0,
|
||||
position: [2.0, 0.0, 1.5],
|
||||
amplitude: vec![],
|
||||
subcarrier_count: 0,
|
||||
sync,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_present_serializes_all_seven_fields() {
|
||||
let v = serde_json::to_value(sample_node(Some(sample_sync()))).unwrap();
|
||||
let s = v.get("sync").expect("sync key must be present");
|
||||
// All eight contract fields named exactly as iter 23/34 documented.
|
||||
for key in ["offset_us", "is_leader", "is_valid", "smoothed",
|
||||
"sequence", "csi_fps_ema", "csi_fps_samples",
|
||||
"staleness_ms"] {
|
||||
assert!(s.get(key).is_some(),
|
||||
"sync object missing field `{}` — UI contract broken", key);
|
||||
}
|
||||
// Spot-check values round-trip.
|
||||
assert_eq!(s["offset_us"], 1_163_565);
|
||||
assert_eq!(s["is_leader"], false);
|
||||
assert_eq!(s["sequence"], 20);
|
||||
assert_eq!(s["csi_fps_samples"], 47);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_absent_omits_the_key_entirely() {
|
||||
// skip_serializing_if = "Option::is_none" must drop the key, not
|
||||
// emit `"sync": null`. The non-mesh paths rely on this for
|
||||
// backwards compatibility with pre-iter-23 UI clients.
|
||||
let v = serde_json::to_value(sample_node(None)).unwrap();
|
||||
assert!(v.get("sync").is_none(),
|
||||
"expected `sync` key omitted when None, got {:?}", v.get("sync"));
|
||||
// The base NodeInfo fields are still there.
|
||||
assert_eq!(v["node_id"], 9);
|
||||
assert_eq!(v["rssi_dbm"], -38.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_round_trips_through_serde() {
|
||||
let original = sample_node(Some(sample_sync()));
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed: NodeInfo = serde_json::from_str(&json).unwrap();
|
||||
// Field-level equality on the sync sub-object.
|
||||
let s_orig = original.sync.unwrap();
|
||||
let s_parsed = parsed.sync.expect("sync should survive round-trip");
|
||||
assert_eq!(s_parsed.offset_us, s_orig.offset_us);
|
||||
assert_eq!(s_parsed.is_leader, s_orig.is_leader);
|
||||
assert_eq!(s_parsed.is_valid, s_orig.is_valid);
|
||||
assert_eq!(s_parsed.smoothed, s_orig.smoothed);
|
||||
assert_eq!(s_parsed.sequence, s_orig.sequence);
|
||||
assert!((s_parsed.csi_fps_ema - s_orig.csi_fps_ema).abs() < 1e-9);
|
||||
assert_eq!(s_parsed.csi_fps_samples, s_orig.csi_fps_samples);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sync_snapshot_helper_tests {
|
||||
//! ADR-110 iter 30 — covers the pure helper that backs both
|
||||
//! `/api/v1/nodes/:id/sync` and `/api/v1/mesh` REST endpoints and
|
||||
//! the WebSocket sensing_update broadcast. Tests at this layer keep
|
||||
//! the public-API contract honest without spinning up the axum
|
||||
//! router or constructing a full AppStateInner.
|
||||
|
||||
use super::*;
|
||||
use wifi_densepose_hardware::{SyncPacket, SyncPacketFlags};
|
||||
|
||||
fn populated_sync(node_id: u8) -> SyncPacket {
|
||||
SyncPacket {
|
||||
node_id,
|
||||
proto_ver: 1,
|
||||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||||
local_us: 28_798_450,
|
||||
epoch_us: 27_634_885,
|
||||
sequence: 20,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_node_with_no_sync_returns_none() {
|
||||
// Mirrors the REST 404 "no_sync" branch.
|
||||
let ns = NodeState::new();
|
||||
assert!(ns.sync_snapshot().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_latest_sync_produces_correct_snapshot() {
|
||||
// Mirrors the REST 200 OK branch + the WebSocket sync field.
|
||||
let mut ns = NodeState::new();
|
||||
ns.latest_sync = Some(populated_sync(9));
|
||||
ns.latest_sync_at = Some(std::time::Instant::now());
|
||||
// Pretend the fps EMA has settled (iter 18 5-sample warmup).
|
||||
ns.csi_fps_ema = 10.5;
|
||||
ns.csi_fps_samples = 42;
|
||||
|
||||
let snap = ns.sync_snapshot().expect("populated state must produce a snapshot");
|
||||
assert_eq!(snap.offset_us, 1_163_565); // §A0.10 measured boot delta
|
||||
assert!(!snap.is_leader);
|
||||
assert!(snap.is_valid);
|
||||
assert!(snap.smoothed);
|
||||
assert_eq!(snap.sequence, 20);
|
||||
assert!((snap.csi_fps_ema - 10.5).abs() < 1e-9);
|
||||
assert_eq!(snap.csi_fps_samples, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sync_packet_populates_a_fresh_node() {
|
||||
// Mirrors what udp_receiver_task does on the very first sync
|
||||
// packet from a previously-unseen node.
|
||||
let mut ns = NodeState::new();
|
||||
assert!(ns.latest_sync.is_none());
|
||||
assert!(ns.latest_sync_at.is_none());
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
ns.apply_sync_packet(populated_sync(9), now);
|
||||
|
||||
let sync = ns.latest_sync.as_ref().expect("must be populated");
|
||||
assert_eq!(sync.node_id, 9);
|
||||
assert_eq!(sync.sequence, 20);
|
||||
// latest_sync_at must be exactly the Instant we passed (no clock skew).
|
||||
assert_eq!(ns.latest_sync_at, Some(now));
|
||||
// sync_snapshot now produces a value (REST 200 OK path).
|
||||
assert!(ns.sync_snapshot().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sync_packet_overwrites_older_data() {
|
||||
// Subsequent packets must replace, not accumulate. Otherwise the
|
||||
// §A0.10-smoothed offset would lag the latest beacon.
|
||||
let mut ns = NodeState::new();
|
||||
let t0 = std::time::Instant::now();
|
||||
ns.apply_sync_packet(populated_sync(9), t0);
|
||||
|
||||
// Second packet: same node, advanced sequence + offset.
|
||||
let mut second = populated_sync(9);
|
||||
second.sequence = 40;
|
||||
second.local_us = 30_000_000;
|
||||
second.epoch_us = 28_834_900;
|
||||
let t1 = t0 + std::time::Duration::from_secs(2);
|
||||
ns.apply_sync_packet(second, t1);
|
||||
|
||||
let cur = ns.latest_sync.as_ref().unwrap();
|
||||
assert_eq!(cur.sequence, 40); // newer sequence persisted
|
||||
assert_eq!(cur.local_us, 30_000_000); // newer local persisted
|
||||
assert_eq!(ns.latest_sync_at, Some(t1)); // staleness clock reset
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_staleness_ms_tracks_apply_time() {
|
||||
// Iter 34: staleness_ms = (Instant::now() - latest_sync_at).as_millis().
|
||||
// We can't pass a synthetic "now" through sync_snapshot, but we can
|
||||
// pin latest_sync_at to a past instant and assert the value lands
|
||||
// in a plausible window.
|
||||
let mut ns = NodeState::new();
|
||||
ns.latest_sync = Some(populated_sync(9));
|
||||
ns.latest_sync_at = std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_millis(750));
|
||||
|
||||
let snap = ns.sync_snapshot().unwrap();
|
||||
let st = snap.staleness_ms.expect("staleness_ms must be present");
|
||||
// Should be approximately 750 ms — give a generous ±500 ms tolerance
|
||||
// for any test-runner scheduling delay between checked_sub() and
|
||||
// elapsed() within sync_snapshot.
|
||||
assert!(st >= 740 && st < 1250,
|
||||
"expected ~750 ms staleness, got {} ms", st);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_role_counts_classifies_correctly() {
|
||||
// Iter 37 — verify the leader/follower split that drives the
|
||||
// Prometheus `wifi_densepose_mesh_node_total{state=...}` gauge.
|
||||
// Local fixture rather than reaching across test modules.
|
||||
fn snap(is_leader: bool) -> NodeSyncSnapshot {
|
||||
NodeSyncSnapshot {
|
||||
offset_us: 0, is_leader, is_valid: true, smoothed: true,
|
||||
sequence: 0, csi_fps_ema: 10.0, csi_fps_samples: 10,
|
||||
staleness_ms: Some(0),
|
||||
}
|
||||
}
|
||||
assert_eq!(super::fleet_role_counts(&[]), (0, 0));
|
||||
let snaps = vec![(12u8, snap(true)), (9, snap(false)), (3, snap(false))];
|
||||
assert_eq!(super::fleet_role_counts(&snaps), (1, 2));
|
||||
// Edge: all leaders (election would prevent this but gauge math must hold).
|
||||
assert_eq!(super::fleet_role_counts(&[(1u8, snap(true)), (2, snap(true))]), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_metric_returns_zero_or_one_as_text() {
|
||||
// Locks the Prometheus exposition convention: gauges holding a
|
||||
// boolean state MUST emit literal "0" or "1", never "false"/"true".
|
||||
// If anyone changes the helper to format!("{}", b), Prometheus will
|
||||
// 400-reject the scrape — catch it here instead of in production.
|
||||
assert_eq!(super::bool_metric(true), "1");
|
||||
assert_eq!(super::bool_metric(false), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_aligned_us_honors_9s_staleness_gate() {
|
||||
// The receive helper stores latest_sync_at = Instant::now() each
|
||||
// beacon. mesh_aligned_us_for_csi_frame returns None once that
|
||||
// Instant is older than 9 s (3 × VALID_WINDOW_MS). Verify both
|
||||
// sides of that boundary without sleeping — set latest_sync_at
|
||||
// to past instants directly.
|
||||
let mut ns = NodeState::new();
|
||||
let now = std::time::Instant::now();
|
||||
ns.latest_sync = Some(populated_sync(9));
|
||||
|
||||
// Fresh: 1 s old → should return Some.
|
||||
ns.latest_sync_at = now.checked_sub(std::time::Duration::from_secs(1));
|
||||
assert!(ns.mesh_aligned_us_for_csi_frame(20).is_some(),
|
||||
"1 s old sync must produce a mesh-aligned timestamp");
|
||||
|
||||
// Just inside the gate: 8 s old → should still return Some.
|
||||
ns.latest_sync_at = now.checked_sub(std::time::Duration::from_secs(8));
|
||||
assert!(ns.mesh_aligned_us_for_csi_frame(20).is_some(),
|
||||
"8 s old sync must still be inside the 9 s gate");
|
||||
|
||||
// Just outside the gate: 10 s old → must return None.
|
||||
ns.latest_sync_at = now.checked_sub(std::time::Duration::from_secs(10));
|
||||
assert!(ns.mesh_aligned_us_for_csi_frame(20).is_none(),
|
||||
"10 s old sync must trigger the 9 s staleness gate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reflects_leader_state() {
|
||||
// Same data shape that /api/v1/mesh emits for a leader node.
|
||||
let mut ns = NodeState::new();
|
||||
let mut s = populated_sync(12);
|
||||
s.flags = SyncPacketFlags { is_leader: true, is_valid: true, smoothed_used: false };
|
||||
s.local_us = 28_864_932;
|
||||
s.epoch_us = 28_864_939; // -7 µs delta on the leader
|
||||
ns.latest_sync = Some(s);
|
||||
ns.latest_sync_at = Some(std::time::Instant::now());
|
||||
|
||||
let snap = ns.sync_snapshot().unwrap();
|
||||
assert!(snap.is_leader);
|
||||
assert_eq!(snap.offset_us, -7); // call-stack µs only
|
||||
assert!(!snap.smoothed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod novelty_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user