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:
rUv
2026-05-23 15:34:48 -04:00
committed by GitHub
parent 5d544126ee
commit 00a234eda8
63 changed files with 5864 additions and 63 deletions
Generated
+1
View File
@@ -9140,6 +9140,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"ureq 2.12.1",
"wifi-densepose-hardware",
"wifi-densepose-signal",
"wifi-densepose-wifiscan",
]
@@ -101,6 +101,8 @@ mod tests {
rx_antennas: n_antennas,
},
sequence: 42,
ppdu_type: crate::csi_frame::PpduType::HtLegacy,
adr018_flags: crate::csi_frame::Adr018Flags::default(),
},
subcarriers,
}
@@ -85,6 +85,98 @@ pub struct CsiMetadata {
pub antenna_config: AntennaConfig,
/// Sequence number for ordering
pub sequence: u32,
/// ADR-110: PPDU type from ADR-018 byte 18. None on pre-ADR-110 firmware
/// (or when CONFIG_CSI_FRAME_HE_TAGGING is disabled — byte stays zero
/// and pre-ADR-110 readers see the same zero, full backwards compat).
/// Byte 18 = 0 reads as PpduType::HtLegacy (the wire encoding for the
/// HT/legacy bucket); 0xFF reads as PpduType::Unknown.
pub ppdu_type: PpduType,
/// ADR-110: flags from ADR-018 byte 19 — bandwidth bits, STBC, LDPC,
/// 802.15.4-time-sync-valid bit. See [`Adr018Flags`].
pub adr018_flags: Adr018Flags,
}
/// PPDU type encoded in ADR-018 byte 18 (ADR-110 extension).
///
/// Wire encoding (matches firmware `csi_collector.c`):
/// 0 = HT / legacy bucket (11b/g/HT/VHT all collapse here)
/// 1 = HE-SU (802.11ax single-user)
/// 2 = HE-MU (802.11ax multi-user)
/// 3 = HE-TB (802.11ax trigger-based)
/// 0xFF = Unknown
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PpduType {
HtLegacy,
HeSu,
HeMu,
HeTb,
Unknown,
}
impl PpduType {
pub fn from_byte(b: u8) -> Self {
match b {
0 => Self::HtLegacy,
1 => Self::HeSu,
2 => Self::HeMu,
3 => Self::HeTb,
_ => Self::Unknown,
}
}
pub fn to_byte(self) -> u8 {
match self {
Self::HtLegacy => 0,
Self::HeSu => 1,
Self::HeMu => 2,
Self::HeTb => 3,
Self::Unknown => 0xFF,
}
}
pub fn is_he(self) -> bool {
matches!(self, Self::HeSu | Self::HeMu | Self::HeTb)
}
}
/// Flags encoded in ADR-018 byte 19 (ADR-110 extension).
///
/// Wire encoding:
/// bit 0 : bandwidth wide (set = 40 MHz, clear = 20 MHz)
/// bit 1 : (reserved for 80/160 future)
/// bit 2 : STBC
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
pub ldpc: bool,
pub ieee802154_sync_valid: bool,
}
impl Adr018Flags {
pub fn from_byte(b: u8) -> Self {
Self {
bw40: (b & 0x01) != 0,
stbc: (b & 0x04) != 0,
ldpc: (b & 0x08) != 0,
ieee802154_sync_valid: (b & 0x10) != 0,
}
}
pub fn to_byte(self) -> u8 {
let mut b = 0u8;
if self.bw40 { b |= 0x01; }
if self.stbc { b |= 0x04; }
if self.ldpc { b |= 0x08; }
if self.ieee802154_sync_valid { b |= 0x10; }
b
}
}
impl Default for Adr018Flags {
fn default() -> Self {
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
}
}
/// WiFi channel bandwidth.
@@ -159,6 +251,8 @@ mod tests {
bandwidth: Bandwidth::Bw20,
antenna_config: AntennaConfig::default(),
sequence: 1,
ppdu_type: PpduType::HtLegacy,
adr018_flags: Adr018Flags::default(),
},
subcarriers: vec![
SubcarrierData {
@@ -31,7 +31,9 @@ use byteorder::{LittleEndian, ReadBytesExt};
use chrono::Utc;
use std::io::Cursor;
use crate::csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
use crate::csi_frame::{
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
};
use crate::error::ParseError;
/// ESP32 CSI binary frame magic number (ADR-018).
@@ -185,13 +187,20 @@ impl Esp32CsiParser {
message: "Failed to read noise floor".into(),
})?;
// Reserved (offset 18, 2 bytes) — skip
let _reserved = cursor
.read_u16::<LittleEndian>()
.map_err(|_| ParseError::ByteError {
offset: 18,
message: "Failed to read reserved bytes".into(),
})?;
// ADR-110: bytes 18-19 carry PPDU type + flags (previously reserved-zero,
// now opt-in via CONFIG_CSI_FRAME_HE_TAGGING in firmware). Pre-ADR-110
// firmware sends zeros, which round-trip as PpduType::HtLegacy +
// Adr018Flags::default() — fully backwards compatible.
let ppdu_byte = cursor.read_u8().map_err(|_| ParseError::ByteError {
offset: 18,
message: "Failed to read PPDU type byte".into(),
})?;
let flags_byte = cursor.read_u8().map_err(|_| ParseError::ByteError {
offset: 19,
message: "Failed to read flags byte".into(),
})?;
let ppdu_type = PpduType::from_byte(ppdu_byte);
let adr018_flags = Adr018Flags::from_byte(flags_byte);
// I/Q data: n_antennas * n_subcarriers * 2 bytes
let iq_pair_count = n_antennas as usize * n_subcarriers;
@@ -254,6 +263,8 @@ impl Esp32CsiParser {
rx_antennas: n_antennas,
},
sequence,
ppdu_type,
adr018_flags,
},
subcarriers,
};
@@ -302,7 +313,20 @@ mod tests {
use super::*;
/// Build a valid ADR-018 ESP32 CSI frame with known parameters.
/// PPDU type + flags bytes (offset 18-19) are zero — pre-ADR-110 default,
/// which round-trips as PpduType::HtLegacy + Adr018Flags::default().
fn build_test_frame(node_id: u8, n_antennas: u8, subcarrier_pairs: &[(i8, i8)]) -> Vec<u8> {
build_test_frame_with_he(node_id, n_antennas, subcarrier_pairs, 0, 0)
}
/// ADR-110-aware variant: explicit byte 18 (PPDU type) and byte 19 (flags).
fn build_test_frame_with_he(
node_id: u8,
n_antennas: u8,
subcarrier_pairs: &[(i8, i8)],
ppdu_byte: u8,
flags_byte: u8,
) -> Vec<u8> {
let n_subcarriers = if n_antennas == 0 {
subcarrier_pairs.len()
} else {
@@ -310,26 +334,16 @@ mod tests {
};
let mut buf = Vec::new();
// Magic (offset 0)
buf.extend_from_slice(&ESP32_CSI_MAGIC.to_le_bytes());
// Node ID (offset 4)
buf.push(node_id);
// Number of antennas (offset 5)
buf.push(n_antennas);
// Number of subcarriers (offset 6, LE u16)
buf.extend_from_slice(&(n_subcarriers as u16).to_le_bytes());
// Frequency MHz (offset 8, LE u32)
buf.extend_from_slice(&2437u32.to_le_bytes());
// Sequence number (offset 12, LE u32)
buf.extend_from_slice(&1u32.to_le_bytes());
// RSSI (offset 16, i8)
buf.push((-50i8) as u8);
// Noise floor (offset 17, i8)
buf.push((-95i8) as u8);
// Reserved (offset 18, 2 bytes)
buf.extend_from_slice(&[0u8; 2]);
// I/Q data (offset 20)
buf.push(ppdu_byte);
buf.push(flags_byte);
for (i, q) in subcarrier_pairs {
buf.push(*i as u8);
buf.push(*q as u8);
@@ -338,6 +352,65 @@ mod tests {
buf
}
// ── ADR-110: byte 18-19 round-trip tests ─────────────────────────────────
#[test]
fn adr110_pre_adr110_firmware_round_trips_as_ht_legacy_default_flags() {
// Pre-ADR-110 firmware writes zeros to bytes 18-19. The parser must
// surface that as HtLegacy + default flags so old aggregators see
// identical behavior to before the extension.
let data = build_test_frame(1, 1, &[(0, 0); 56]);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
assert_eq!(frame.metadata.adr018_flags, Adr018Flags::default());
assert!(!frame.metadata.ppdu_type.is_he());
}
#[test]
fn adr110_he_su_ppdu_decodes() {
let data = build_test_frame_with_he(2, 1, &[(0, 0); 56], /*PPDU*/ 1, /*flags*/ 0);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
assert!(frame.metadata.ppdu_type.is_he());
}
#[test]
fn adr110_he_mu_he_tb_decode() {
let mu = build_test_frame_with_he(3, 1, &[(0, 0); 56], 2, 0);
let tb = build_test_frame_with_he(4, 1, &[(0, 0); 56], 3, 0);
let (mu_frame, _) = Esp32CsiParser::parse_frame(&mu).unwrap();
let (tb_frame, _) = Esp32CsiParser::parse_frame(&tb).unwrap();
assert_eq!(mu_frame.metadata.ppdu_type, PpduType::HeMu);
assert_eq!(tb_frame.metadata.ppdu_type, PpduType::HeTb);
}
#[test]
fn adr110_unknown_ppdu_byte_decodes_as_unknown() {
let data = build_test_frame_with_he(5, 1, &[(0, 0); 56], 0xFF, 0);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert_eq!(frame.metadata.ppdu_type, PpduType::Unknown);
}
#[test]
fn adr110_flags_round_trip_all_bits() {
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert!(frame.metadata.adr018_flags.bw40);
assert!(frame.metadata.adr018_flags.stbc);
assert!(frame.metadata.adr018_flags.ldpc);
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
// Round-trip the encoder
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
}
#[test]
fn adr110_ppdu_byte_round_trips_for_known_variants() {
for v in [PpduType::HtLegacy, PpduType::HeSu, PpduType::HeMu, PpduType::HeTb, PpduType::Unknown] {
assert_eq!(PpduType::from_byte(v.to_byte()), v, "round-trip failed for {v:?}");
}
}
#[test]
fn test_parse_valid_frame() {
// 1 antenna, 56 subcarriers
@@ -40,6 +40,7 @@ mod csi_frame;
mod error;
pub mod esp32;
mod esp32_parser;
pub mod sync_packet;
// ADR-081: Rust mirror of the firmware radio abstraction layer (L1) and
// mesh sensing plane (L3). Lets host tests, simulators, and future
@@ -55,6 +56,9 @@ pub use esp32_parser::{
RUVIEW_FEATURE_MAGIC, RUVIEW_FEATURE_STATE_MAGIC, RUVIEW_FUSED_VITALS_MAGIC,
RUVIEW_TEMPORAL_MAGIC, RUVIEW_VITALS_MAGIC,
};
pub use sync_packet::{
SyncPacket, SyncPacketFlags, SYNC_PACKET_MAGIC, SYNC_PACKET_SIZE, SYNC_PACKET_PROTO_VER,
};
pub use radio_ops::{
crc32_ieee, decode_anomaly_alert, decode_mesh, decode_node_status, encode_health, AnomalyAlert,
AuthClass, CaptureProfile, MeshError, MeshHeader, MeshMsgType, MeshRole, MockRadio, NodeStatus,
@@ -0,0 +1,471 @@
//! ADR-110 §A0.12 sync packet decoder (firmware v0.6.9+).
//!
//! Emitted by the firmware on the same UDP socket as ADR-018 CSI frames,
//! distinguished by leading magic `0xC511A110`. Pairs `(node_id, sequence)`
//! across the two UDP streams so a host aggregator can recover mesh-aligned
//! timestamps for every CSI frame — see `WITNESS-LOG-110 §A0.12` for live
//! verification, `archive/v1/src/hardware/csi_extractor.py:SyncPacketParser`
//! for the matching Python decoder.
//!
//! Wire format (32 bytes, little-endian):
//! ```text
//! [0..3] magic 0xC511A110 (LE u32)
//! [4] node_id
//! [5] proto_ver (currently 0x01)
//! [6] flags: bit 0 = is_leader
//! bit 1 = is_valid (fresh sync within VALID_WINDOW_MS)
//! bit 2 = smoothed_used (EMA filter active)
//! [7] reserved
//! [8..15] local esp_timer_get_time() (u64)
//! [16..23] mesh-aligned epoch = local + smoothed offset (u64)
//! [24..27] high-water CSI sequence (u32) — pairing key against ADR-018 frames
//! [28..31] reserved
//! ```
//!
//! Recover the per-board offset for a given sync packet as
//! `local_us - epoch_us` (signed). Follower nodes report the EMA-smoothed
//! offset measured in §A0.10; leader nodes report `~0` modulo call-stack
//! elapsed time (`leader_epoch_us = now_us` by definition).
use serde::{Deserialize, Serialize};
use crate::error::ParseError;
/// Magic constant in the first 4 little-endian bytes of every sync packet.
pub const SYNC_PACKET_MAGIC: u32 = 0xC511_A110;
/// Total wire size of a v0.6.9+ sync packet.
pub const SYNC_PACKET_SIZE: usize = 32;
/// Wire protocol version currently emitted by firmware.
pub const SYNC_PACKET_PROTO_VER: u8 = 0x01;
/// Decoded ADR-110 §A0.12 sync packet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncPacket {
pub node_id: u8,
pub proto_ver: u8,
pub flags: SyncPacketFlags,
/// Node-local `esp_timer_get_time()` snapshot at emission time.
pub local_us: u64,
/// Mesh-aligned epoch — `local_us + smoothed_offset`.
pub epoch_us: u64,
/// High-water ADR-018 CSI sequence number at emission time. Host
/// aggregator pairs (`node_id`, `sequence`) across the two UDP streams
/// to apply the recovered offset back to in-flight CSI frames.
pub sequence: u32,
}
/// Flag bits packed into byte 6 of the sync packet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SyncPacketFlags {
pub is_leader: bool,
pub is_valid: bool,
pub smoothed_used: bool,
}
impl SyncPacketFlags {
pub fn from_byte(b: u8) -> Self {
Self {
is_leader: (b & 0x01) != 0,
is_valid: (b & 0x02) != 0,
smoothed_used: (b & 0x04) != 0,
}
}
pub fn to_byte(self) -> u8 {
let mut b = 0u8;
if self.is_leader { b |= 0x01; }
if self.is_valid { b |= 0x02; }
if self.smoothed_used { b |= 0x04; }
b
}
}
impl SyncPacket {
/// Decode a 32-byte sync packet. Returns `ParseError::InvalidMagic` if
/// the leading u32 doesn't match `SYNC_PACKET_MAGIC` (host should
/// dispatch on the magic before calling this — see crate-level docs).
pub fn from_bytes(buf: &[u8]) -> Result<Self, ParseError> {
if buf.len() < SYNC_PACKET_SIZE {
return Err(ParseError::InsufficientData {
needed: SYNC_PACKET_SIZE,
got: buf.len(),
});
}
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
if magic != SYNC_PACKET_MAGIC {
return Err(ParseError::InvalidMagic { expected: SYNC_PACKET_MAGIC, got: magic });
}
let node_id = buf[4];
let proto_ver = buf[5];
let flags = SyncPacketFlags::from_byte(buf[6]);
// buf[7] reserved
let local_us = u64::from_le_bytes(buf[8..16].try_into().unwrap());
let epoch_us = u64::from_le_bytes(buf[16..24].try_into().unwrap());
let sequence = u32::from_le_bytes(buf[24..28].try_into().unwrap());
// buf[28..32] reserved
Ok(Self {
node_id,
proto_ver,
flags,
local_us,
epoch_us,
sequence,
})
}
/// Recover the signed offset between this node's local monotonic clock
/// and the mesh epoch (`local_us - epoch_us`). For followers this is
/// the EMA-smoothed offset; for leaders this is approximately 0 (a few
/// µs of call-stack elapsed only).
pub fn local_minus_epoch_us(&self) -> i64 {
(self.local_us as i64) - (self.epoch_us as i64)
}
/// Given a CSI frame's node-local `esp_timer_get_time()` snapshot,
/// recover the mesh-aligned timestamp using this sync packet as the
/// reference point.
///
/// Math (all in node-local µs, see ADR-110 §A0.12):
///
/// ```text
/// offset = epoch_us - local_us (signed; this packet)
/// mesh_epoch(frame) = local_at_frame_us + offset
/// = local_at_frame_us + (epoch_us - local_us)
/// ```
///
/// On the leader this gives `≈ local_at_frame_us`. On a follower this
/// gives the mesh-aligned time aligned to the leader's clock within
/// the §A0.10 measured 104 µs stdev (the same EMA-smoothed offset
/// the firmware applied when it built this sync packet's `epoch_us`).
///
/// Use this on the host side whenever a CSI frame arrives with
/// ADR-018 byte 19 bit 4 set: look up the matching node's most-recent
/// `SyncPacket`, call `apply_to_local(frame.local_us)`, stamp the
/// result on the frame for downstream multistatic fusion.
pub fn apply_to_local(&self, local_at_frame_us: u64) -> u64 {
// Compute the offset as a signed delta in the µs domain. Adding it
// back to the frame's local snapshot recovers the mesh epoch.
let offset = (self.epoch_us as i64).wrapping_sub(self.local_us as i64);
(local_at_frame_us as i64).wrapping_add(offset) as u64
}
/// Recover the mesh-aligned timestamp for an in-flight CSI frame
/// **using its ADR-018 sequence number** as the timeline anchor.
///
/// CSI frames carry no per-frame `local_us` field (ADR-018 v1 wire
/// format reserves no slot for it — see WITNESS-LOG-110 §A0.11),
/// but they do carry a 32-bit sequence number. The firmware emits
/// a sync packet alongside CSI frames, stamping the sequence
/// high-water observed at emit time into [`SyncPacket::sequence`].
///
/// Given a frame's sequence and the node's observed CSI frame rate,
/// estimate the node-local time at the frame and apply the mesh
/// offset:
///
/// ```text
/// Δframes = frame_seq - sync.sequence (wrapping)
/// Δus = Δframes × 1_000_000 / fps_hz (node-local)
/// local_at = sync.local_us + Δus
/// mesh = local_at + (sync.epoch_us - sync.local_us)
/// ```
///
/// `fps_hz` must be > 0; pass the firmware's `CSI_MIN_SEND_INTERVAL_US`
/// inverse (≈ 20 fps) or a measured rate from the broadcast-tick task.
/// The estimate is exact when the frame rate is stable (a node holding
/// 20 fps within ±1 frame for the sync→frame interval gives
/// |error| < 1/fps_hz ≈ 50 ms × the per-frame jitter ratio).
pub fn mesh_aligned_us_for_sequence(&self, frame_seq: u32, fps_hz: f64) -> u64 {
debug_assert!(fps_hz > 0.0, "fps_hz must be positive");
let dframes = (frame_seq.wrapping_sub(self.sequence)) as i64;
let dus = (dframes as f64 * 1_000_000.0 / fps_hz) as i64;
let local_at = (self.local_us as i64).wrapping_add(dus) as u64;
self.apply_to_local(local_at)
}
/// Serialize back to wire bytes (32 bytes, little-endian).
pub fn to_bytes(&self) -> [u8; SYNC_PACKET_SIZE] {
let mut out = [0u8; SYNC_PACKET_SIZE];
out[0..4].copy_from_slice(&SYNC_PACKET_MAGIC.to_le_bytes());
out[4] = self.node_id;
out[5] = self.proto_ver;
out[6] = self.flags.to_byte();
// out[7] reserved zero
out[8..16].copy_from_slice(&self.local_us.to_le_bytes());
out[16..24].copy_from_slice(&self.epoch_us.to_le_bytes());
out[24..28].copy_from_slice(&self.sequence.to_le_bytes());
// out[28..32] reserved zero
out
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Reproduces the COM9 follower sync-pkt #1 captured in WITNESS-LOG-110 §A0.12.
#[test]
fn follower_typical_packet_roundtrips() {
let pkt = SyncPacket {
node_id: 9,
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,
};
let wire = pkt.to_bytes();
let decoded = SyncPacket::from_bytes(&wire).unwrap();
assert_eq!(decoded, pkt);
// The 1.16-second boot delta §A0.10 measured between COM9 and COM12.
assert_eq!(decoded.local_minus_epoch_us(), 1_163_565);
assert_eq!(decoded.flags.to_byte(), 0x06);
}
/// COM12 leader case from WITNESS-LOG-110 §A0.12: flags=0x03, epoch ≈ local.
#[test]
fn leader_packet_has_local_close_to_epoch() {
let pkt = SyncPacket {
node_id: 12,
proto_ver: 1,
flags: SyncPacketFlags { is_leader: true, is_valid: true, smoothed_used: false },
local_us: 28_864_932,
epoch_us: 28_864_939,
sequence: 20,
};
let wire = pkt.to_bytes();
let decoded = SyncPacket::from_bytes(&wire).unwrap();
assert_eq!(decoded.flags.to_byte(), 0x03);
assert_eq!(decoded.local_minus_epoch_us(), -7); // leader has zero offset modulo call-stack
assert!(decoded.flags.is_leader);
assert!(decoded.flags.is_valid);
assert!(!decoded.flags.smoothed_used);
}
#[test]
fn magic_mismatch_is_typed_error() {
let mut wire = SyncPacket {
node_id: 1, proto_ver: 1, flags: SyncPacketFlags::default(),
local_us: 0, epoch_us: 0, sequence: 0,
}.to_bytes();
wire[0] = 0x01; // corrupt magic low byte
let err = SyncPacket::from_bytes(&wire).unwrap_err();
match err {
ParseError::InvalidMagic { got, .. } => assert_ne!(got, SYNC_PACKET_MAGIC),
other => panic!("expected InvalidMagic, got {other:?}"),
}
}
#[test]
fn short_packet_is_typed_error() {
let wire = [0u8; 16]; // half a packet
let err = SyncPacket::from_bytes(&wire).unwrap_err();
match err {
ParseError::InsufficientData { needed, got } => {
assert_eq!(needed, SYNC_PACKET_SIZE);
assert_eq!(got, 16);
}
other => panic!("expected InsufficientData, got {other:?}"),
}
}
/// Every (leader, valid, smoothed_used) triple round-trips independently.
#[test]
fn all_flag_combinations_roundtrip() {
for &is_leader in &[false, true] {
for &is_valid in &[false, true] {
for &smoothed_used in &[false, true] {
let flags = SyncPacketFlags { is_leader, is_valid, smoothed_used };
let pkt = SyncPacket {
node_id: 1, proto_ver: 1, flags,
local_us: 1234, epoch_us: 5678, sequence: 99,
};
let wire = pkt.to_bytes();
let decoded = SyncPacket::from_bytes(&wire).unwrap();
assert_eq!(decoded.flags, flags);
assert_eq!(decoded.flags.to_byte(), flags.to_byte());
}
}
}
}
/// A host dispatches CSI vs sync purely on the leading u32. The two
/// magics must therefore never collide.
#[test]
fn sync_and_csi_magics_differ() {
assert_ne!(SYNC_PACKET_MAGIC, crate::esp32_parser::ESP32_CSI_MAGIC);
}
/// Applying a sync packet to its own local_us must recover its own
/// epoch_us. Foundational identity for the math.
#[test]
fn apply_to_local_recovers_packet_epoch() {
let pkt = SyncPacket {
node_id: 9, 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,
};
assert_eq!(pkt.apply_to_local(pkt.local_us), pkt.epoch_us);
}
/// A CSI frame's local timestamp arriving after the sync packet
/// gets the same offset applied — the µs delta between sync and frame
/// is preserved on both clocks.
#[test]
fn apply_to_local_preserves_inter_frame_delta() {
let pkt = SyncPacket {
node_id: 9, 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,
};
// Frame arrives 100 ms after the sync packet on the follower's local clock.
let local_at_frame = pkt.local_us + 100_000;
let mesh_epoch = pkt.apply_to_local(local_at_frame);
// Mesh epoch should also be 100 ms after the sync packet's epoch.
assert_eq!(mesh_epoch, pkt.epoch_us + 100_000);
// Offset must equal local - epoch on both clocks.
assert_eq!(local_at_frame - mesh_epoch, pkt.local_us - pkt.epoch_us);
}
/// Leader sync packet has near-zero offset, so apply_to_local is
/// approximately identity (modulo the few µs call-stack delta).
#[test]
fn apply_to_local_on_leader_is_near_identity() {
let pkt = SyncPacket {
node_id: 12, proto_ver: 1,
flags: SyncPacketFlags { is_leader: true, is_valid: true, smoothed_used: false },
local_us: 28_864_932, epoch_us: 28_864_939, sequence: 20,
};
let frame_local = 30_000_000u64;
let mesh = pkt.apply_to_local(frame_local);
assert!((mesh as i64 - frame_local as i64).abs() <= 100,
"leader apply should be within 100 µs of identity, got {} delta",
mesh as i64 - frame_local as i64);
}
/// At the sync packet's own sequence number, the interpolated mesh
/// time must equal `epoch_us` exactly.
#[test]
fn mesh_aligned_for_sequence_identity_at_sync_point() {
let pkt = SyncPacket {
node_id: 9, 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,
};
assert_eq!(pkt.mesh_aligned_us_for_sequence(20, 20.0), pkt.epoch_us);
}
/// 20 frames after the sync packet at 20 Hz → mesh time advances by 1 s,
/// preserving the leader/follower clock offset.
#[test]
fn mesh_aligned_for_sequence_extrapolates_forward() {
let pkt = SyncPacket {
node_id: 9, 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,
};
// 20 frames at 20 fps = 1 000 000 µs
let mesh = pkt.mesh_aligned_us_for_sequence(40, 20.0);
assert_eq!(mesh, pkt.epoch_us + 1_000_000);
}
/// Sequence wraparound (u32 overflow) must extrapolate forward by one
/// frame, not jump backward by 2^32. The wrapping_sub semantics in
/// the implementation guard this.
#[test]
fn mesh_aligned_for_sequence_handles_seq_wraparound() {
let pkt = SyncPacket {
node_id: 9, proto_ver: 1,
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
local_us: 10_000, epoch_us: 10_000, sequence: u32::MAX,
};
// Next sequence after u32::MAX is 0 (wrap). Δframes = 1, not -2^32.
let mesh = pkt.mesh_aligned_us_for_sequence(0, 20.0);
assert_eq!(mesh, pkt.epoch_us + 50_000); // 1 frame at 20 fps = 50 ms
}
/// End-to-end ADR-110 pipeline sanity:
/// (1) firmware emits sync packet (bytes built here as a stand-in)
/// (2) host wire-decodes via from_bytes
/// (3) a CSI frame arrives 100 sequences later (≈ 5 s @ 20 fps)
/// (4) mesh_aligned_us_for_sequence recovers its mesh timestamp
/// Asserts that the recovered mesh time matches sync.epoch_us + Δus exactly,
/// and cross-checks against apply_to_local. This is the contract every
/// downstream multistatic-fusion consumer relies on.
#[test]
fn end_to_end_sync_decode_then_frame_mesh_recovery() {
let pkt = SyncPacket {
node_id: 9,
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,
};
let wire = pkt.to_bytes();
assert_eq!(wire.len(), SYNC_PACKET_SIZE);
let decoded = SyncPacket::from_bytes(&wire).unwrap();
assert_eq!(decoded, pkt);
// 5 s after sync at 20 fps = 100 frames later
let frame_seq = pkt.sequence + 100;
let mesh_us = decoded.mesh_aligned_us_for_sequence(frame_seq, 20.0);
assert_eq!(mesh_us, pkt.epoch_us + 5_000_000);
// Same mesh time via direct apply_to_local — both paths must agree
let local_at_frame = pkt.local_us + 5_000_000;
assert_eq!(decoded.apply_to_local(local_at_frame), mesh_us);
}
#[test]
fn wire_size_constant_is_correct() {
let pkt = SyncPacket {
node_id: 0, proto_ver: 1, flags: SyncPacketFlags::default(),
local_us: 0, epoch_us: 0, sequence: 0,
};
assert_eq!(pkt.to_bytes().len(), SYNC_PACKET_SIZE);
assert_eq!(SYNC_PACKET_SIZE, 32);
}
/// ADR-110 iter 21 — cross-language wire-format conformance gate.
///
/// These exact bytes are ALSO pinned in the Python test
/// `test_canonical_wire_bytes_match_rust_decoder` in
/// `archive/v1/tests/unit/test_esp32_binary_parser.py`. If this
/// canonical hex stops matching what Python emits for the same
/// SyncPacket fields, ONE of the decoders has drifted from the wire.
///
/// Canonical packet: COM9 sync-pkt #1 from §A0.12 live capture.
#[test]
fn canonical_wire_bytes_match_python_decoder() {
// Exact bytes matching the Python pin (hex-decoded by hand to bytes).
let canonical: [u8; 32] = [
0x10, 0xa1, 0x11, 0xc5, // magic 0xC511A110 (LE u32)
0x09, // node_id = 9
0x01, // proto_ver = 1
0x06, // flags: bit1=is_valid, bit2=smoothed_used
0x00, // reserved
0xf2, 0x6d, 0xb7, 0x01, 0x00, 0x00, 0x00, 0x00, // local_us = 28_798_450
0xc5, 0xac, 0xa5, 0x01, 0x00, 0x00, 0x00, 0x00, // epoch_us = 27_634_885
0x14, 0x00, 0x00, 0x00, // sequence = 20
0x00, 0x00, 0x00, 0x00, // reserved
];
let decoded = SyncPacket::from_bytes(&canonical).unwrap();
assert_eq!(decoded.node_id, 9);
assert_eq!(decoded.proto_ver, 1);
assert_eq!(decoded.flags.to_byte(), 0x06);
assert!(!decoded.flags.is_leader);
assert!(decoded.flags.is_valid);
assert!(decoded.flags.smoothed_used);
assert_eq!(decoded.local_us, 28_798_450);
assert_eq!(decoded.epoch_us, 27_634_885);
assert_eq!(decoded.sequence, 20);
// §A0.10's measured 1.16-second boot delta.
assert_eq!(decoded.local_minus_epoch_us(), 1_163_565);
// Round-trip: re-encoding the decoded struct must produce the same
// canonical bytes — this is what catches any drift in to_bytes.
let re_encoded = decoded.to_bytes();
assert_eq!(re_encoded, canonical,
"Rust to_bytes drifted from the canonical pin — Python decoder will break");
}
}
@@ -50,6 +50,9 @@ wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifisca
# build without vcpkg/openblas (issue #366, #415).
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
# explicitly out of scope of ADR-099 (D5).
@@ -65,6 +68,26 @@ ureq = { version = "2", default-features = false, features = ["tls", "json"
sha2 = "0.10"
thiserror = "1"
# ADR-115 §3.8 — MQTT publisher (HA-DISCO).
# Gated behind the `mqtt` feature so the default binary stays small for users
# who don't need Home Assistant integration. `rumqttc` is the chosen Rust MQTT
# client (ADR-115 §10 references). `rustls` is preferred over openssl on
# Windows to keep parity with the rest of the workspace (`ureq` above also
# uses rustls).
rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true }
[features]
default = []
# Enables the ADR-115 §2 MQTT auto-discovery publisher. Without this feature
# all `--mqtt-*` CLI flags still parse (cli.rs declares them unconditionally),
# but enabling `--mqtt` at runtime logs a `WARN` and the publisher is a no-op.
mqtt = ["dep:rumqttc"]
# ADR-115 §3.11 — Matter Bridge (HA-FABRIC). Same gating principle: flags
# parse unconditionally; the bridge is a no-op without this feature.
# matter-rs is added in P7; intentionally absent in P1 to keep the dep
# surface small until the SDK choice is validated.
matter = []
[dev-dependencies]
tempfile = "3.10"
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
@@ -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::*;