mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +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:
Generated
+1
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user