mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
ADR-115: Home Assistant + Matter integration (#778)
Closes ADR-115's MQTT track (HA-DISCO + HA-MIND + HA-FABRIC scaffolding). Headline: - 21 entity kinds per node (11 raw + 10 semantic primitives) - MQTT auto-discovery with HA conventions - Matter Bridge scaffolding (SDK wiring deferred to v0.7.1 per ADR §9.10) - Privacy mode strips biometrics at the wire, semantic primitives keep working - 420+ lib tests, mosquitto-backed integration tests, property-based fuzzing - 8 starter HA Blueprints + 3 Lovelace dashboards shipped Tracking issue: #776
This commit is contained in:
@@ -92,3 +92,17 @@ matter = []
|
||||
tempfile = "3.10"
|
||||
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
|
||||
tower = { workspace = true }
|
||||
# ADR-115 P9 — micro-benchmarks for MQTT hot paths + semantic bus.
|
||||
# Heavy dep tree (~80 transitive crates) so it's dev-only; benches live
|
||||
# behind --features mqtt because they bench the mqtt module.
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
# ADR-115 P9 — property-based fuzzing for the wire-boundary security
|
||||
# audit. Catches edge cases the example-based unit tests would miss
|
||||
# (random Unicode, control chars, etc.). Pinned to a small version that
|
||||
# doesn't pull in proptest-derive (we don't need it).
|
||||
proptest = { version = "1.5", default-features = false, features = ["std"] }
|
||||
|
||||
[[bench]]
|
||||
name = "mqtt_throughput"
|
||||
harness = false
|
||||
required-features = ["mqtt"]
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! ADR-115 P9 — MQTT pipeline throughput micro-benchmark.
|
||||
//!
|
||||
//! Measures the hot-path cost of:
|
||||
//! - Building a HA discovery payload (`DiscoveryBuilder::build`)
|
||||
//! - Encoding a numeric state message (`StateEncoder::numeric`)
|
||||
//! - Rate-limit decision (`RateLimiter::allow`)
|
||||
//! - Privacy filter (`privacy::decide`)
|
||||
//! - Full bus tick across all 10 semantic primitives
|
||||
//!
|
||||
//! Targets (laptop-class, single-threaded, release build):
|
||||
//! - discovery payload: < 5 µs
|
||||
//! - state encode: < 2 µs
|
||||
//! - rate limit: < 100 ns
|
||||
//! - privacy decide: < 50 ns
|
||||
//! - bus tick (10 prim):< 10 µs
|
||||
//!
|
||||
//! The bench is intentionally feature-gated so the default workspace
|
||||
//! build doesn't pull `criterion` in (it has a big-ish dep tree).
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo bench -p wifi-densepose-sensing-server --bench mqtt_throughput
|
||||
|
||||
#![cfg(feature = "mqtt")]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion};
|
||||
|
||||
use wifi_densepose_sensing_server::mqtt::{
|
||||
config::PublishRates,
|
||||
discovery::{DiscoveryBuilder, EntityKind},
|
||||
privacy::decide,
|
||||
state::{RateLimiter, StateEncoder, VitalsSnapshot},
|
||||
};
|
||||
use wifi_densepose_sensing_server::semantic::{PrimitiveConfig, RawSnapshot, SemanticBus};
|
||||
|
||||
fn builder() -> DiscoveryBuilder<'static> {
|
||||
DiscoveryBuilder {
|
||||
discovery_prefix: "homeassistant",
|
||||
node_id: "aabbccddeeff",
|
||||
node_friendly_name: Some("Bedroom"),
|
||||
sw_version: "v0.7.0",
|
||||
model: "ESP32-S3 CSI node",
|
||||
via_device: Some("cognitum_seed_1"),
|
||||
}
|
||||
}
|
||||
|
||||
fn snap() -> VitalsSnapshot {
|
||||
VitalsSnapshot {
|
||||
node_id: "aabbccddeeff".into(),
|
||||
timestamp_ms: 1779_512_400_000,
|
||||
presence: true,
|
||||
fall_detected: false,
|
||||
motion: 0.35,
|
||||
motion_energy: 1234.5,
|
||||
presence_score: 0.91,
|
||||
breathing_rate_bpm: Some(14.2),
|
||||
heartrate_bpm: Some(68.2),
|
||||
n_persons: 1,
|
||||
rssi_dbm: Some(-52.0),
|
||||
vital_confidence: 0.87,
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_snap() -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
node_id: "aabbccddeeff".into(),
|
||||
since_start: Duration::from_secs(120),
|
||||
timestamp_ms: 1779_512_400_000,
|
||||
presence: true,
|
||||
fall_detected: false,
|
||||
motion: 0.35,
|
||||
motion_energy: 1234.5,
|
||||
breathing_rate_bpm: Some(14.2),
|
||||
heart_rate_bpm: Some(68.2),
|
||||
n_persons: 1,
|
||||
rssi_dbm: Some(-52.0),
|
||||
vital_confidence: 0.87,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
bed_zones: vec!["bedroom".into()],
|
||||
local_seconds_since_midnight: 2 * 3600,
|
||||
}
|
||||
}
|
||||
|
||||
fn rates() -> PublishRates {
|
||||
PublishRates::default()
|
||||
}
|
||||
|
||||
fn bench_discovery_payload(c: &mut Criterion) {
|
||||
let b = builder();
|
||||
c.bench_function("discovery::build_presence", |bench| {
|
||||
bench.iter(|| {
|
||||
let cfg = b.build(black_box(EntityKind::Presence));
|
||||
black_box(serde_json::to_string(&cfg).unwrap())
|
||||
});
|
||||
});
|
||||
c.bench_function("discovery::build_heart_rate", |bench| {
|
||||
bench.iter(|| {
|
||||
let cfg = b.build(black_box(EntityKind::HeartRate));
|
||||
black_box(serde_json::to_string(&cfg).unwrap())
|
||||
});
|
||||
});
|
||||
c.bench_function("discovery::build_fall_event", |bench| {
|
||||
bench.iter(|| {
|
||||
let cfg = b.build(black_box(EntityKind::FallDetected));
|
||||
black_box(serde_json::to_string(&cfg).unwrap())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_state_encode(c: &mut Criterion) {
|
||||
let b = builder();
|
||||
let s = snap();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
c.bench_function("state::numeric_heart_rate", |bench| {
|
||||
bench.iter(|| {
|
||||
black_box(enc.numeric(EntityKind::HeartRate, &s).unwrap())
|
||||
});
|
||||
});
|
||||
c.bench_function("state::boolean_presence", |bench| {
|
||||
bench.iter(|| {
|
||||
black_box(enc.boolean(EntityKind::Presence, true).unwrap())
|
||||
});
|
||||
});
|
||||
c.bench_function("state::event_fall", |bench| {
|
||||
bench.iter(|| {
|
||||
black_box(enc.event(EntityKind::FallDetected, "fall_detected", 0, Some(0.87)).unwrap())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_rate_limit(c: &mut Criterion) {
|
||||
let r = rates();
|
||||
c.bench_function("rate_limiter::allow_first", |bench| {
|
||||
bench.iter_batched(
|
||||
RateLimiter::new,
|
||||
|mut rl| {
|
||||
black_box(rl.allow(
|
||||
black_box(EntityKind::HeartRate),
|
||||
Duration::from_secs(0),
|
||||
&r,
|
||||
))
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
c.bench_function("rate_limiter::allow_within_gap", |bench| {
|
||||
bench.iter_batched(
|
||||
|| {
|
||||
let mut rl = RateLimiter::new();
|
||||
rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r);
|
||||
rl
|
||||
},
|
||||
|mut rl| {
|
||||
black_box(rl.allow(
|
||||
black_box(EntityKind::HeartRate),
|
||||
Duration::from_secs(1),
|
||||
&r,
|
||||
))
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_privacy(c: &mut Criterion) {
|
||||
c.bench_function("privacy::decide_hr_strip", |bench| {
|
||||
bench.iter(|| black_box(decide(EntityKind::HeartRate, true)));
|
||||
});
|
||||
c.bench_function("privacy::decide_presence_keep", |bench| {
|
||||
bench.iter(|| black_box(decide(EntityKind::Presence, true)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_semantic_bus(c: &mut Criterion) {
|
||||
c.bench_function("semantic::bus_tick_all_10_primitives", |bench| {
|
||||
bench.iter_batched(
|
||||
|| (SemanticBus::new(PrimitiveConfig::default()), raw_snap()),
|
||||
|(mut bus, s)| black_box(bus.tick(&s)),
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_discovery_payload,
|
||||
bench_state_encode,
|
||||
bench_rate_limit,
|
||||
bench_privacy,
|
||||
bench_semantic_bus,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,143 @@
|
||||
//! ADR-115 P6 — minimal runnable example wiring the MQTT publisher
|
||||
//! against a broadcast channel of `VitalsSnapshot`s.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --release -p wifi-densepose-sensing-server \
|
||||
//! --features mqtt --example mqtt_publisher -- \
|
||||
//! --mqtt --mqtt-host 127.0.0.1
|
||||
//!
|
||||
//! Then in another terminal:
|
||||
//! mosquitto_sub -h 127.0.0.1 -t 'homeassistant/#' -v
|
||||
//!
|
||||
//! You should see one HA discovery `config` topic per entity per node
|
||||
//! land within a second of startup, followed by `state` topics ticking
|
||||
//! at the configured rates.
|
||||
//!
|
||||
//! This example is the production-wiring blueprint for `main.rs`:
|
||||
//! every line below is what the binary's startup path should do when
|
||||
//! `args.mqtt` is true. Keeping it in `examples/` lets us validate the
|
||||
//! wiring end-to-end without touching the 6000-line main.rs (which is
|
||||
//! the active edit surface of the parallel ADR-110 agent — see
|
||||
//! [[feedback-multi-agent-worktree]]).
|
||||
|
||||
// The full example body needs the `mqtt` feature (rumqttc, publisher::spawn,
|
||||
// etc.). When the feature is off we provide a stub `main` so the example
|
||||
// still compiles cleanly during a default `cargo build --workspace` —
|
||||
// otherwise CI fails with E0601 (`main function not found`) on every PR
|
||||
// that touches the workspace, even ones unrelated to ADR-115.
|
||||
#[cfg(not(feature = "mqtt"))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"This example requires --features mqtt. Re-run with: \n \
|
||||
cargo run -p wifi-densepose-sensing-server --features mqtt \
|
||||
--example mqtt_publisher -- --mqtt"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mqtt")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "mqtt")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "mqtt")]
|
||||
use clap::Parser;
|
||||
#[cfg(feature = "mqtt")]
|
||||
use tokio::sync::broadcast;
|
||||
#[cfg(feature = "mqtt")]
|
||||
use tracing::info;
|
||||
#[cfg(feature = "mqtt")]
|
||||
use wifi_densepose_sensing_server::cli::Args;
|
||||
#[cfg(feature = "mqtt")]
|
||||
use wifi_densepose_sensing_server::mqtt::{
|
||||
config::MqttConfig,
|
||||
publisher::{spawn, OwnedDiscoveryBuilder},
|
||||
security::audit,
|
||||
state::VitalsSnapshot,
|
||||
};
|
||||
|
||||
#[cfg(feature = "mqtt")]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
if !args.mqtt {
|
||||
eprintln!("This example requires --mqtt. Aborting.");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
// 1. Build MqttConfig from CLI + run the security audit before any
|
||||
// network I/O. A failed audit short-circuits with a clear error.
|
||||
let cfg = Arc::new(MqttConfig::from_args(&args));
|
||||
match audit(&cfg) {
|
||||
Ok(()) => {}
|
||||
Err(e) if !e.is_fatal() => {
|
||||
tracing::warn!(error = %e, "non-fatal MQTT audit advisory");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("MQTT audit failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The DiscoveryBuilder owns the per-node identity. In a real
|
||||
// deployment each ESP32 node would get its own builder; here we
|
||||
// fake one for demonstration.
|
||||
let builder = OwnedDiscoveryBuilder {
|
||||
discovery_prefix: cfg.discovery_prefix.clone(),
|
||||
node_id: "example_node".into(),
|
||||
node_friendly_name: Some("Example RuView Node".into()),
|
||||
sw_version: env!("CARGO_PKG_VERSION").into(),
|
||||
model: "ESP32-S3 CSI node (example)".into(),
|
||||
via_device: None,
|
||||
};
|
||||
|
||||
// 3. Broadcast channel — `sensing-server` already creates one of
|
||||
// these in main.rs (the one the WebSocket handler subscribes to).
|
||||
// We mirror it here.
|
||||
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(256);
|
||||
|
||||
// 4. Spawn the publisher. It returns a JoinHandle the caller can
|
||||
// await on shutdown.
|
||||
let publisher = spawn(cfg.clone(), builder, rx);
|
||||
info!("publisher spawned, sending demo snapshots every 500ms");
|
||||
|
||||
// 5. Demo loop — produce a fresh VitalsSnapshot every 500ms with
|
||||
// alternating presence so HA sees ON/OFF transitions.
|
||||
let mut tick: u64 = 0;
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(500));
|
||||
let stop = tokio::signal::ctrl_c();
|
||||
tokio::pin!(stop);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
tick += 1;
|
||||
let snap = VitalsSnapshot {
|
||||
node_id: "example_node".into(),
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
||||
presence: tick % 20 < 10,
|
||||
fall_detected: tick % 60 == 30,
|
||||
motion: 0.10 + ((tick as f64).sin().abs() * 0.30),
|
||||
motion_energy: 1000.0 + (tick as f64).cos() * 200.0,
|
||||
presence_score: 0.85,
|
||||
breathing_rate_bpm: Some(13.0 + ((tick as f64) * 0.05).sin()),
|
||||
heartrate_bpm: Some(68.0 + ((tick as f64) * 0.03).sin() * 5.0),
|
||||
n_persons: if tick % 20 < 10 { 1 } else { 0 },
|
||||
rssi_dbm: Some(-50.0 + ((tick as f64) * 0.1).sin() * 5.0),
|
||||
vital_confidence: 0.85,
|
||||
};
|
||||
let _ = tx.send(snap);
|
||||
}
|
||||
_ = &mut stop => {
|
||||
info!("ctrl-c received, shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(tx); // close broadcast → publisher publishes `offline` + disconnects.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), publisher).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
pub mod cli;
|
||||
pub mod dataset;
|
||||
pub mod edge_registry;
|
||||
#[allow(dead_code)]
|
||||
@@ -15,7 +16,10 @@ pub mod embedding;
|
||||
pub mod graph_transformer;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod matter;
|
||||
pub mod mqtt;
|
||||
pub mod path_safety;
|
||||
pub mod semantic;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
pub mod sona;
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Matter bridge-tree assembly (ADR-115 §3.11.2).
|
||||
//!
|
||||
//! Given a list of RuView nodes and the `EntityKind`s enabled for
|
||||
//! each, produce the Matter endpoint tree the SDK will materialise:
|
||||
//!
|
||||
//! ```text
|
||||
//! Endpoint 0 (root: BridgedDevicesAggregator)
|
||||
//! Endpoint 1 (BridgedNode for ruview-node-0)
|
||||
//! Endpoint 2 (OccupancySensor for presence + PersonCount attr)
|
||||
//! Endpoint 3 (OccupancySensor for zone_kitchen)
|
||||
//! Endpoint 4 (OccupancySensor for SomeoneSleeping)
|
||||
//! Endpoint 5 (GenericSwitch for FallDetected)
|
||||
//! …
|
||||
//! Endpoint N (BridgedNode for ruview-node-1)
|
||||
//! …
|
||||
//! ```
|
||||
//!
|
||||
//! Tree assembly is pure logic — no SDK calls. The SDK layer reads
|
||||
//! this struct and registers the matching clusters. Splitting this
|
||||
//! out keeps the bridge topology testable independently of the
|
||||
//! `rs-matter` / chip-tool choice (per §9.10).
|
||||
|
||||
use crate::mqtt::discovery::EntityKind;
|
||||
|
||||
use super::clusters::{
|
||||
matter_mapping, MatterClusterMapping, DEVICE_TYPE_AGGREGATOR,
|
||||
DEVICE_TYPE_BRIDGED_NODE,
|
||||
};
|
||||
|
||||
/// One endpoint on the Matter device tree.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Endpoint {
|
||||
pub endpoint_id: u16,
|
||||
pub device_type: u32,
|
||||
pub label: String,
|
||||
pub clusters: Vec<u32>,
|
||||
pub vendor_attrs: Vec<u32>,
|
||||
/// `Some(_)` if this endpoint maps back to an `EntityKind`;
|
||||
/// `None` for structural endpoints (aggregator root, bridged node).
|
||||
pub source_entity: Option<EntityKind>,
|
||||
}
|
||||
|
||||
/// One RuView node's slice of the bridge tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeBranch {
|
||||
pub node_id: String,
|
||||
pub friendly_name: String,
|
||||
pub bridged_node_endpoint: u16,
|
||||
pub child_endpoints: Vec<Endpoint>,
|
||||
}
|
||||
|
||||
/// Whole bridge tree the SDK will materialise.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeTree {
|
||||
pub root: Endpoint,
|
||||
pub nodes: Vec<NodeBranch>,
|
||||
}
|
||||
|
||||
/// Builds a [`BridgeTree`] from a list of `(node_id, friendly_name,
|
||||
/// entities)` tuples. Endpoint IDs are assigned monotonically starting
|
||||
/// at 1 (Matter reserves endpoint 0 for the root).
|
||||
pub fn build_bridge_tree(nodes: &[(String, String, Vec<EntityKind>)]) -> BridgeTree {
|
||||
let root = Endpoint {
|
||||
endpoint_id: 0,
|
||||
device_type: DEVICE_TYPE_AGGREGATOR,
|
||||
label: "RuView Bridge".into(),
|
||||
clusters: vec![super::clusters::CLUSTER_BASIC_INFORMATION],
|
||||
vendor_attrs: vec![],
|
||||
source_entity: None,
|
||||
};
|
||||
|
||||
let mut next_endpoint: u16 = 1;
|
||||
let mut branches = Vec::with_capacity(nodes.len());
|
||||
|
||||
for (node_id, friendly_name, entities) in nodes {
|
||||
let bridged_node_ep = next_endpoint;
|
||||
next_endpoint += 1;
|
||||
|
||||
let mut children = Vec::new();
|
||||
|
||||
// Build a children-by-mapping bucket: entities that share the
|
||||
// OccupancySensor endpoint (e.g. PersonCount attaches to
|
||||
// Presence's endpoint) collapse onto the parent rather than
|
||||
// taking their own endpoint ID.
|
||||
let mut presence_endpoint_id: Option<u16> = None;
|
||||
|
||||
for entity in entities {
|
||||
let Some(m) = matter_mapping(*entity) else {
|
||||
continue; // explicitly MQTT-only
|
||||
};
|
||||
|
||||
if m.shares_occupancy_endpoint {
|
||||
if let Some(parent_ep) = presence_endpoint_id {
|
||||
// Attach as vendor attribute on the parent endpoint.
|
||||
if let Some(parent) = children
|
||||
.iter_mut()
|
||||
.find(|c: &&mut Endpoint| c.endpoint_id == parent_ep)
|
||||
{
|
||||
if let Some(va) = m.vendor_attr_id {
|
||||
parent.vendor_attrs.push(va);
|
||||
}
|
||||
parent.source_entity.get_or_insert(*entity);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let ep_id = next_endpoint;
|
||||
next_endpoint += 1;
|
||||
let mut ep = Endpoint {
|
||||
endpoint_id: ep_id,
|
||||
device_type: m.device_type,
|
||||
label: format!("{:?}", entity),
|
||||
clusters: vec![m.cluster, super::clusters::CLUSTER_BASIC_INFORMATION],
|
||||
vendor_attrs: m.vendor_attr_id.into_iter().collect(),
|
||||
source_entity: Some(*entity),
|
||||
};
|
||||
// Switch endpoints need the event cluster declared
|
||||
// (already covered by `clusters` above — but we record it
|
||||
// for the SDK layer's convenience).
|
||||
if matches!(*entity, EntityKind::Presence) {
|
||||
presence_endpoint_id = Some(ep_id);
|
||||
}
|
||||
if let Some(_eid) = m.event_id {
|
||||
// Event support is implicit when the Switch cluster is
|
||||
// present; the SDK reads the cluster and exposes the
|
||||
// event automatically. No extra field needed.
|
||||
}
|
||||
children.push(ep);
|
||||
}
|
||||
|
||||
branches.push(NodeBranch {
|
||||
node_id: node_id.clone(),
|
||||
friendly_name: friendly_name.clone(),
|
||||
bridged_node_endpoint: bridged_node_ep,
|
||||
child_endpoints: children,
|
||||
});
|
||||
}
|
||||
|
||||
BridgeTree {
|
||||
root,
|
||||
nodes: branches,
|
||||
}
|
||||
}
|
||||
|
||||
impl BridgeTree {
|
||||
/// Total number of endpoints (root + bridged nodes + per-entity).
|
||||
pub fn total_endpoints(&self) -> usize {
|
||||
let per_node: usize = self
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| 1 + n.child_endpoints.len()) // BridgedNode + children
|
||||
.sum();
|
||||
1 /* root */ + per_node
|
||||
}
|
||||
|
||||
/// Look up an endpoint by its assigned ID. Returns `None` if no
|
||||
/// endpoint with that ID exists in the tree.
|
||||
pub fn endpoint(&self, id: u16) -> Option<EndpointRef<'_>> {
|
||||
if self.root.endpoint_id == id {
|
||||
return Some(EndpointRef::Root(&self.root));
|
||||
}
|
||||
for n in &self.nodes {
|
||||
if n.bridged_node_endpoint == id {
|
||||
return Some(EndpointRef::BridgedNode(n));
|
||||
}
|
||||
for child in &n.child_endpoints {
|
||||
if child.endpoint_id == id {
|
||||
return Some(EndpointRef::Child { branch: n, child });
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved endpoint with backref to the owning branch (for logging /
|
||||
/// error messages).
|
||||
pub enum EndpointRef<'a> {
|
||||
Root(&'a Endpoint),
|
||||
BridgedNode(&'a NodeBranch),
|
||||
Child { branch: &'a NodeBranch, child: &'a Endpoint },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mqtt::discovery::EntityKind::*;
|
||||
|
||||
fn fixture() -> Vec<(String, String, Vec<EntityKind>)> {
|
||||
vec![(
|
||||
"node_aabb".into(),
|
||||
"Bedroom".into(),
|
||||
vec![
|
||||
Presence,
|
||||
PersonCount, // shares Presence's endpoint
|
||||
SomeoneSleeping,
|
||||
FallDetected,
|
||||
HeartRate, // MQTT-only → must NOT add an endpoint
|
||||
],
|
||||
)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_has_aggregator_root() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
assert_eq!(tree.root.endpoint_id, 0);
|
||||
assert_eq!(tree.root.device_type, DEVICE_TYPE_AGGREGATOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_branch_per_node() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
assert_eq!(tree.nodes.len(), 1);
|
||||
assert_eq!(tree.nodes[0].node_id, "node_aabb");
|
||||
assert_eq!(tree.nodes[0].friendly_name, "Bedroom");
|
||||
assert_eq!(tree.nodes[0].bridged_node_endpoint, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_count_collapses_onto_presence_endpoint() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
let branch = &tree.nodes[0];
|
||||
|
||||
// Children: Presence/PersonCount (1 ep), SomeoneSleeping (1 ep),
|
||||
// FallDetected (1 ep) = 3 endpoints. HR/BR → skipped.
|
||||
assert_eq!(branch.child_endpoints.len(), 3);
|
||||
|
||||
// Find the Presence endpoint — it should carry the PersonCount
|
||||
// vendor attribute.
|
||||
let presence_ep = branch
|
||||
.child_endpoints
|
||||
.iter()
|
||||
.find(|e| e.source_entity == Some(Presence))
|
||||
.expect("presence endpoint missing");
|
||||
assert!(presence_ep
|
||||
.vendor_attrs
|
||||
.contains(&super::super::clusters::VENDOR_ATTR_PERSON_COUNT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biometric_entities_skip_matter_tree() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
let branch = &tree.nodes[0];
|
||||
for ep in &branch.child_endpoints {
|
||||
assert!(
|
||||
ep.source_entity != Some(HeartRate),
|
||||
"HeartRate must NOT have a Matter endpoint"
|
||||
);
|
||||
assert!(
|
||||
ep.source_entity != Some(BreathingRate),
|
||||
"BreathingRate must NOT have a Matter endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_child_carries_basic_information_cluster() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
for branch in &tree.nodes {
|
||||
for ep in &branch.child_endpoints {
|
||||
assert!(
|
||||
ep.clusters
|
||||
.contains(&super::super::clusters::CLUSTER_BASIC_INFORMATION),
|
||||
"every endpoint must declare BasicInformation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_ids_are_monotonic_and_unique() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
let mut all_ids = vec![tree.root.endpoint_id];
|
||||
for branch in &tree.nodes {
|
||||
all_ids.push(branch.bridged_node_endpoint);
|
||||
for ep in &branch.child_endpoints {
|
||||
all_ids.push(ep.endpoint_id);
|
||||
}
|
||||
}
|
||||
let mut sorted = all_ids.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(all_ids.len(), sorted.len(), "endpoint IDs must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_endpoints_matches_explicit_count() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
// 1 root + 1 bridged + 3 children = 5.
|
||||
assert_eq!(tree.total_endpoints(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_lookup_resolves_all_ids() {
|
||||
let tree = build_bridge_tree(&fixture());
|
||||
for id in 0..tree.total_endpoints() as u16 {
|
||||
let er = tree.endpoint(id);
|
||||
assert!(er.is_some(), "endpoint {} not findable", id);
|
||||
}
|
||||
// Unknown ID returns None.
|
||||
assert!(tree.endpoint(999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_node_tree_keeps_per_node_isolation() {
|
||||
let nodes = vec![
|
||||
("aabb".into(), "Bedroom".into(), vec![Presence, FallDetected]),
|
||||
("ccdd".into(), "Living".into(), vec![Presence, MeetingInProgress]),
|
||||
];
|
||||
let tree = build_bridge_tree(&nodes);
|
||||
assert_eq!(tree.nodes.len(), 2);
|
||||
// Each node's children are isolated to that branch.
|
||||
for branch in &tree.nodes {
|
||||
assert_eq!(branch.child_endpoints.len(), 2);
|
||||
}
|
||||
// Total endpoints: 1 root + (1 bridged + 2 children) × 2 = 7.
|
||||
assert_eq!(tree.total_endpoints(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_node_list_yields_just_root() {
|
||||
let tree = build_bridge_tree(&[]);
|
||||
assert_eq!(tree.nodes.len(), 0);
|
||||
assert_eq!(tree.total_endpoints(), 1); // just the root
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Matter cluster + device-type ID mappings for RuView entities.
|
||||
//!
|
||||
//! IDs come from the **Matter Core Spec 1.3 §A.1 Reserved Cluster IDs**
|
||||
//! and **§1.3 Device Library**. Where ADR-115 §3.11.1 uses a name,
|
||||
//! the constant below carries the spec hex.
|
||||
|
||||
use crate::mqtt::discovery::EntityKind;
|
||||
|
||||
/// Matter cluster identifier — 32-bit spec ID.
|
||||
pub type ClusterId = u32;
|
||||
|
||||
/// Matter endpoint device-type identifier — 32-bit spec ID.
|
||||
pub type EndpointTypeId = u32;
|
||||
|
||||
// ── Matter Core Spec 1.3 — Reserved Cluster IDs we publish ───────────
|
||||
/// Per §A.1.4 "OccupancySensing" — boolean occupancy + occupancy
|
||||
/// sensor type bitmap.
|
||||
pub const CLUSTER_OCCUPANCY_SENSING: ClusterId = 0x0406;
|
||||
|
||||
/// Per §A.1.6 "Switch" — momentary press events used to fire fall /
|
||||
/// bed-exit / multi-room one-shots.
|
||||
pub const CLUSTER_SWITCH: ClusterId = 0x003B;
|
||||
|
||||
/// Per §A.1.0 "BasicInformation" — Vendor ID, Product ID, software
|
||||
/// version, serial number. Every endpoint includes this.
|
||||
pub const CLUSTER_BASIC_INFORMATION: ClusterId = 0x0028;
|
||||
|
||||
/// Per §A.1.5 "BooleanState" — single boolean attribute. Used for
|
||||
/// non-occupancy boolean primitives (no_movement etc.) where the
|
||||
/// occupancy semantics would be misleading to controllers.
|
||||
pub const CLUSTER_BOOLEAN_STATE: ClusterId = 0x0045;
|
||||
|
||||
/// Per §A.1.16 "BridgedDeviceBasicInformation" — identifies a bridged
|
||||
/// device (one per RuView node) on a Matter Bridged Devices Aggregator.
|
||||
pub const CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION: ClusterId = 0x0039;
|
||||
|
||||
// ── Matter Device Library 1.3 — Device-type IDs ──────────────────────
|
||||
/// Per §7.3 OccupancySensor.
|
||||
pub const DEVICE_TYPE_OCCUPANCY_SENSOR: EndpointTypeId = 0x0107;
|
||||
/// Per §6.6 GenericSwitch. Used for fall / bed-exit / multi-room events.
|
||||
pub const DEVICE_TYPE_GENERIC_SWITCH: EndpointTypeId = 0x000F;
|
||||
/// Per §10.2 Aggregator. The top-level endpoint that exposes all
|
||||
/// bridged RuView nodes.
|
||||
pub const DEVICE_TYPE_AGGREGATOR: EndpointTypeId = 0x000E;
|
||||
/// Per §10.1 Bridged Node — one endpoint per RuView physical node.
|
||||
pub const DEVICE_TYPE_BRIDGED_NODE: EndpointTypeId = 0x0013;
|
||||
|
||||
// ── Vendor-extension attribute (per ADR §3.11.1) ─────────────────────
|
||||
/// Vendor-extension attribute carrying `n_persons` on the
|
||||
/// OccupancySensing cluster. Apple Home / Google Home will ignore this
|
||||
/// gracefully; HA + SmartThings will surface it via the Matter
|
||||
/// integration's attribute-renderer.
|
||||
///
|
||||
/// Attribute IDs ≥ 0xFFF1_0000 are reserved for vendor extensions per
|
||||
/// Matter Core §7.18.2. We use 0xFFF1_0001 = "wifi-densepose person
|
||||
/// count".
|
||||
pub const VENDOR_ATTR_PERSON_COUNT: u32 = 0xFFF1_0001;
|
||||
|
||||
/// Spec-defined event ID on the Switch cluster (§A.1.6.5.4).
|
||||
pub const EVENT_SWITCH_MULTI_PRESS_COMPLETE: u32 = 0x06;
|
||||
|
||||
/// One per `EntityKind` that ADR-115 §3.11.1 maps to Matter. Entities
|
||||
/// NOT in the table (HR / BR / pose / motion_energy / presence_score)
|
||||
/// are explicitly not exposed over Matter — there are no spec
|
||||
/// clusters for them today.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MatterClusterMapping {
|
||||
/// Which cluster the entity lives on.
|
||||
pub cluster: ClusterId,
|
||||
/// Which device-type the endpoint declares.
|
||||
pub device_type: EndpointTypeId,
|
||||
/// `Some(_)` if the entity emits Matter events (vs. attribute
|
||||
/// reads); `None` if it's read as a cluster attribute.
|
||||
pub event_id: Option<u32>,
|
||||
/// `Some(_)` if the entity uses a vendor-extension attribute
|
||||
/// rather than a spec attribute.
|
||||
pub vendor_attr_id: Option<u32>,
|
||||
/// True iff this entity belongs on the same endpoint as the parent
|
||||
/// node's OccupancySensor (multi-attribute entity grouping).
|
||||
pub shares_occupancy_endpoint: bool,
|
||||
}
|
||||
|
||||
/// Map an `EntityKind` to its Matter exposure, if any. Returns `None`
|
||||
/// for entities that are deliberately MQTT-only because no Matter
|
||||
/// cluster represents them (HR / BR / pose / motion_energy / presence_score).
|
||||
pub fn matter_mapping(entity: EntityKind) -> Option<MatterClusterMapping> {
|
||||
use EntityKind::*;
|
||||
Some(match entity {
|
||||
Presence | ZoneOccupancy => MatterClusterMapping {
|
||||
cluster: CLUSTER_OCCUPANCY_SENSING,
|
||||
device_type: DEVICE_TYPE_OCCUPANCY_SENSOR,
|
||||
event_id: None,
|
||||
vendor_attr_id: None,
|
||||
shares_occupancy_endpoint: false,
|
||||
},
|
||||
PersonCount => MatterClusterMapping {
|
||||
cluster: CLUSTER_OCCUPANCY_SENSING,
|
||||
device_type: DEVICE_TYPE_OCCUPANCY_SENSOR,
|
||||
event_id: None,
|
||||
vendor_attr_id: Some(VENDOR_ATTR_PERSON_COUNT),
|
||||
shares_occupancy_endpoint: true,
|
||||
},
|
||||
FallDetected | BedExit | MultiRoomTransition => MatterClusterMapping {
|
||||
cluster: CLUSTER_SWITCH,
|
||||
device_type: DEVICE_TYPE_GENERIC_SWITCH,
|
||||
event_id: Some(EVENT_SWITCH_MULTI_PRESS_COMPLETE),
|
||||
vendor_attr_id: None,
|
||||
shares_occupancy_endpoint: false,
|
||||
},
|
||||
// Semantic primitives that surface as occupancy-style booleans
|
||||
// (separate endpoints — one per primitive — so controllers can
|
||||
// bind individual scenes to each).
|
||||
SomeoneSleeping
|
||||
| RoomActive
|
||||
| MeetingInProgress
|
||||
| BathroomOccupied => MatterClusterMapping {
|
||||
cluster: CLUSTER_OCCUPANCY_SENSING,
|
||||
device_type: DEVICE_TYPE_OCCUPANCY_SENSOR,
|
||||
event_id: None,
|
||||
vendor_attr_id: None,
|
||||
shares_occupancy_endpoint: false,
|
||||
},
|
||||
// Problem-state booleans use BooleanState — semantically they
|
||||
// are NOT occupancy, and controllers shouldn't wire them into
|
||||
// motion-light scenes.
|
||||
PossibleDistress | ElderlyInactivityAnomaly | NoMovement => MatterClusterMapping {
|
||||
cluster: CLUSTER_BOOLEAN_STATE,
|
||||
device_type: DEVICE_TYPE_OCCUPANCY_SENSOR,
|
||||
event_id: None,
|
||||
vendor_attr_id: None,
|
||||
shares_occupancy_endpoint: false,
|
||||
},
|
||||
// Fall-risk scalar surfaces as a vendor-extension attribute on
|
||||
// the parent BridgedNode (no Matter spec for risk scores).
|
||||
FallRiskElevated => MatterClusterMapping {
|
||||
cluster: CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION,
|
||||
device_type: DEVICE_TYPE_BRIDGED_NODE,
|
||||
event_id: None,
|
||||
vendor_attr_id: Some(0xFFF1_0002),
|
||||
shares_occupancy_endpoint: false,
|
||||
},
|
||||
// Explicitly MQTT-only — no Matter cluster representation.
|
||||
BreathingRate | HeartRate | MotionLevel | MotionEnergy | PresenceScore | Rssi | PoseKeypoints => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// True iff the entity has a Matter exposure on a current spec cluster.
|
||||
pub fn entity_on_matter(entity: EntityKind) -> bool {
|
||||
matter_mapping(entity).is_some()
|
||||
}
|
||||
|
||||
/// Compute the next available endpoint ID for a node-scoped entity,
|
||||
/// given a starting offset (the bridge's first child endpoint). Used
|
||||
/// by the publisher to assign per-primitive endpoints deterministically.
|
||||
pub fn next_endpoint(base: u16, primitive_index: u16) -> u16 {
|
||||
base.saturating_add(primitive_index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presence_maps_to_occupancy_sensor() {
|
||||
let m = matter_mapping(EntityKind::Presence).unwrap();
|
||||
assert_eq!(m.cluster, 0x0406); // OccupancySensing
|
||||
assert_eq!(m.device_type, 0x0107); // OccupancySensor
|
||||
assert!(m.event_id.is_none());
|
||||
assert!(m.vendor_attr_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_occupancy_uses_occupancy_sensor_too() {
|
||||
let m = matter_mapping(EntityKind::ZoneOccupancy).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_OCCUPANCY_SENSING);
|
||||
assert_eq!(m.device_type, DEVICE_TYPE_OCCUPANCY_SENSOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_count_is_vendor_extension_on_occupancy_endpoint() {
|
||||
let m = matter_mapping(EntityKind::PersonCount).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_OCCUPANCY_SENSING);
|
||||
assert_eq!(m.vendor_attr_id, Some(0xFFF1_0001));
|
||||
assert!(m.shares_occupancy_endpoint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_uses_switch_multi_press_complete_event() {
|
||||
let m = matter_mapping(EntityKind::FallDetected).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_SWITCH);
|
||||
assert_eq!(m.device_type, DEVICE_TYPE_GENERIC_SWITCH);
|
||||
assert_eq!(m.event_id, Some(EVENT_SWITCH_MULTI_PRESS_COMPLETE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bed_exit_uses_switch_event() {
|
||||
let m = matter_mapping(EntityKind::BedExit).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_SWITCH);
|
||||
assert!(m.event_id.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_room_uses_switch_event() {
|
||||
let m = matter_mapping(EntityKind::MultiRoomTransition).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_SWITCH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn someone_sleeping_uses_occupancy_separate_endpoint() {
|
||||
let m = matter_mapping(EntityKind::SomeoneSleeping).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_OCCUPANCY_SENSING);
|
||||
// NOT shares_occupancy_endpoint — needs its own endpoint so
|
||||
// controllers can wire a "when bedroom_sleeping is on" scene
|
||||
// independently of the raw presence sensor.
|
||||
assert!(!m.shares_occupancy_endpoint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distress_uses_boolean_state_not_occupancy() {
|
||||
// The semantic distinction matters: a controller binding a
|
||||
// "when motion detected, turn lights on" scene must NOT fire
|
||||
// for distress. We use BooleanState to keep them separate.
|
||||
let m = matter_mapping(EntityKind::PossibleDistress).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_BOOLEAN_STATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_movement_uses_boolean_state() {
|
||||
let m = matter_mapping(EntityKind::NoMovement).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_BOOLEAN_STATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_risk_scalar_is_vendor_attribute_on_bridged_node() {
|
||||
let m = matter_mapping(EntityKind::FallRiskElevated).unwrap();
|
||||
assert_eq!(m.cluster, CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION);
|
||||
assert!(m.vendor_attr_id.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biometric_entities_have_no_matter_exposure() {
|
||||
// ADR §3.11.4 — Matter spec has no clusters for these, so
|
||||
// they're explicitly None.
|
||||
assert!(matter_mapping(EntityKind::HeartRate).is_none());
|
||||
assert!(matter_mapping(EntityKind::BreathingRate).is_none());
|
||||
assert!(matter_mapping(EntityKind::PoseKeypoints).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rssi_and_motion_continuous_are_mqtt_only() {
|
||||
// No standard cluster represents signal strength or continuous
|
||||
// motion-level for a non-light device.
|
||||
assert!(matter_mapping(EntityKind::Rssi).is_none());
|
||||
assert!(matter_mapping(EntityKind::MotionLevel).is_none());
|
||||
assert!(matter_mapping(EntityKind::MotionEnergy).is_none());
|
||||
assert!(matter_mapping(EntityKind::PresenceScore).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_endpoint_is_deterministic_and_overflow_safe() {
|
||||
assert_eq!(next_endpoint(2, 0), 2);
|
||||
assert_eq!(next_endpoint(2, 5), 7);
|
||||
// Saturation on overflow rather than panic.
|
||||
assert_eq!(next_endpoint(u16::MAX, 1), u16::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_on_matter_is_consistent_with_matter_mapping_some() {
|
||||
for e in [
|
||||
EntityKind::Presence,
|
||||
EntityKind::FallDetected,
|
||||
EntityKind::SomeoneSleeping,
|
||||
EntityKind::HeartRate,
|
||||
EntityKind::Rssi,
|
||||
] {
|
||||
assert_eq!(entity_on_matter(e), matter_mapping(e).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_entities_exhaustive_classification() {
|
||||
// Spot-check that every EntityKind variant has a defined
|
||||
// status — either a mapping or an explicit None — so a future
|
||||
// addition can't silently miss the Matter table.
|
||||
let known = [
|
||||
EntityKind::Presence,
|
||||
EntityKind::PersonCount,
|
||||
EntityKind::BreathingRate,
|
||||
EntityKind::HeartRate,
|
||||
EntityKind::MotionLevel,
|
||||
EntityKind::MotionEnergy,
|
||||
EntityKind::FallDetected,
|
||||
EntityKind::PresenceScore,
|
||||
EntityKind::Rssi,
|
||||
EntityKind::ZoneOccupancy,
|
||||
EntityKind::PoseKeypoints,
|
||||
EntityKind::SomeoneSleeping,
|
||||
EntityKind::PossibleDistress,
|
||||
EntityKind::RoomActive,
|
||||
EntityKind::ElderlyInactivityAnomaly,
|
||||
EntityKind::MeetingInProgress,
|
||||
EntityKind::BathroomOccupied,
|
||||
EntityKind::FallRiskElevated,
|
||||
EntityKind::BedExit,
|
||||
EntityKind::NoMovement,
|
||||
EntityKind::MultiRoomTransition,
|
||||
];
|
||||
// Hit every variant — this acts as a compile-time exhaustiveness
|
||||
// canary: any new EntityKind added without updating
|
||||
// `matter_mapping` will fail to match here.
|
||||
for e in known {
|
||||
let _ = matter_mapping(e); // doesn't panic
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_ids_match_matter_spec_1_3() {
|
||||
// Sanity-check the cluster IDs against the published spec
|
||||
// values — catches a transcription typo.
|
||||
assert_eq!(CLUSTER_OCCUPANCY_SENSING, 0x0406);
|
||||
assert_eq!(CLUSTER_SWITCH, 0x003B);
|
||||
assert_eq!(CLUSTER_BOOLEAN_STATE, 0x0045);
|
||||
assert_eq!(CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION, 0x0039);
|
||||
assert_eq!(DEVICE_TYPE_OCCUPANCY_SENSOR, 0x0107);
|
||||
assert_eq!(DEVICE_TYPE_GENERIC_SWITCH, 0x000F);
|
||||
assert_eq!(DEVICE_TYPE_AGGREGATOR, 0x000E);
|
||||
assert_eq!(DEVICE_TYPE_BRIDGED_NODE, 0x0013);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Matter commissioning code generation (ADR-115 §3.11.2).
|
||||
//!
|
||||
//! When `--matter` is enabled, the publisher prints a setup code on
|
||||
//! first start that the user scans/enters into their Matter controller
|
||||
//! (Apple Home / Google Home / HA Matter integration). This module
|
||||
//! generates that code without depending on any Matter SDK.
|
||||
//!
|
||||
//! ## Spec
|
||||
//!
|
||||
//! Matter Core Spec 1.3 §5.1 defines two pairing-code formats:
|
||||
//!
|
||||
//! - **Manual pairing code** — 11 digits, base-10 encoded from packed
|
||||
//! bits. This is what we emit for `--matter-setup-file`.
|
||||
//! - **QR code payload** — `MT:` prefix + base-38 of a longer
|
||||
//! bit-packed payload. v0.7.0 emits the manual code only; QR string
|
||||
//! generation is a v0.7.1 follow-up (per §9.9 dev-VID note —
|
||||
//! commissioning works in either form with dev VID).
|
||||
//!
|
||||
//! ## Bit layout (manual code, §5.1.4.1)
|
||||
//!
|
||||
//! ```text
|
||||
//! bits width meaning
|
||||
//! ---- ------- -------------------------------------------------------
|
||||
//! 0 1 Version (always 0 today)
|
||||
//! 1 1 VID/PID present flag (0 = short code, 1 = with VID/PID)
|
||||
//! 2 10 Discriminator (12-bit overall, low 4 bits go elsewhere)
|
||||
//! 12 27 Passcode (27-bit setup PIN, range 0..2^27)
|
||||
//! 39 4 Discriminator (high 4 bits)
|
||||
//! 43 9 Reserved / VID-PID stitched in v0 = 0
|
||||
//! ```
|
||||
//!
|
||||
//! The bit-packed payload is then base-10 encoded and prefixed with
|
||||
//! the Luhn-style check digit.
|
||||
|
||||
use super::super::matter::clusters::VENDOR_ATTR_PERSON_COUNT as _; // re-export-only guard
|
||||
|
||||
/// Inputs to setup-code generation. `passcode` and `discriminator`
|
||||
/// are usually random at first start and persisted in the
|
||||
/// `--matter-setup-file` so the same code re-prints next boot.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SetupCodeInput {
|
||||
/// 27-bit Matter setup PIN. Must be in the range `0..2^27`
|
||||
/// excluding the disallowed values listed in §5.1.6.1 (00000000,
|
||||
/// 11111111, 22222222, …, 99999999, 12345678, 87654321).
|
||||
pub passcode: u32,
|
||||
/// 12-bit discriminator advertised in mDNS so controllers find the
|
||||
/// device. Must be in `0..4096`.
|
||||
pub discriminator: u16,
|
||||
/// CSA-assigned vendor ID. Today we use dev VID `0xFFF1` per
|
||||
/// ADR-115 §9.9 until P10 cert decision.
|
||||
pub vendor_id: u16,
|
||||
/// Vendor-assigned product ID. Default `0x8001` per the same ADR row.
|
||||
pub product_id: u16,
|
||||
}
|
||||
|
||||
impl SetupCodeInput {
|
||||
/// Build with the production-default dev VID + sensible product ID.
|
||||
/// `passcode` and `discriminator` come from a CSPRNG at first start.
|
||||
pub fn dev(passcode: u32, discriminator: u16) -> Self {
|
||||
Self { passcode, discriminator, vendor_id: 0xFFF1, product_id: 0x8001 }
|
||||
}
|
||||
|
||||
/// Validate against §5.1.6.1 disallowed values + bit-width ranges.
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.passcode == 0
|
||||
|| self.passcode == 11111111
|
||||
|| self.passcode == 22222222
|
||||
|| self.passcode == 33333333
|
||||
|| self.passcode == 44444444
|
||||
|| self.passcode == 55555555
|
||||
|| self.passcode == 66666666
|
||||
|| self.passcode == 77777777
|
||||
|| self.passcode == 88888888
|
||||
|| self.passcode == 99999999
|
||||
|| self.passcode == 12345678
|
||||
|| self.passcode == 87654321
|
||||
{
|
||||
return Err("passcode is in the §5.1.6.1 disallowed-values list");
|
||||
}
|
||||
if self.passcode >= 1 << 27 {
|
||||
return Err("passcode exceeds 27-bit range");
|
||||
}
|
||||
if self.discriminator >= 1 << 12 {
|
||||
return Err("discriminator exceeds 12-bit range");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The 11-digit manual pairing code as a fixed-length string. Always
|
||||
/// 11 digits because the Matter spec specifies fixed-width encoding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManualPairingCode(pub String);
|
||||
|
||||
impl ManualPairingCode {
|
||||
/// Build the 11-digit short code (§5.1.4.1, VID/PID-absent variant).
|
||||
/// Returns the code as a `String` so the caller can `Display`-print
|
||||
/// it directly. Validates the input first.
|
||||
pub fn from_input(input: &SetupCodeInput) -> Result<Self, &'static str> {
|
||||
input.validate()?;
|
||||
|
||||
// §5.1.4.1 — 10-digit short code = 1-digit header (encodes
|
||||
// version + VID/PID flag + discriminator high 2 bits) +
|
||||
// 5-digit middle (low passcode + low discriminator bits) +
|
||||
// 4-digit trailer (high passcode bits). Plus 1-digit Verhoeff
|
||||
// check digit = 11 total.
|
||||
//
|
||||
// The numeric chunks are sized to fit their decimal widths
|
||||
// exactly (max value < 10^width), so the format! macro
|
||||
// produces fixed-width output without truncation.
|
||||
//
|
||||
// This is a placeholder implementation: it produces a
|
||||
// deterministic, validated, 11-digit string suitable for
|
||||
// human display + Verhoeff-check round-trip. The bit-perfect
|
||||
// spec-compliant code (with QR base-38 payload) is generated
|
||||
// by the Matter SDK at P8 once `rs-matter` lands.
|
||||
let disc = input.discriminator as u32;
|
||||
let pin = input.passcode;
|
||||
|
||||
// Bit layout (placeholder — see header comment):
|
||||
// header = disc_high_2_bits → 1 digit (0..3)
|
||||
// chunk1 = (disc_low_10 << 14) | pin_low_14 → 24 bits, take mod 10^5
|
||||
// chunk2 = pin_high_13 → 13 bits, take mod 10^4
|
||||
//
|
||||
// The mod-by-10^width step is what differs from a fully
|
||||
// spec-conformant encoder — but it preserves determinism and
|
||||
// input sensitivity, which is what we need until P8 SDK.
|
||||
let header = ((disc >> 10) & 0x3) as u64;
|
||||
let chunk1_raw = ((pin & 0x3FFF) as u64) | (((disc & 0x3FF) as u64) << 14);
|
||||
let chunk1 = chunk1_raw % 100_000;
|
||||
let chunk2_raw = ((pin >> 14) & 0x1FFF) as u64;
|
||||
let chunk2 = chunk2_raw % 10_000;
|
||||
|
||||
let body = format!("{:01}{:05}{:04}", header, chunk1, chunk2);
|
||||
debug_assert_eq!(body.len(), 10, "body must be 10 digits — fix chunk widths");
|
||||
|
||||
let check = verhoeff_check_digit(&body);
|
||||
Ok(Self(format!("{}{}", body, check)))
|
||||
}
|
||||
|
||||
/// 4-3-4 dash format the way Matter controllers actually display
|
||||
/// it (e.g. `1234-567-8901`). Used for human readability in
|
||||
/// `--matter-setup-file` and console logs.
|
||||
pub fn display_4_3_4(&self) -> String {
|
||||
let s = &self.0;
|
||||
format!("{}-{}-{}", &s[0..4], &s[4..7], &s[7..11])
|
||||
}
|
||||
}
|
||||
|
||||
/// Verhoeff check-digit algorithm per Matter Core §5.1.4.1.5 (the
|
||||
/// spec doesn't mandate Verhoeff specifically, but several controllers
|
||||
/// expect the published reference impl behaviour. We follow §5.1.4.1
|
||||
/// "decimal check digit using Verhoeff scheme".)
|
||||
fn verhoeff_check_digit(s: &str) -> u8 {
|
||||
const D: [[u8; 10]; 10] = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
||||
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
||||
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
||||
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
||||
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
||||
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
||||
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
||||
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
||||
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
||||
];
|
||||
const P: [[u8; 10]; 8] = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
||||
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
||||
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
||||
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
||||
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
||||
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
||||
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
||||
];
|
||||
const INV: [u8; 10] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
|
||||
|
||||
let mut c = 0u8;
|
||||
for (i, ch) in s.chars().rev().enumerate() {
|
||||
let n = ch.to_digit(10).expect("non-digit in code body") as u8;
|
||||
c = D[c as usize][P[(i + 1) % 8][n as usize] as usize];
|
||||
}
|
||||
INV[c as usize]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dev_constructor_uses_dev_vid_pid() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
assert_eq!(s.vendor_id, 0xFFF1);
|
||||
assert_eq!(s.product_id, 0x8001);
|
||||
assert_eq!(s.passcode, 20202021);
|
||||
assert_eq!(s.discriminator, 3840);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_disallowed_passcodes() {
|
||||
for &bad in &[
|
||||
0u32, 11111111, 22222222, 33333333, 44444444, 55555555,
|
||||
66666666, 77777777, 88888888, 99999999, 12345678, 87654321,
|
||||
] {
|
||||
let s = SetupCodeInput::dev(bad, 100);
|
||||
assert!(s.validate().is_err(), "passcode {} must be rejected", bad);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_oversized_passcode() {
|
||||
let s = SetupCodeInput::dev(1 << 27, 100);
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_oversized_discriminator() {
|
||||
let s = SetupCodeInput::dev(20202021, 4096);
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_canonical_test_vectors() {
|
||||
// Common test values seen across Matter test suites.
|
||||
for (pin, disc) in &[(20202021u32, 3840u16), (12345678 + 1, 100), (1, 0)] {
|
||||
let s = SetupCodeInput::dev(*pin, *disc);
|
||||
assert!(s.validate().is_ok(), "({}, {}) should validate", pin, disc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_is_11_digits() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let code = ManualPairingCode::from_input(&s).unwrap();
|
||||
assert_eq!(code.0.len(), 11);
|
||||
assert!(code.0.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_display_format_is_4_3_4() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let code = ManualPairingCode::from_input(&s).unwrap();
|
||||
let pretty = code.display_4_3_4();
|
||||
// 4-3-4 + 2 dashes = 13 chars.
|
||||
assert_eq!(pretty.len(), 13);
|
||||
assert_eq!(&pretty[4..5], "-");
|
||||
assert_eq!(&pretty[8..9], "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_is_deterministic_for_same_input() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let a = ManualPairingCode::from_input(&s).unwrap();
|
||||
let b = ManualPairingCode::from_input(&s).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_differs_when_passcode_changes() {
|
||||
let a = ManualPairingCode::from_input(&SetupCodeInput::dev(20202021, 3840))
|
||||
.unwrap();
|
||||
let b = ManualPairingCode::from_input(&SetupCodeInput::dev(20202022, 3840))
|
||||
.unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_differs_when_discriminator_changes() {
|
||||
let a = ManualPairingCode::from_input(&SetupCodeInput::dev(20202021, 3840))
|
||||
.unwrap();
|
||||
let b = ManualPairingCode::from_input(&SetupCodeInput::dev(20202021, 100))
|
||||
.unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verhoeff_check_digit_is_self_consistent() {
|
||||
// The Verhoeff scheme has the property that appending the
|
||||
// check digit to the body produces a string with check-digit-
|
||||
// appended == 0. Verify the recursive property holds.
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let code = ManualPairingCode::from_input(&s).unwrap();
|
||||
// Re-verify: the check digit appended to the body should make
|
||||
// the Verhoeff sum collapse to 0.
|
||||
let body = &code.0[0..10];
|
||||
let check_recomputed = verhoeff_check_digit(body);
|
||||
let body_digit = code.0[10..11].parse::<u8>().unwrap();
|
||||
assert_eq!(check_recomputed, body_digit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_input_rejects_invalid_input() {
|
||||
// Build with a disallowed passcode; from_input must return Err.
|
||||
let s = SetupCodeInput::dev(11111111, 3840);
|
||||
assert!(ManualPairingCode::from_input(&s).is_err());
|
||||
}
|
||||
|
||||
// ─── Property-based invariants for the commissioning encoder ─────
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// The §5.1.6.1 disallowed-passcodes set, hoisted to a const for
|
||||
/// reuse in property tests.
|
||||
const DISALLOWED_PASSCODES: &[u32] = &[
|
||||
0u32, 11111111, 22222222, 33333333, 44444444, 55555555,
|
||||
66666666, 77777777, 88888888, 99999999, 12345678, 87654321,
|
||||
];
|
||||
|
||||
proptest! {
|
||||
/// For ANY (passcode, discriminator) in the valid range that
|
||||
/// is not in the §5.1.6.1 disallowed set, from_input MUST
|
||||
/// produce a code with the same shape:
|
||||
/// - exactly 11 ASCII digits
|
||||
/// - Verhoeff-self-consistent
|
||||
/// - 4-3-4 display form is 13 chars with dashes at positions 4 and 8
|
||||
#[test]
|
||||
fn manual_code_shape_invariants(
|
||||
passcode in 1u32..((1 << 27) - 1),
|
||||
disc in 0u16..4095,
|
||||
) {
|
||||
// Reject the disallowed-by-spec set inside the proptest body
|
||||
// so the input strategy stays simple.
|
||||
prop_assume!(!DISALLOWED_PASSCODES.contains(&passcode));
|
||||
|
||||
let s = SetupCodeInput::dev(passcode, disc);
|
||||
let code = ManualPairingCode::from_input(&s);
|
||||
prop_assert!(code.is_ok(), "valid input rejected: {:?}", code.err());
|
||||
let code = code.unwrap();
|
||||
|
||||
// 11 ASCII digits.
|
||||
prop_assert_eq!(code.0.len(), 11);
|
||||
prop_assert!(code.0.chars().all(|c| c.is_ascii_digit()));
|
||||
|
||||
// Verhoeff self-consistency.
|
||||
let body = &code.0[0..10];
|
||||
let body_digit = code.0[10..11].parse::<u8>().unwrap();
|
||||
prop_assert_eq!(verhoeff_check_digit(body), body_digit);
|
||||
|
||||
// 4-3-4 form.
|
||||
let pretty = code.display_4_3_4();
|
||||
prop_assert_eq!(pretty.len(), 13);
|
||||
prop_assert_eq!(&pretty[4..5], "-");
|
||||
prop_assert_eq!(&pretty[8..9], "-");
|
||||
}
|
||||
|
||||
/// Every disallowed passcode in the §5.1.6.1 list MUST be
|
||||
/// rejected by validate(), regardless of discriminator.
|
||||
#[test]
|
||||
fn disallowed_passcodes_always_rejected(
|
||||
disc in 0u16..4095,
|
||||
bad_idx in 0usize..DISALLOWED_PASSCODES.len(),
|
||||
) {
|
||||
let bad = DISALLOWED_PASSCODES[bad_idx];
|
||||
let s = SetupCodeInput::dev(bad, disc);
|
||||
prop_assert!(s.validate().is_err(), "passcode {} must be rejected", bad);
|
||||
}
|
||||
|
||||
/// Oversized inputs always rejected, regardless of the
|
||||
/// allowed dim.
|
||||
#[test]
|
||||
fn oversized_inputs_always_rejected(
|
||||
big_pin in (1u32 << 27)..u32::MAX,
|
||||
big_disc in 4096u16..,
|
||||
) {
|
||||
prop_assert!(SetupCodeInput::dev(big_pin, 100).validate().is_err());
|
||||
prop_assert!(SetupCodeInput::dev(20202021, big_disc).validate().is_err());
|
||||
}
|
||||
|
||||
/// Same input → same code (determinism property under random sampling).
|
||||
#[test]
|
||||
fn manual_code_deterministic_under_random_input(
|
||||
passcode in 1u32..((1 << 27) - 1),
|
||||
disc in 0u16..4095,
|
||||
) {
|
||||
prop_assume!(!DISALLOWED_PASSCODES.contains(&passcode));
|
||||
let s = SetupCodeInput::dev(passcode, disc);
|
||||
let a = ManualPairingCode::from_input(&s).unwrap();
|
||||
let b = ManualPairingCode::from_input(&s).unwrap();
|
||||
prop_assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! ADR-115 §3.11 — Matter Bridge (HA-FABRIC) scaffolding.
|
||||
//!
|
||||
//! This module owns the **Matter device-type and cluster mappings**
|
||||
//! independent of any specific Matter SDK. Pure types + lookup tables
|
||||
//! land here in v0.7.0; the actual SDK wiring (rs-matter or chip-tool
|
||||
//! FFI per §9.10) lands in P7 → P8 in v0.7.1 once the SDK choice is
|
||||
//! validated by a pairing spike against Apple Home / Google Home / HA.
|
||||
//!
|
||||
//! ## Why scaffolding-first
|
||||
//!
|
||||
//! 1. **Decision principle** (maintainer ACK §9): preserve clean
|
||||
//! protocols, avoid fake semantics, ship MQTT first, validate Matter
|
||||
//! second. This module defines what Matter *would* expose without
|
||||
//! committing to an SDK.
|
||||
//! 2. **Reusability**. The mapping table is the same regardless of SDK
|
||||
//! choice — rs-matter and chip-tool both speak in cluster IDs +
|
||||
//! attribute IDs. Defining it here means the SDK swap (if needed
|
||||
//! at P7) is local.
|
||||
//! 3. **Testability**. Cluster / attribute / event IDs are well-known
|
||||
//! integers in the Matter spec; we can validate the mapping against
|
||||
//! the spec without a live controller.
|
||||
//!
|
||||
//! ## Spec versions tracked
|
||||
//!
|
||||
//! - **Matter Core Spec 1.3** (CSA, 2024) — the surface this module
|
||||
//! targets. ID values below match §1.3 §A.1 Reserved Cluster IDs.
|
||||
//!
|
||||
//! Future Matter spec revisions that add biometric clusters (HR / BR)
|
||||
//! would expand `EntityKind::matter_mapping` to cover them. Today HR /
|
||||
//! BR have no Matter cluster and stay MQTT-only.
|
||||
|
||||
mod bridge;
|
||||
mod clusters;
|
||||
mod commissioning;
|
||||
|
||||
pub use bridge::{build_bridge_tree, BridgeTree, Endpoint, EndpointRef, NodeBranch};
|
||||
pub use clusters::{
|
||||
matter_mapping, ClusterId, EndpointTypeId, MatterClusterMapping,
|
||||
};
|
||||
pub use commissioning::{ManualPairingCode, SetupCodeInput};
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Runtime configuration for the MQTT publisher, built from CLI args.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// All knobs the MQTT publisher needs. Built by [`MqttConfig::from_args`]
|
||||
/// after [`crate::cli::Args`] parsing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MqttConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub client_id: String,
|
||||
pub discovery_prefix: String,
|
||||
pub tls: TlsConfig,
|
||||
pub refresh_secs: u64,
|
||||
pub rates: PublishRates,
|
||||
pub publish_pose: bool,
|
||||
pub privacy_mode: bool,
|
||||
}
|
||||
|
||||
/// TLS settings for the MQTT publisher.
|
||||
///
|
||||
/// `None` means plaintext. `Some(TlsBundle::SystemTrust)` means encrypt
|
||||
/// against the system trust store. `Some(TlsBundle::PinnedCa { ... })`
|
||||
/// means encrypt against a specific CA (the typical Cognitum Seed mTLS
|
||||
/// recipe).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TlsConfig {
|
||||
Off,
|
||||
SystemTrust,
|
||||
PinnedCa { ca_file: PathBuf },
|
||||
MutualTls { ca_file: PathBuf, client_cert: PathBuf, client_key: PathBuf },
|
||||
}
|
||||
|
||||
/// Per-entity publish rates (Hz). Zero means "publish on change only".
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PublishRates {
|
||||
pub vitals_hz: f64,
|
||||
pub motion_hz: f64,
|
||||
pub count_hz: f64,
|
||||
pub rssi_hz: f64,
|
||||
pub pose_hz: f64,
|
||||
}
|
||||
|
||||
impl Default for PublishRates {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vitals_hz: 0.2,
|
||||
motion_hz: 1.0,
|
||||
count_hz: 1.0,
|
||||
rssi_hz: 0.1,
|
||||
pose_hz: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MqttConfig {
|
||||
/// Build an [`MqttConfig`] from parsed [`crate::cli::Args`].
|
||||
///
|
||||
/// Reads `mqtt_password_env` to resolve the broker password from the
|
||||
/// environment so secrets never appear on the command line. Reads
|
||||
/// `hostname()` via the `gethostname` crate if `mqtt_client_id` was
|
||||
/// not supplied — we don't add a dep here, we let the publisher
|
||||
/// supply the default lazily.
|
||||
pub fn from_args(args: &crate::cli::Args) -> Self {
|
||||
let password = std::env::var(&args.mqtt_password_env).ok();
|
||||
let port = args.mqtt_port.unwrap_or(if args.mqtt_tls { 8883 } else { 1883 });
|
||||
let tls = build_tls(args);
|
||||
let client_id = args
|
||||
.mqtt_client_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
// Avoid a `gethostname` dep in P1 — fallback only.
|
||||
format!("wifi-densepose-{}", std::process::id())
|
||||
});
|
||||
|
||||
Self {
|
||||
host: args.mqtt_host.clone(),
|
||||
port,
|
||||
username: args.mqtt_username.clone(),
|
||||
password,
|
||||
client_id,
|
||||
discovery_prefix: args.mqtt_prefix.clone(),
|
||||
tls,
|
||||
refresh_secs: args.mqtt_refresh_secs,
|
||||
rates: PublishRates {
|
||||
vitals_hz: args.mqtt_rate_vitals,
|
||||
motion_hz: args.mqtt_rate_motion,
|
||||
count_hz: args.mqtt_rate_count,
|
||||
rssi_hz: args.mqtt_rate_rssi,
|
||||
pose_hz: args.mqtt_rate_pose,
|
||||
},
|
||||
publish_pose: args.mqtt_publish_pose,
|
||||
privacy_mode: args.privacy_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this config is safe to start. Pre-flight validation that
|
||||
/// runs before any network I/O so users get a clean error instead of
|
||||
/// a connect failure 30 s later.
|
||||
pub fn validate(&self) -> Result<(), MqttConfigError> {
|
||||
if self.host.is_empty() {
|
||||
return Err(MqttConfigError::EmptyHost);
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err(MqttConfigError::InvalidPort(self.port));
|
||||
}
|
||||
if self.refresh_secs == 0 {
|
||||
return Err(MqttConfigError::RefreshTooSmall);
|
||||
}
|
||||
for rate in [
|
||||
self.rates.vitals_hz,
|
||||
self.rates.motion_hz,
|
||||
self.rates.count_hz,
|
||||
self.rates.rssi_hz,
|
||||
self.rates.pose_hz,
|
||||
] {
|
||||
if !rate.is_finite() || rate < 0.0 {
|
||||
return Err(MqttConfigError::InvalidRate(rate));
|
||||
}
|
||||
}
|
||||
if !self.host.eq_ignore_ascii_case("localhost")
|
||||
&& !self.host.starts_with("127.")
|
||||
&& !self.host.starts_with("::1")
|
||||
&& matches!(self.tls, TlsConfig::Off)
|
||||
{
|
||||
// Per ADR-115 §3.9 / §9.5 — WARN now, hard-fail at v0.8.0.
|
||||
// We return a non-fatal advisory; the caller decides.
|
||||
return Err(MqttConfigError::PlaintextOnPublicHost {
|
||||
host: self.host.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tls(args: &crate::cli::Args) -> TlsConfig {
|
||||
if !args.mqtt_tls {
|
||||
return TlsConfig::Off;
|
||||
}
|
||||
match (
|
||||
args.mqtt_ca_file.as_ref(),
|
||||
args.mqtt_client_cert.as_ref(),
|
||||
args.mqtt_client_key.as_ref(),
|
||||
) {
|
||||
(Some(ca), Some(cert), Some(key)) => TlsConfig::MutualTls {
|
||||
ca_file: ca.clone(),
|
||||
client_cert: cert.clone(),
|
||||
client_key: key.clone(),
|
||||
},
|
||||
(Some(ca), None, None) => TlsConfig::PinnedCa { ca_file: ca.clone() },
|
||||
_ => TlsConfig::SystemTrust,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-flight validation errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MqttConfigError {
|
||||
#[error("MQTT broker host is empty")]
|
||||
EmptyHost,
|
||||
#[error("invalid MQTT broker port: {0}")]
|
||||
InvalidPort(u16),
|
||||
#[error("--mqtt-refresh-secs must be >= 1")]
|
||||
RefreshTooSmall,
|
||||
#[error("invalid MQTT publish rate: {0} Hz")]
|
||||
InvalidRate(f64),
|
||||
#[error(
|
||||
"plaintext MQTT on non-localhost broker {host} is deprecated and will hard-fail in v0.8.0 \
|
||||
(ADR-115 §3.9). Add --mqtt-tls to encrypt."
|
||||
)]
|
||||
PlaintextOnPublicHost { host: String },
|
||||
}
|
||||
|
||||
impl MqttConfigError {
|
||||
/// True for errors that block startup. False for advisories the user
|
||||
/// can override (used for the v0.7.0 → v0.8.0 deprecation curve on
|
||||
/// plaintext).
|
||||
pub fn is_fatal(&self) -> bool {
|
||||
!matches!(self, MqttConfigError::PlaintextOnPublicHost { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn parse(args: &[&str]) -> crate::cli::Args {
|
||||
crate::cli::Args::parse_from(std::iter::once("sensing-server").chain(args.iter().copied()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_args_defaults_localhost_1883() {
|
||||
let cfg = MqttConfig::from_args(&parse(&[]));
|
||||
assert_eq!(cfg.host, "localhost");
|
||||
assert_eq!(cfg.port, 1883);
|
||||
assert_eq!(cfg.discovery_prefix, "homeassistant");
|
||||
assert!(matches!(cfg.tls, TlsConfig::Off));
|
||||
assert_eq!(cfg.refresh_secs, 600);
|
||||
assert_eq!(cfg.rates.vitals_hz, 0.2);
|
||||
assert!(!cfg.publish_pose);
|
||||
assert!(!cfg.privacy_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_flag_bumps_port_to_8883() {
|
||||
let cfg = MqttConfig::from_args(&parse(&["--mqtt-tls"]));
|
||||
assert_eq!(cfg.port, 8883);
|
||||
assert!(matches!(cfg.tls, TlsConfig::SystemTrust));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_port_overrides_default() {
|
||||
let cfg = MqttConfig::from_args(&parse(&["--mqtt-port", "8884"]));
|
||||
assert_eq!(cfg.port, 8884);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtls_when_full_triplet_supplied() {
|
||||
let cfg = MqttConfig::from_args(&parse(&[
|
||||
"--mqtt-tls",
|
||||
"--mqtt-ca-file", "/etc/ca.pem",
|
||||
"--mqtt-client-cert", "/etc/client.pem",
|
||||
"--mqtt-client-key", "/etc/client.key",
|
||||
]));
|
||||
assert!(matches!(cfg.tls, TlsConfig::MutualTls { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_empty_host() {
|
||||
let mut cfg = MqttConfig::from_args(&parse(&[]));
|
||||
cfg.host = String::new();
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(matches!(err, MqttConfigError::EmptyHost));
|
||||
assert!(err.is_fatal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_zero_port() {
|
||||
let mut cfg = MqttConfig::from_args(&parse(&[]));
|
||||
cfg.port = 0;
|
||||
assert!(matches!(cfg.validate(), Err(MqttConfigError::InvalidPort(0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_localhost_plaintext_ok() {
|
||||
let cfg = MqttConfig::from_args(&parse(&[]));
|
||||
// localhost + plaintext is fine — no advisory.
|
||||
assert!(cfg.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_plaintext_public_advises_but_not_fatal() {
|
||||
let cfg = MqttConfig::from_args(&parse(&["--mqtt-host", "broker.example.com"]));
|
||||
let err = cfg.validate().unwrap_err();
|
||||
assert!(matches!(err, MqttConfigError::PlaintextOnPublicHost { .. }));
|
||||
assert!(!err.is_fatal(), "v0.7.0 should warn, not block (ADR-115 §3.9)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_public_tls_ok() {
|
||||
let cfg = MqttConfig::from_args(&parse(&[
|
||||
"--mqtt-host", "broker.example.com",
|
||||
"--mqtt-tls",
|
||||
]));
|
||||
assert!(cfg.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_negative_rate() {
|
||||
let mut cfg = MqttConfig::from_args(&parse(&[]));
|
||||
cfg.rates.vitals_hz = -1.0;
|
||||
assert!(matches!(cfg.validate(), Err(MqttConfigError::InvalidRate(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_nan_rate() {
|
||||
let mut cfg = MqttConfig::from_args(&parse(&[]));
|
||||
cfg.rates.motion_hz = f64::NAN;
|
||||
assert!(matches!(cfg.validate(), Err(MqttConfigError::InvalidRate(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_env_resolution() {
|
||||
std::env::set_var("RUVIEW_TEST_MQTT_PW", "s3cret");
|
||||
let cfg = MqttConfig::from_args(&parse(&[
|
||||
"--mqtt-password-env", "RUVIEW_TEST_MQTT_PW",
|
||||
]));
|
||||
assert_eq!(cfg.password.as_deref(), Some("s3cret"));
|
||||
std::env::remove_var("RUVIEW_TEST_MQTT_PW");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
//! HA MQTT auto-discovery payload generators.
|
||||
//!
|
||||
//! Per ADR-115 §3.1 — §3.4 each RuView node becomes one HA `device` and
|
||||
//! each capability (presence, person count, heart rate, breathing rate,
|
||||
//! motion, fall, RSSI, zone occupancy, pose) becomes one entity on that
|
||||
//! device. This module owns the JSON-serializable structures HA expects
|
||||
//! on the `homeassistant/<component>/<object_id>/<entity>/config` topic.
|
||||
//!
|
||||
//! The structures are `Serialize`-only; we never need to parse them
|
||||
//! back. Field names match Home Assistant's published MQTT-discovery
|
||||
//! schema (https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
||||
//! pinned to the version the project tests against (v2025.5 as of this
|
||||
//! ADR; bump in `docs/integrations/home-assistant.md` when the test
|
||||
//! matrix moves).
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{MANUFACTURER, ORIGIN_NAME, SUPPORT_URL};
|
||||
|
||||
/// HA component kinds we publish today. Strings match the HA URL slug.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiscoveryComponent {
|
||||
BinarySensor,
|
||||
Sensor,
|
||||
Event,
|
||||
}
|
||||
|
||||
impl DiscoveryComponent {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
DiscoveryComponent::BinarySensor => "binary_sensor",
|
||||
DiscoveryComponent::Sensor => "sensor",
|
||||
DiscoveryComponent::Event => "event",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level HA discovery payload. Serialised to JSON and published
|
||||
/// retained, QoS 1 on `<prefix>/<component>/<object_id>/<entity>/config`.
|
||||
///
|
||||
/// We only model the fields ADR-115 §3.3 examples touch. HA's schema has
|
||||
/// many more optional fields; we add them on a per-entity-need basis to
|
||||
/// keep payloads small (some retained brokers cap message size).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiscoveryConfig {
|
||||
pub name: String,
|
||||
pub unique_id: String,
|
||||
pub object_id: String,
|
||||
pub state_topic: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub availability_topic: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload_available: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload_not_available: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload_on: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload_off: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_class: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_class: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unit_of_measurement: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_template: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub json_attributes_topic: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_types: Option<Vec<String>>,
|
||||
pub qos: u8,
|
||||
pub device: DeviceMeta,
|
||||
pub origin: OriginMeta,
|
||||
}
|
||||
|
||||
/// HA `device` block. Multiple entities pointing at the same
|
||||
/// `identifiers` are grouped into one device card in the HA UI.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeviceMeta {
|
||||
pub identifiers: Vec<String>,
|
||||
pub name: String,
|
||||
pub manufacturer: String,
|
||||
pub model: String,
|
||||
pub sw_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub via_device: Option<String>,
|
||||
}
|
||||
|
||||
/// HA `origin` block. Tells HA users which software emitted the entities.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OriginMeta {
|
||||
pub name: String,
|
||||
pub sw_version: String,
|
||||
pub support_url: String,
|
||||
}
|
||||
|
||||
/// Per-entity availability payload. Used as MQTT LWT so the broker
|
||||
/// publishes `offline` automatically if our connection drops.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AvailabilityPayload {
|
||||
pub topic: String,
|
||||
pub online: &'static str,
|
||||
pub offline: &'static str,
|
||||
}
|
||||
|
||||
impl AvailabilityPayload {
|
||||
pub fn for_entity(prefix: &str, component: DiscoveryComponent, node_id: &str, entity: &str) -> Self {
|
||||
Self {
|
||||
topic: format!(
|
||||
"{prefix}/{}/wifi_densepose_{node_id}/{entity}/availability",
|
||||
component.as_str()
|
||||
),
|
||||
online: "online",
|
||||
offline: "offline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All entity kinds RuView publishes via MQTT. Used by [`DiscoveryBuilder`]
|
||||
/// to generate matching `config` and `state` topic strings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EntityKind {
|
||||
Presence,
|
||||
PersonCount,
|
||||
BreathingRate,
|
||||
HeartRate,
|
||||
MotionLevel,
|
||||
MotionEnergy,
|
||||
FallDetected,
|
||||
PresenceScore,
|
||||
Rssi,
|
||||
ZoneOccupancy,
|
||||
PoseKeypoints,
|
||||
// Semantic primitives (ADR-115 §3.12).
|
||||
SomeoneSleeping,
|
||||
PossibleDistress,
|
||||
RoomActive,
|
||||
ElderlyInactivityAnomaly,
|
||||
MeetingInProgress,
|
||||
BathroomOccupied,
|
||||
FallRiskElevated,
|
||||
BedExit,
|
||||
NoMovement,
|
||||
MultiRoomTransition,
|
||||
}
|
||||
|
||||
impl EntityKind {
|
||||
pub fn topic_slug(self) -> &'static str {
|
||||
match self {
|
||||
EntityKind::Presence => "presence",
|
||||
EntityKind::PersonCount => "person_count",
|
||||
EntityKind::BreathingRate => "breathing_rate",
|
||||
EntityKind::HeartRate => "heart_rate",
|
||||
EntityKind::MotionLevel => "motion_level",
|
||||
EntityKind::MotionEnergy => "motion_energy",
|
||||
EntityKind::FallDetected => "fall",
|
||||
EntityKind::PresenceScore => "presence_score",
|
||||
EntityKind::Rssi => "rssi",
|
||||
EntityKind::ZoneOccupancy => "zone_occupancy",
|
||||
EntityKind::PoseKeypoints => "pose",
|
||||
EntityKind::SomeoneSleeping => "someone_sleeping",
|
||||
EntityKind::PossibleDistress => "possible_distress",
|
||||
EntityKind::RoomActive => "room_active",
|
||||
EntityKind::ElderlyInactivityAnomaly => "elderly_inactivity_anomaly",
|
||||
EntityKind::MeetingInProgress => "meeting_in_progress",
|
||||
EntityKind::BathroomOccupied => "bathroom_occupied",
|
||||
EntityKind::FallRiskElevated => "fall_risk_elevated",
|
||||
EntityKind::BedExit => "bed_exit",
|
||||
EntityKind::NoMovement => "no_movement",
|
||||
EntityKind::MultiRoomTransition => "multi_room_transition",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn component(self) -> DiscoveryComponent {
|
||||
match self {
|
||||
// Boolean states → binary_sensor.
|
||||
EntityKind::Presence
|
||||
| EntityKind::ZoneOccupancy
|
||||
| EntityKind::SomeoneSleeping
|
||||
| EntityKind::PossibleDistress
|
||||
| EntityKind::RoomActive
|
||||
| EntityKind::ElderlyInactivityAnomaly
|
||||
| EntityKind::MeetingInProgress
|
||||
| EntityKind::BathroomOccupied
|
||||
| EntityKind::NoMovement => DiscoveryComponent::BinarySensor,
|
||||
// One-shot triggers → event.
|
||||
EntityKind::FallDetected
|
||||
| EntityKind::BedExit
|
||||
| EntityKind::MultiRoomTransition => DiscoveryComponent::Event,
|
||||
// Numeric measurements → sensor.
|
||||
EntityKind::PersonCount
|
||||
| EntityKind::BreathingRate
|
||||
| EntityKind::HeartRate
|
||||
| EntityKind::MotionLevel
|
||||
| EntityKind::MotionEnergy
|
||||
| EntityKind::PresenceScore
|
||||
| EntityKind::Rssi
|
||||
| EntityKind::PoseKeypoints
|
||||
| EntityKind::FallRiskElevated => DiscoveryComponent::Sensor,
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this entity carries biometric data that `--privacy-mode`
|
||||
/// must suppress per ADR-115 §3.10 and §3.12.3. Semantic primitives
|
||||
/// stay published even in privacy mode because they're inferred
|
||||
/// states, not raw values.
|
||||
pub fn is_biometric(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
EntityKind::BreathingRate | EntityKind::HeartRate | EntityKind::PoseKeypoints
|
||||
)
|
||||
}
|
||||
|
||||
/// Human-readable HA entity name shown in the UI.
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
EntityKind::Presence => "Presence",
|
||||
EntityKind::PersonCount => "Person count",
|
||||
EntityKind::BreathingRate => "Breathing rate",
|
||||
EntityKind::HeartRate => "Heart rate",
|
||||
EntityKind::MotionLevel => "Motion level",
|
||||
EntityKind::MotionEnergy => "Motion energy",
|
||||
EntityKind::FallDetected => "Fall detected",
|
||||
EntityKind::PresenceScore => "Presence score",
|
||||
EntityKind::Rssi => "Signal strength",
|
||||
EntityKind::ZoneOccupancy => "Zone occupancy",
|
||||
EntityKind::PoseKeypoints => "Pose",
|
||||
EntityKind::SomeoneSleeping => "Someone sleeping",
|
||||
EntityKind::PossibleDistress => "Possible distress",
|
||||
EntityKind::RoomActive => "Room active",
|
||||
EntityKind::ElderlyInactivityAnomaly => "Elderly inactivity anomaly",
|
||||
EntityKind::MeetingInProgress => "Meeting in progress",
|
||||
EntityKind::BathroomOccupied => "Bathroom occupied",
|
||||
EntityKind::FallRiskElevated => "Fall risk elevated",
|
||||
EntityKind::BedExit => "Bed exit",
|
||||
EntityKind::NoMovement => "No movement",
|
||||
EntityKind::MultiRoomTransition => "Room transition",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds HA discovery payloads for a specific RuView node.
|
||||
pub struct DiscoveryBuilder<'a> {
|
||||
pub discovery_prefix: &'a str,
|
||||
pub node_id: &'a str,
|
||||
pub node_friendly_name: Option<&'a str>,
|
||||
pub sw_version: &'a str,
|
||||
pub model: &'a str,
|
||||
pub via_device: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> DiscoveryBuilder<'a> {
|
||||
fn unique_id(&self, entity: EntityKind) -> String {
|
||||
format!("wifi_densepose_{}_{}", self.node_id, entity.topic_slug())
|
||||
}
|
||||
|
||||
fn state_topic(&self, entity: EntityKind) -> String {
|
||||
format!(
|
||||
"{}/{}/wifi_densepose_{}/{}/state",
|
||||
self.discovery_prefix,
|
||||
entity.component().as_str(),
|
||||
self.node_id,
|
||||
entity.topic_slug(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn config_topic(&self, entity: EntityKind) -> String {
|
||||
format!(
|
||||
"{}/{}/wifi_densepose_{}/{}/config",
|
||||
self.discovery_prefix,
|
||||
entity.component().as_str(),
|
||||
self.node_id,
|
||||
entity.topic_slug(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn availability_topic(&self, entity: EntityKind) -> String {
|
||||
format!(
|
||||
"{}/{}/wifi_densepose_{}/{}/availability",
|
||||
self.discovery_prefix,
|
||||
entity.component().as_str(),
|
||||
self.node_id,
|
||||
entity.topic_slug(),
|
||||
)
|
||||
}
|
||||
|
||||
fn device(&self) -> DeviceMeta {
|
||||
let display = self
|
||||
.node_friendly_name
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("RuView node {}", self.node_id));
|
||||
DeviceMeta {
|
||||
identifiers: vec![format!("wifi_densepose_{}", self.node_id)],
|
||||
name: display,
|
||||
manufacturer: MANUFACTURER.to_string(),
|
||||
model: self.model.to_string(),
|
||||
sw_version: self.sw_version.to_string(),
|
||||
via_device: self.via_device.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn origin(&self) -> OriginMeta {
|
||||
OriginMeta {
|
||||
name: ORIGIN_NAME.to_string(),
|
||||
sw_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
support_url: SUPPORT_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a discovery config payload for one entity on this node.
|
||||
pub fn build(&self, entity: EntityKind) -> DiscoveryConfig {
|
||||
let component = entity.component();
|
||||
let mut cfg = DiscoveryConfig {
|
||||
name: entity.display_name().to_string(),
|
||||
unique_id: self.unique_id(entity),
|
||||
object_id: self.unique_id(entity),
|
||||
state_topic: self.state_topic(entity),
|
||||
availability_topic: Some(self.availability_topic(entity)),
|
||||
payload_available: Some("online".into()),
|
||||
payload_not_available: Some("offline".into()),
|
||||
payload_on: None,
|
||||
payload_off: None,
|
||||
device_class: None,
|
||||
state_class: None,
|
||||
unit_of_measurement: None,
|
||||
icon: None,
|
||||
value_template: None,
|
||||
json_attributes_topic: None,
|
||||
event_types: None,
|
||||
qos: match component {
|
||||
DiscoveryComponent::BinarySensor | DiscoveryComponent::Event => 1,
|
||||
DiscoveryComponent::Sensor => 0,
|
||||
},
|
||||
device: self.device(),
|
||||
origin: self.origin(),
|
||||
};
|
||||
|
||||
match entity {
|
||||
EntityKind::Presence
|
||||
| EntityKind::ZoneOccupancy
|
||||
| EntityKind::SomeoneSleeping
|
||||
| EntityKind::RoomActive
|
||||
| EntityKind::MeetingInProgress
|
||||
| EntityKind::BathroomOccupied => {
|
||||
cfg.payload_on = Some("ON".into());
|
||||
cfg.payload_off = Some("OFF".into());
|
||||
cfg.device_class = Some("occupancy".into());
|
||||
cfg.icon = Some(match entity {
|
||||
EntityKind::SomeoneSleeping => "mdi:sleep",
|
||||
EntityKind::MeetingInProgress => "mdi:account-group",
|
||||
EntityKind::BathroomOccupied => "mdi:shower",
|
||||
EntityKind::RoomActive => "mdi:home-account",
|
||||
EntityKind::ZoneOccupancy => "mdi:map-marker",
|
||||
_ => "mdi:motion-sensor",
|
||||
}.into());
|
||||
}
|
||||
EntityKind::PossibleDistress
|
||||
| EntityKind::ElderlyInactivityAnomaly
|
||||
| EntityKind::NoMovement => {
|
||||
cfg.payload_on = Some("ON".into());
|
||||
cfg.payload_off = Some("OFF".into());
|
||||
cfg.device_class = Some("problem".into());
|
||||
cfg.icon = Some("mdi:alert-octagon".into());
|
||||
}
|
||||
EntityKind::FallDetected => {
|
||||
cfg.event_types = Some(vec!["fall_detected".into()]);
|
||||
cfg.icon = Some("mdi:human-fall".into());
|
||||
}
|
||||
EntityKind::BedExit => {
|
||||
cfg.event_types = Some(vec!["bed_exit".into()]);
|
||||
cfg.icon = Some("mdi:bed-empty".into());
|
||||
}
|
||||
EntityKind::MultiRoomTransition => {
|
||||
cfg.event_types = Some(vec!["transition".into()]);
|
||||
cfg.icon = Some("mdi:transit-transfer".into());
|
||||
}
|
||||
EntityKind::PersonCount => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.unit_of_measurement = Some("persons".into());
|
||||
cfg.icon = Some("mdi:account-group".into());
|
||||
cfg.value_template = Some("{{ value_json.n_persons }}".into());
|
||||
}
|
||||
EntityKind::BreathingRate => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.unit_of_measurement = Some("bpm".into());
|
||||
cfg.icon = Some("mdi:lungs".into());
|
||||
cfg.value_template = Some("{{ value_json.bpm }}".into());
|
||||
cfg.json_attributes_topic = Some(cfg.state_topic.clone());
|
||||
}
|
||||
EntityKind::HeartRate => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.unit_of_measurement = Some("bpm".into());
|
||||
cfg.icon = Some("mdi:heart-pulse".into());
|
||||
cfg.value_template = Some("{{ value_json.bpm }}".into());
|
||||
cfg.json_attributes_topic = Some(cfg.state_topic.clone());
|
||||
}
|
||||
EntityKind::MotionLevel => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.unit_of_measurement = Some("%".into());
|
||||
cfg.icon = Some("mdi:run".into());
|
||||
cfg.value_template = Some("{{ value_json.level_pct }}".into());
|
||||
}
|
||||
EntityKind::MotionEnergy => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.icon = Some("mdi:waveform".into());
|
||||
cfg.value_template = Some("{{ value_json.energy }}".into());
|
||||
}
|
||||
EntityKind::PresenceScore => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.unit_of_measurement = Some("%".into());
|
||||
cfg.icon = Some("mdi:gauge".into());
|
||||
cfg.value_template = Some("{{ value_json.score_pct }}".into());
|
||||
}
|
||||
EntityKind::Rssi => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.device_class = Some("signal_strength".into());
|
||||
cfg.unit_of_measurement = Some("dBm".into());
|
||||
cfg.icon = Some("mdi:wifi".into());
|
||||
cfg.value_template = Some("{{ value_json.dbm }}".into());
|
||||
}
|
||||
EntityKind::PoseKeypoints => {
|
||||
cfg.icon = Some("mdi:human".into());
|
||||
cfg.json_attributes_topic = Some(cfg.state_topic.clone());
|
||||
cfg.value_template = Some("{{ value_json.n_keypoints }}".into());
|
||||
}
|
||||
EntityKind::FallRiskElevated => {
|
||||
cfg.state_class = Some("measurement".into());
|
||||
cfg.unit_of_measurement = Some("score".into());
|
||||
cfg.icon = Some("mdi:human-fall".into());
|
||||
cfg.value_template = Some("{{ value_json.score }}".into());
|
||||
}
|
||||
}
|
||||
|
||||
cfg
|
||||
}
|
||||
|
||||
/// All entity kinds this builder will publish, given a `privacy_mode`
|
||||
/// flag and a `publish_pose` flag. Used by the publisher to drive the
|
||||
/// discovery-emission loop.
|
||||
pub fn enabled_entities(privacy_mode: bool, publish_pose: bool, semantic_disabled: &[String]) -> Vec<EntityKind> {
|
||||
let all = [
|
||||
EntityKind::Presence,
|
||||
EntityKind::PersonCount,
|
||||
EntityKind::BreathingRate,
|
||||
EntityKind::HeartRate,
|
||||
EntityKind::MotionLevel,
|
||||
EntityKind::MotionEnergy,
|
||||
EntityKind::FallDetected,
|
||||
EntityKind::PresenceScore,
|
||||
EntityKind::Rssi,
|
||||
EntityKind::ZoneOccupancy,
|
||||
EntityKind::PoseKeypoints,
|
||||
EntityKind::SomeoneSleeping,
|
||||
EntityKind::PossibleDistress,
|
||||
EntityKind::RoomActive,
|
||||
EntityKind::ElderlyInactivityAnomaly,
|
||||
EntityKind::MeetingInProgress,
|
||||
EntityKind::BathroomOccupied,
|
||||
EntityKind::FallRiskElevated,
|
||||
EntityKind::BedExit,
|
||||
EntityKind::NoMovement,
|
||||
EntityKind::MultiRoomTransition,
|
||||
];
|
||||
|
||||
all.into_iter()
|
||||
.filter(|e| {
|
||||
if privacy_mode && e.is_biometric() {
|
||||
return false;
|
||||
}
|
||||
if *e == EntityKind::PoseKeypoints && !publish_pose {
|
||||
return false;
|
||||
}
|
||||
if let Some(slug) = semantic_slug_for(*e) {
|
||||
if semantic_disabled.iter().any(|d| d == slug) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// For an entity kind, return the `--no-semantic <PRIMITIVE>` slug it
|
||||
/// would be disabled by, or `None` if it's not a semantic primitive.
|
||||
fn semantic_slug_for(e: EntityKind) -> Option<&'static str> {
|
||||
Some(match e {
|
||||
EntityKind::SomeoneSleeping => "sleeping",
|
||||
EntityKind::PossibleDistress => "distress",
|
||||
EntityKind::RoomActive => "room_active",
|
||||
EntityKind::ElderlyInactivityAnomaly => "elderly_anomaly",
|
||||
EntityKind::MeetingInProgress => "meeting",
|
||||
EntityKind::BathroomOccupied => "bathroom",
|
||||
EntityKind::FallRiskElevated => "fall_risk",
|
||||
EntityKind::BedExit => "bed_exit",
|
||||
EntityKind::NoMovement => "no_movement",
|
||||
EntityKind::MultiRoomTransition => "multi_room",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn builder() -> DiscoveryBuilder<'static> {
|
||||
DiscoveryBuilder {
|
||||
discovery_prefix: "homeassistant",
|
||||
node_id: "aabbccddeeff",
|
||||
node_friendly_name: Some("Bedroom"),
|
||||
sw_version: "v0.7.0",
|
||||
model: "ESP32-S3 CSI node",
|
||||
via_device: Some("cognitum_seed_1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_discovery_payload_shape() {
|
||||
let b = builder();
|
||||
let cfg = b.build(EntityKind::Presence);
|
||||
let j: Value = serde_json::to_value(&cfg).unwrap();
|
||||
assert_eq!(j["name"], "Presence");
|
||||
assert_eq!(j["unique_id"], "wifi_densepose_aabbccddeeff_presence");
|
||||
assert_eq!(j["device_class"], "occupancy");
|
||||
assert_eq!(j["payload_on"], "ON");
|
||||
assert_eq!(j["payload_off"], "OFF");
|
||||
assert_eq!(j["qos"], 1);
|
||||
assert_eq!(
|
||||
j["state_topic"],
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state"
|
||||
);
|
||||
assert_eq!(j["device"]["identifiers"][0], "wifi_densepose_aabbccddeeff");
|
||||
assert_eq!(j["device"]["name"], "Bedroom");
|
||||
assert_eq!(j["device"]["via_device"], "cognitum_seed_1");
|
||||
assert_eq!(j["origin"]["name"], "wifi-densepose-sensing-server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heart_rate_discovery_payload_shape() {
|
||||
let b = builder();
|
||||
let cfg = b.build(EntityKind::HeartRate);
|
||||
let j: Value = serde_json::to_value(&cfg).unwrap();
|
||||
assert_eq!(j["unit_of_measurement"], "bpm");
|
||||
assert_eq!(j["state_class"], "measurement");
|
||||
assert_eq!(j["value_template"], "{{ value_json.bpm }}");
|
||||
assert_eq!(j["qos"], 0);
|
||||
assert!(j["json_attributes_topic"].as_str().unwrap().ends_with("/state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_event_payload_uses_event_component_and_types() {
|
||||
let b = builder();
|
||||
let cfg = b.build(EntityKind::FallDetected);
|
||||
let j: Value = serde_json::to_value(&cfg).unwrap();
|
||||
assert!(j["state_topic"].as_str().unwrap().contains("/event/"));
|
||||
assert_eq!(j["event_types"][0], "fall_detected");
|
||||
assert_eq!(j["qos"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_primitive_uses_problem_class_for_distress() {
|
||||
let b = builder();
|
||||
let cfg = b.build(EntityKind::PossibleDistress);
|
||||
let j: Value = serde_json::to_value(&cfg).unwrap();
|
||||
assert_eq!(j["device_class"], "problem");
|
||||
assert_eq!(j["payload_on"], "ON");
|
||||
assert_eq!(j["payload_off"], "OFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_entities_default_excludes_pose_and_includes_all_others() {
|
||||
let entities = DiscoveryBuilder::enabled_entities(false, false, &[]);
|
||||
assert!(!entities.contains(&EntityKind::PoseKeypoints));
|
||||
assert!(entities.contains(&EntityKind::Presence));
|
||||
assert!(entities.contains(&EntityKind::HeartRate));
|
||||
assert!(entities.contains(&EntityKind::SomeoneSleeping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_mode_strips_biometrics() {
|
||||
let entities = DiscoveryBuilder::enabled_entities(true, true, &[]);
|
||||
for e in &entities {
|
||||
assert!(!e.is_biometric(), "biometric {:?} leaked with privacy_mode", e);
|
||||
}
|
||||
// Semantic primitives must remain available (ADR-115 §3.12.3).
|
||||
assert!(entities.contains(&EntityKind::SomeoneSleeping));
|
||||
assert!(entities.contains(&EntityKind::BathroomOccupied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_semantic_disables_specific_primitive() {
|
||||
let disabled = vec!["distress".to_string(), "sleeping".to_string()];
|
||||
let entities = DiscoveryBuilder::enabled_entities(false, false, &disabled);
|
||||
assert!(!entities.contains(&EntityKind::PossibleDistress));
|
||||
assert!(!entities.contains(&EntityKind::SomeoneSleeping));
|
||||
// Raw signals untouched.
|
||||
assert!(entities.contains(&EntityKind::Presence));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_components_match_entity_kind() {
|
||||
// binary_sensor for booleans.
|
||||
assert_eq!(EntityKind::Presence.component(), DiscoveryComponent::BinarySensor);
|
||||
assert_eq!(EntityKind::SomeoneSleeping.component(), DiscoveryComponent::BinarySensor);
|
||||
// event for one-shots.
|
||||
assert_eq!(EntityKind::FallDetected.component(), DiscoveryComponent::Event);
|
||||
assert_eq!(EntityKind::BedExit.component(), DiscoveryComponent::Event);
|
||||
// sensor for measurements.
|
||||
assert_eq!(EntityKind::HeartRate.component(), DiscoveryComponent::Sensor);
|
||||
assert_eq!(EntityKind::Rssi.component(), DiscoveryComponent::Sensor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_config_serialises_without_null_fields() {
|
||||
let b = builder();
|
||||
let cfg = b.build(EntityKind::Presence);
|
||||
let j = serde_json::to_string(&cfg).unwrap();
|
||||
// skip_serializing_if = "Option::is_none" must hide unused fields
|
||||
// so retained payloads stay compact on small brokers.
|
||||
assert!(!j.contains("\"event_types\":null"));
|
||||
assert!(!j.contains("\"unit_of_measurement\":null"));
|
||||
assert!(!j.contains("\"value_template\":null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_topic_matches_state_topic_path() {
|
||||
let b = builder();
|
||||
let state = format!(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state"
|
||||
);
|
||||
let avail = b.availability_topic(EntityKind::Presence);
|
||||
// Must differ only in suffix.
|
||||
assert_eq!(
|
||||
state.trim_end_matches("/state"),
|
||||
avail.trim_end_matches("/availability"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_id_uses_namespaced_node_prefix() {
|
||||
let b = builder();
|
||||
let cfg = b.build(EntityKind::Rssi);
|
||||
assert!(cfg.unique_id.starts_with("wifi_densepose_"));
|
||||
// ADR-115 §7 — namespace prevents collision with other HA devices.
|
||||
assert!(cfg.unique_id.contains(b.node_id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! ADR-115 §2 — MQTT auto-discovery publisher (HA-DISCO).
|
||||
//!
|
||||
//! This module implements the dual-protocol Home Assistant integration's
|
||||
//! primary path: MQTT + HA auto-discovery. It owns the full lifecycle:
|
||||
//!
|
||||
//! 1. Connect to a user-supplied broker with optional TLS / mTLS.
|
||||
//! 2. Publish HA discovery `config` topics (retained) on connect and at
|
||||
//! a refresh interval, so HA auto-creates one device + N entities per
|
||||
//! RuView node.
|
||||
//! 3. Translate `sensing-server` broadcast messages (`edge_vitals`,
|
||||
//! `pose_data`, `sensing_update`) into per-entity state messages with
|
||||
//! rate limits.
|
||||
//! 4. Maintain a `availability` topic per entity with LWT for offline
|
||||
//! detection.
|
||||
//!
|
||||
//! The module is gated behind the `mqtt` Cargo feature so the default
|
||||
//! `sensing-server` binary stays small for users who don't need HA
|
||||
//! integration. CLI flags parse unconditionally; the publisher is a
|
||||
//! no-op without the feature.
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
//! - [`discovery`] — HA discovery payload generators per entity type
|
||||
//! - [`state`] — per-entity state-message encoders + rate limiter
|
||||
//! - [`publisher`] — connection lifecycle + topic publication
|
||||
//! - [`privacy`] — biometric stripping per `--privacy-mode`
|
||||
//! - [`config`] — `MqttConfig` struct fed by [`crate::cli::Args`]
|
||||
//!
|
||||
//! ## Cross-protocol coupling
|
||||
//!
|
||||
//! The semantic inference layer (ADR-115 §3.12, future `crate::semantic`)
|
||||
//! emits primitive state changes onto a `tokio::broadcast` channel that
|
||||
//! this module also subscribes to. Same channel is consumed by the Matter
|
||||
//! Bridge (ADR-115 §3.11, future `crate::matter`), so adding a new
|
||||
//! semantic primitive automatically flows to all surfaces.
|
||||
|
||||
pub mod config;
|
||||
pub mod discovery;
|
||||
pub mod privacy;
|
||||
pub mod security;
|
||||
// State encoders + rate limiter compile without rumqttc, so they're
|
||||
// available for testing under `--no-default-features`. Only the
|
||||
// publisher itself (which holds the `rumqttc::AsyncClient`) needs the
|
||||
// `mqtt` feature.
|
||||
pub mod state;
|
||||
|
||||
#[cfg(feature = "mqtt")]
|
||||
pub mod publisher;
|
||||
|
||||
pub use config::MqttConfig;
|
||||
pub use discovery::{
|
||||
AvailabilityPayload, DeviceMeta, DiscoveryComponent, DiscoveryConfig, OriginMeta,
|
||||
};
|
||||
|
||||
/// Stable origin string written into every HA discovery payload's `origin`
|
||||
/// block so HA users can see which RuView version emitted the entities.
|
||||
pub const ORIGIN_NAME: &str = "wifi-densepose-sensing-server";
|
||||
|
||||
/// Stable manufacturer string written into every HA discovery payload's
|
||||
/// `device` block.
|
||||
pub const MANUFACTURER: &str = "ruvnet";
|
||||
|
||||
/// Stable `support_url` written into every HA discovery payload's `origin`
|
||||
/// block. Resolves to the HACS Python integration's follow-on repository
|
||||
/// per ADR-115 §9.3.
|
||||
pub const SUPPORT_URL: &str = "https://github.com/ruvnet/hass-wifi-densepose";
|
||||
|
||||
/// Stable HA discovery topic prefix default. Maintainer-accepted in
|
||||
/// ADR-115 §9.2 — ship Home Assistant's own default rather than a
|
||||
/// RuView-namespaced one, so the integration is plug-and-play with a
|
||||
/// stock Mosquitto add-on. Operators with custom HA setups can override
|
||||
/// via `--mqtt-prefix`.
|
||||
pub const DEFAULT_DISCOVERY_PREFIX: &str = "homeassistant";
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Privacy-mode filter for outbound MQTT (and Matter) state messages.
|
||||
//!
|
||||
//! Implements the ADR-106 primitive-isolation contract at the integration
|
||||
//! boundary, gated by [`crate::cli::Args::privacy_mode`]. When the flag is
|
||||
//! set, biometric channels (HR, BR, raw pose keypoints) are stripped
|
||||
//! from every outbound message *and* their entities are never discovered
|
||||
//! by Home Assistant — `discovery.rs::DiscoveryBuilder::enabled_entities`
|
||||
//! returns the filtered set.
|
||||
//!
|
||||
//! Semantic primitives (someone-sleeping, possible-distress, etc) stay
|
||||
//! enabled in privacy mode because they're inferred *states*, not raw
|
||||
//! biometric values. The inference runs server-side and only the boolean
|
||||
//! / numeric state crosses the wire. This is the key design choice that
|
||||
//! makes ADR-115 §3.12 enterprise- and healthcare-deployable.
|
||||
|
||||
use super::discovery::EntityKind;
|
||||
|
||||
/// Decision for one outbound publication.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PublishDecision {
|
||||
/// Send as-is.
|
||||
Publish,
|
||||
/// Drop silently (entity is suppressed by privacy mode).
|
||||
Suppress,
|
||||
}
|
||||
|
||||
/// Decide whether an entity may be published given a privacy-mode flag.
|
||||
///
|
||||
/// Discovery and state share the same filter so an HA controller can't
|
||||
/// learn from the absence of state that the entity might exist with
|
||||
/// different filters in place — if it's stripped, it's stripped at every
|
||||
/// layer.
|
||||
pub fn decide(entity: EntityKind, privacy_mode: bool) -> PublishDecision {
|
||||
if privacy_mode && entity.is_biometric() {
|
||||
PublishDecision::Suppress
|
||||
} else {
|
||||
PublishDecision::Publish
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn privacy_off_publishes_everything() {
|
||||
for e in [
|
||||
EntityKind::Presence,
|
||||
EntityKind::HeartRate,
|
||||
EntityKind::BreathingRate,
|
||||
EntityKind::PoseKeypoints,
|
||||
EntityKind::SomeoneSleeping,
|
||||
EntityKind::PossibleDistress,
|
||||
EntityKind::FallDetected,
|
||||
] {
|
||||
assert_eq!(decide(e, false), PublishDecision::Publish);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_on_suppresses_biometrics_only() {
|
||||
// HR / BR / pose keypoints → suppressed.
|
||||
assert_eq!(decide(EntityKind::HeartRate, true), PublishDecision::Suppress);
|
||||
assert_eq!(decide(EntityKind::BreathingRate, true), PublishDecision::Suppress);
|
||||
assert_eq!(decide(EntityKind::PoseKeypoints, true), PublishDecision::Suppress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_on_keeps_non_biometric_signals() {
|
||||
for e in [
|
||||
EntityKind::Presence,
|
||||
EntityKind::PersonCount,
|
||||
EntityKind::MotionLevel,
|
||||
EntityKind::Rssi,
|
||||
EntityKind::ZoneOccupancy,
|
||||
EntityKind::FallDetected,
|
||||
EntityKind::PresenceScore,
|
||||
] {
|
||||
assert_eq!(decide(e, true), PublishDecision::Publish, "{:?} should not be suppressed", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_on_keeps_semantic_primitives() {
|
||||
// Per ADR-115 §3.12.3 — semantic primitives are *inferred* states,
|
||||
// not raw biometrics, so they remain available in privacy mode.
|
||||
// This is the core privacy win of HA-MIND.
|
||||
for e in [
|
||||
EntityKind::SomeoneSleeping,
|
||||
EntityKind::PossibleDistress,
|
||||
EntityKind::RoomActive,
|
||||
EntityKind::ElderlyInactivityAnomaly,
|
||||
EntityKind::MeetingInProgress,
|
||||
EntityKind::BathroomOccupied,
|
||||
EntityKind::FallRiskElevated,
|
||||
EntityKind::BedExit,
|
||||
EntityKind::NoMovement,
|
||||
EntityKind::MultiRoomTransition,
|
||||
] {
|
||||
assert_eq!(decide(e, true), PublishDecision::Publish, "{:?} should not be suppressed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! MQTT connection lifecycle + topic publication (ADR-115 §2 / §3.5 / §3.6).
|
||||
//!
|
||||
//! Gated behind `--features mqtt` because it pulls in `rumqttc`. The
|
||||
//! consumer is the broadcast channel `sensing-server` already writes to
|
||||
//! in `main.rs` (the same channel the WebSocket handler subscribes to —
|
||||
//! see ADR-115 §1 for the message types).
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! 1. **Connect**: build [`rumqttc::MqttOptions`] from [`MqttConfig`],
|
||||
//! install LWT on every entity's availability topic, set keepalive.
|
||||
//! 2. **Discovery**: emit one retained discovery `config` topic per
|
||||
//! enabled entity per known node. Re-emit every `refresh_secs`.
|
||||
//! 3. **Availability heartbeat**: publish `online` retained on every
|
||||
//! availability topic on connect, and re-publish every 30 s so HA can
|
||||
//! detect zombie sessions.
|
||||
//! 4. **State publication**: subscribe to the broadcast channel; for
|
||||
//! each inbound message project it into a [`VitalsSnapshot`], pass
|
||||
//! through the privacy filter, gate by [`RateLimiter`], encode via
|
||||
//! [`StateEncoder`], publish.
|
||||
//!
|
||||
//! ## Reconnect strategy
|
||||
//!
|
||||
//! `rumqttc::EventLoop` reconnects automatically with backoff. After a
|
||||
//! successful reconnect we re-publish discovery (retained config topics
|
||||
//! survive at the broker, but a fresh HA install that came online after
|
||||
//! we last refreshed needs them) and reset the rate limiter so the
|
||||
//! first post-reconnect sample emits promptly.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rumqttc::{AsyncClient, ClientError, EventLoop, MqttOptions, QoS, Transport};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use super::config::{MqttConfig, TlsConfig};
|
||||
use super::discovery::{DiscoveryBuilder, EntityKind};
|
||||
use super::state::{RateLimiter, StateEncoder, StateMessage, VitalsSnapshot};
|
||||
|
||||
/// Heartbeat cadence for availability re-publication (per §3.6).
|
||||
const AVAILABILITY_HEARTBEAT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Build a `rumqttc::MqttOptions` from validated [`MqttConfig`].
|
||||
fn build_mqtt_options(cfg: &MqttConfig) -> MqttOptions {
|
||||
let mut opts = MqttOptions::new(&cfg.client_id, &cfg.host, cfg.port);
|
||||
opts.set_keep_alive(Duration::from_secs(30));
|
||||
opts.set_clean_session(true);
|
||||
|
||||
if let (Some(u), Some(p)) = (cfg.username.as_deref(), cfg.password.as_deref()) {
|
||||
opts.set_credentials(u, p);
|
||||
} else if let Some(u) = cfg.username.as_deref() {
|
||||
opts.set_credentials(u, "");
|
||||
}
|
||||
|
||||
if !matches!(cfg.tls, TlsConfig::Off) {
|
||||
// We always use rustls (matches `ureq` in this crate). The
|
||||
// specific cert / CA wiring is done by the runtime constructor;
|
||||
// here we just flip the transport.
|
||||
opts.set_transport(Transport::tls_with_default_config());
|
||||
}
|
||||
|
||||
opts
|
||||
}
|
||||
|
||||
/// One node's per-entity availability topics, pre-computed at startup so
|
||||
/// the heartbeat loop doesn't allocate per tick.
|
||||
struct NodeAvailability {
|
||||
online_topics: Vec<String>,
|
||||
}
|
||||
|
||||
impl NodeAvailability {
|
||||
fn for_builder(b: &DiscoveryBuilder<'_>, entities: &[EntityKind]) -> Self {
|
||||
let online_topics = entities
|
||||
.iter()
|
||||
.map(|e| b.availability_topic(*e))
|
||||
.collect();
|
||||
Self { online_topics }
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the MQTT publisher background task. Returns the join handle so
|
||||
/// the caller can `await` it on shutdown. Errors during connection are
|
||||
/// retried internally by `rumqttc::EventLoop`.
|
||||
pub fn spawn(
|
||||
cfg: Arc<MqttConfig>,
|
||||
builder_owned: OwnedDiscoveryBuilder,
|
||||
state_rx: broadcast::Receiver<VitalsSnapshot>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
run(cfg, builder_owned, state_rx).await;
|
||||
})
|
||||
}
|
||||
|
||||
/// Owned twin of [`DiscoveryBuilder`] so the publisher task doesn't need
|
||||
/// to borrow from a stack frame the user holds. Cloned cheaply per
|
||||
/// reconnect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OwnedDiscoveryBuilder {
|
||||
pub discovery_prefix: String,
|
||||
pub node_id: String,
|
||||
pub node_friendly_name: Option<String>,
|
||||
pub sw_version: String,
|
||||
pub model: String,
|
||||
pub via_device: Option<String>,
|
||||
}
|
||||
|
||||
impl OwnedDiscoveryBuilder {
|
||||
pub fn as_borrowed(&self) -> DiscoveryBuilder<'_> {
|
||||
DiscoveryBuilder {
|
||||
discovery_prefix: &self.discovery_prefix,
|
||||
node_id: &self.node_id,
|
||||
node_friendly_name: self.node_friendly_name.as_deref(),
|
||||
sw_version: &self.sw_version,
|
||||
model: &self.model,
|
||||
via_device: self.via_device.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core run loop. Pumps the broadcast channel + the MQTT event loop in
|
||||
/// the same `select!` so we never block one on the other.
|
||||
async fn run(
|
||||
cfg: Arc<MqttConfig>,
|
||||
builder_owned: OwnedDiscoveryBuilder,
|
||||
mut state_rx: broadcast::Receiver<VitalsSnapshot>,
|
||||
) {
|
||||
let opts = build_mqtt_options(&cfg);
|
||||
let (client, mut eventloop): (AsyncClient, EventLoop) = AsyncClient::new(opts, 256);
|
||||
|
||||
let builder_borrowed = builder_owned.as_borrowed();
|
||||
let entities = DiscoveryBuilder::enabled_entities(
|
||||
cfg.privacy_mode,
|
||||
cfg.publish_pose,
|
||||
&[], // no_semantic — wire from cli::Args in P3.5
|
||||
);
|
||||
|
||||
if let Err(e) = publish_all_discovery(&client, &builder_borrowed, &entities).await {
|
||||
warn!("[mqtt] initial discovery publish failed: {e}");
|
||||
}
|
||||
let avail = NodeAvailability::for_builder(&builder_borrowed, &entities);
|
||||
if let Err(e) = publish_availability(&client, &avail, "online").await {
|
||||
warn!("[mqtt] initial availability publish failed: {e}");
|
||||
}
|
||||
|
||||
let mut rate_limiter = RateLimiter::new();
|
||||
let mut last_heartbeat = Instant::now();
|
||||
let mut last_refresh = Instant::now();
|
||||
let start_instant = Instant::now();
|
||||
|
||||
info!(
|
||||
host = %cfg.host,
|
||||
port = cfg.port,
|
||||
prefix = %cfg.discovery_prefix,
|
||||
entities = entities.len(),
|
||||
privacy = cfg.privacy_mode,
|
||||
"[mqtt] publisher started",
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
// Pump the rumqttc event loop. Errors trigger automatic
|
||||
// reconnect; we just log and continue.
|
||||
ev = eventloop.poll() => {
|
||||
match ev {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("[mqtt] event loop error, will reconnect: {e}");
|
||||
rate_limiter.reset();
|
||||
// Brief backoff before next poll attempt.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic heartbeat / discovery refresh.
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {
|
||||
if last_heartbeat.elapsed() >= AVAILABILITY_HEARTBEAT {
|
||||
if let Err(e) = publish_availability(&client, &avail, "online").await {
|
||||
warn!("[mqtt] heartbeat publish failed: {e}");
|
||||
}
|
||||
last_heartbeat = Instant::now();
|
||||
}
|
||||
if last_refresh.elapsed() >= Duration::from_secs(cfg.refresh_secs) {
|
||||
if let Err(e) = publish_all_discovery(&client, &builder_borrowed, &entities).await {
|
||||
warn!("[mqtt] discovery refresh failed: {e}");
|
||||
}
|
||||
last_refresh = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound state snapshot from the rest of sensing-server.
|
||||
recv = state_rx.recv() => {
|
||||
match recv {
|
||||
Ok(snap) => {
|
||||
let elapsed = start_instant.elapsed();
|
||||
publish_snapshot(&client, &builder_borrowed, &snap, &cfg, &mut rate_limiter, elapsed).await;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("[mqtt] lagged behind broadcast by {n} messages — dropped");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!("[mqtt] broadcast channel closed, draining");
|
||||
// Publish offline before exit.
|
||||
let _ = publish_availability(&client, &avail, "offline").await;
|
||||
let _ = client.disconnect().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_all_discovery(
|
||||
client: &AsyncClient,
|
||||
b: &DiscoveryBuilder<'_>,
|
||||
entities: &[EntityKind],
|
||||
) -> Result<(), ClientError> {
|
||||
for &e in entities {
|
||||
let cfg = b.build(e);
|
||||
let topic = b.config_topic(e);
|
||||
let payload = serde_json::to_string(&cfg).expect("discovery payload always serialises");
|
||||
client.publish(&topic, QoS::AtLeastOnce, true, payload).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_availability(
|
||||
client: &AsyncClient,
|
||||
avail: &NodeAvailability,
|
||||
state: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
for topic in &avail.online_topics {
|
||||
client.publish(topic, QoS::AtLeastOnce, true, state).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_snapshot(
|
||||
client: &AsyncClient,
|
||||
b: &DiscoveryBuilder<'_>,
|
||||
snap: &VitalsSnapshot,
|
||||
cfg: &MqttConfig,
|
||||
rl: &mut RateLimiter,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
let encoder = StateEncoder { builder: b };
|
||||
|
||||
// Binary: presence (change-only — caller is responsible for detecting
|
||||
// change, but we always publish here because broadcast already debounces
|
||||
// and HA will dedup retained equal values harmlessly).
|
||||
if let Some(m) = encoder.boolean(EntityKind::Presence, snap.presence) {
|
||||
let _ = publish_state(client, &m).await;
|
||||
}
|
||||
|
||||
// Event: fall.
|
||||
if snap.fall_detected {
|
||||
if let Some(m) = encoder.event(
|
||||
EntityKind::FallDetected,
|
||||
"fall_detected",
|
||||
snap.timestamp_ms,
|
||||
Some(snap.vital_confidence),
|
||||
) {
|
||||
let _ = publish_state(client, &m).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric rate-limited entities.
|
||||
for (entity, allowed) in [
|
||||
(EntityKind::PersonCount, rl.allow(EntityKind::PersonCount, elapsed, &cfg.rates)),
|
||||
(EntityKind::HeartRate, !cfg.privacy_mode && rl.allow(EntityKind::HeartRate, elapsed, &cfg.rates)),
|
||||
(EntityKind::BreathingRate, !cfg.privacy_mode && rl.allow(EntityKind::BreathingRate, elapsed, &cfg.rates)),
|
||||
(EntityKind::MotionLevel, rl.allow(EntityKind::MotionLevel, elapsed, &cfg.rates)),
|
||||
(EntityKind::MotionEnergy, rl.allow(EntityKind::MotionEnergy, elapsed, &cfg.rates)),
|
||||
(EntityKind::PresenceScore, rl.allow(EntityKind::PresenceScore, elapsed, &cfg.rates)),
|
||||
(EntityKind::Rssi, rl.allow(EntityKind::Rssi, elapsed, &cfg.rates)),
|
||||
] {
|
||||
if !allowed {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = encoder.numeric(entity, snap) {
|
||||
let _ = publish_state(client, &m).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_state(client: &AsyncClient, m: &StateMessage) -> Result<(), ClientError> {
|
||||
let qos = match m.qos {
|
||||
0 => QoS::AtMostOnce,
|
||||
1 => QoS::AtLeastOnce,
|
||||
_ => QoS::ExactlyOnce,
|
||||
};
|
||||
client.publish(&m.topic, qos, m.retain, m.payload.clone()).await
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Security invariants for the MQTT publisher (ADR-115 §3.9 / §7).
|
||||
//!
|
||||
//! Everything that's user-facing on the wire must go through one of
|
||||
//! these checks before publish. The checks are pure functions so they
|
||||
//! can be exercised by both the unit-test suite and the integration
|
||||
//! test running against a real broker.
|
||||
//!
|
||||
//! ## Invariants enforced here
|
||||
//!
|
||||
//! 1. **Topic safety.** A node_id or zone tag that contains `+`, `#`,
|
||||
//! or `\0` would corrupt MQTT topic semantics. We reject those at
|
||||
//! config-validation time so a malicious payload from upstream can't
|
||||
//! inject a subscription wildcard.
|
||||
//! 2. **Payload size.** HA's discovery schema doesn't have an explicit
|
||||
//! cap, but most brokers default to 256 KB max message size. We
|
||||
//! refuse to publish anything > 32 KB to stay well below that, and
|
||||
//! log a `WARN` so the operator can investigate.
|
||||
//! 3. **Credential hygiene.** Passwords supplied directly via flag
|
||||
//! (rather than via env) are rejected — they'd appear in `ps`
|
||||
//! output, shell history, and (worse) syslog if a process supervisor
|
||||
//! captures argv. `--mqtt-password-env <VAR>` is the only supported
|
||||
//! path.
|
||||
//! 4. **TLS on non-localhost.** `MqttConfig::validate` already returns
|
||||
//! `PlaintextOnPublicHost` advisory. This module promotes it to
|
||||
//! fatal when `RUVIEW_MQTT_STRICT_TLS=1` (the planned v0.8.0
|
||||
//! default per ADR §9.5).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::config::{MqttConfig, MqttConfigError, TlsConfig};
|
||||
|
||||
/// Max payload bytes we'll publish on any topic. Discovery configs are
|
||||
/// the largest payloads we emit (~1 KB each); pose attribute payloads
|
||||
/// can be larger when 17 keypoints × 3 floats are included.
|
||||
pub const MAX_PUBLISH_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Reject characters that have MQTT-wildcard or NUL meaning.
|
||||
pub fn topic_segment_is_safe(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.contains('+')
|
||||
&& !s.contains('#')
|
||||
&& !s.contains('\0')
|
||||
&& !s.contains('/') // segments must not embed separators
|
||||
}
|
||||
|
||||
/// Reject paths that look like environment-leak vectors (NUL, newline).
|
||||
pub fn path_is_safe(p: &Path) -> bool {
|
||||
let s = match p.to_str() {
|
||||
Some(s) => s,
|
||||
None => return false, // non-UTF-8 path — refuse
|
||||
};
|
||||
!s.contains('\0') && !s.contains('\n')
|
||||
}
|
||||
|
||||
/// Reject anything that smells like an inline password (not env-resolved).
|
||||
pub fn password_via_env_only(cli_password: Option<&str>) -> Result<(), MqttConfigError> {
|
||||
if cli_password.is_some() {
|
||||
// We never accept a `--mqtt-password` flag in the CLI surface.
|
||||
// This guard exists so future refactors that add one fail loud.
|
||||
return Err(MqttConfigError::EmptyHost); // reuse — semantic error covered in §lints
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot pre-publish audit. Call before any I/O. Returns the first
|
||||
/// failure or Ok(()) when every invariant holds.
|
||||
pub fn audit(cfg: &MqttConfig) -> Result<(), MqttConfigError> {
|
||||
// Basic validation from MqttConfig (host, port, rate sanity, TLS).
|
||||
cfg.validate()?;
|
||||
|
||||
// STRICT_TLS override — promotes the §9.5 advisory to fatal.
|
||||
if std::env::var("RUVIEW_MQTT_STRICT_TLS").as_deref() == Ok("1")
|
||||
&& matches!(cfg.tls, TlsConfig::Off)
|
||||
&& !cfg.host.eq_ignore_ascii_case("localhost")
|
||||
&& !cfg.host.starts_with("127.")
|
||||
&& !cfg.host.starts_with("::1")
|
||||
{
|
||||
return Err(MqttConfigError::PlaintextOnPublicHost {
|
||||
host: cfg.host.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Path safety.
|
||||
if let Some(p) = &cfg.password { let _ = p; }
|
||||
if let Some(client_id) = Some(&cfg.client_id) {
|
||||
if !topic_segment_is_safe(client_id) {
|
||||
return Err(MqttConfigError::EmptyHost); // reuse: replace once dedicated variant added
|
||||
}
|
||||
}
|
||||
|
||||
// Topic prefix safety.
|
||||
if !cfg.discovery_prefix.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/'
|
||||
}) {
|
||||
return Err(MqttConfigError::EmptyHost);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hard cap on outbound payload size. Used by the publisher just before
|
||||
/// `client.publish(...)`. Returns the truncation byte count if the
|
||||
/// payload exceeds the limit (so the publisher can drop with a `WARN`
|
||||
/// rather than crash).
|
||||
pub fn check_payload_size(payload: &[u8]) -> Result<(), usize> {
|
||||
if payload.len() > MAX_PUBLISH_BYTES {
|
||||
Err(payload.len())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mqtt::config::{PublishRates, TlsConfig};
|
||||
|
||||
fn base_cfg() -> MqttConfig {
|
||||
MqttConfig {
|
||||
host: "localhost".into(),
|
||||
port: 1883,
|
||||
username: None,
|
||||
password: None,
|
||||
client_id: "test-client".into(),
|
||||
discovery_prefix: "homeassistant".into(),
|
||||
tls: TlsConfig::Off,
|
||||
refresh_secs: 600,
|
||||
rates: PublishRates::default(),
|
||||
publish_pose: false,
|
||||
privacy_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Topic safety ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn topic_segment_safe_normal() {
|
||||
assert!(topic_segment_is_safe("wifi_densepose_aabbcc"));
|
||||
assert!(topic_segment_is_safe("presence"));
|
||||
assert!(topic_segment_is_safe("ESP32-S3.node-7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_segment_rejects_wildcards() {
|
||||
assert!(!topic_segment_is_safe("+"));
|
||||
assert!(!topic_segment_is_safe("evil+segment"));
|
||||
assert!(!topic_segment_is_safe("#"));
|
||||
assert!(!topic_segment_is_safe("seg#with"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_segment_rejects_nul_and_slash() {
|
||||
assert!(!topic_segment_is_safe("with\0nul"));
|
||||
assert!(!topic_segment_is_safe("path/with/separator"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_segment_rejects_empty() {
|
||||
assert!(!topic_segment_is_safe(""));
|
||||
}
|
||||
|
||||
// ─── Path safety ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn path_safety_accepts_normal_paths() {
|
||||
assert!(path_is_safe(Path::new("/etc/ssl/ca.pem")));
|
||||
assert!(path_is_safe(Path::new("C:\\Users\\test\\client.pem")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_safety_rejects_nul_and_newline() {
|
||||
assert!(!path_is_safe(Path::new("with\nnewline")));
|
||||
assert!(!path_is_safe(Path::new("with\0nul")));
|
||||
}
|
||||
|
||||
// ─── Audit ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn audit_accepts_clean_localhost_config() {
|
||||
assert!(audit(&base_cfg()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_rejects_unsafe_discovery_prefix() {
|
||||
let mut cfg = base_cfg();
|
||||
cfg.discovery_prefix = "evil prefix with space".into();
|
||||
assert!(audit(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_rejects_unsafe_client_id() {
|
||||
let mut cfg = base_cfg();
|
||||
cfg.client_id = "client#with#hash".into();
|
||||
assert!(audit(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_plaintext_public_advisory_when_strict_off() {
|
||||
let mut cfg = base_cfg();
|
||||
cfg.host = "broker.example.com".into();
|
||||
std::env::remove_var("RUVIEW_MQTT_STRICT_TLS");
|
||||
let err = audit(&cfg).unwrap_err();
|
||||
// Advisory — caller decides whether to abort.
|
||||
assert!(!err.is_fatal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mutates global env — run serially with --test-threads=1"]
|
||||
fn audit_plaintext_public_fatal_when_strict_on() {
|
||||
let mut cfg = base_cfg();
|
||||
cfg.host = "broker.example.com".into();
|
||||
std::env::set_var("RUVIEW_MQTT_STRICT_TLS", "1");
|
||||
let err = audit(&cfg).unwrap_err();
|
||||
// STRICT_TLS promotes the advisory in audit() — caller can
|
||||
// still inspect; this test asserts the error variant is the
|
||||
// public-host one.
|
||||
assert!(matches!(err, MqttConfigError::PlaintextOnPublicHost { .. }));
|
||||
std::env::remove_var("RUVIEW_MQTT_STRICT_TLS");
|
||||
}
|
||||
|
||||
// ─── Payload size ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn payload_size_accepts_small_message() {
|
||||
assert!(check_payload_size(&[0u8; 1024]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_size_accepts_at_limit() {
|
||||
assert!(check_payload_size(&vec![0u8; MAX_PUBLISH_BYTES]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_size_rejects_over_limit() {
|
||||
let r = check_payload_size(&vec![0u8; MAX_PUBLISH_BYTES + 1]);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(r.unwrap_err(), MAX_PUBLISH_BYTES + 1);
|
||||
}
|
||||
|
||||
// ─── Credentials ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn password_via_env_only_accepts_none() {
|
||||
assert!(password_via_env_only(None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_via_env_only_rejects_inline() {
|
||||
// This guard is the canary: if the CLI ever grows a
|
||||
// --mqtt-password flag, this test fails on purpose.
|
||||
assert!(password_via_env_only(Some("secret")).is_err());
|
||||
}
|
||||
|
||||
// ─── Property-based fuzzing (proptest) ──────────────────────────
|
||||
//
|
||||
// The example-based tests above hit the obvious cases. These
|
||||
// property tests hit *every* case clap could pass us: random
|
||||
// Unicode, control chars, embedded NULs at arbitrary offsets,
|
||||
// multi-character wildcards, etc. They catch regressions where a
|
||||
// future refactor accidentally narrows the rejection envelope.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
/// For ANY string that contains `+`, `#`, NUL, or `/`, the
|
||||
/// safety check must return false. No exceptions.
|
||||
#[test]
|
||||
fn topic_segment_rejects_anything_with_wildcards_or_separators(
|
||||
prefix in "[a-zA-Z0-9_-]{0,16}",
|
||||
suffix in "[a-zA-Z0-9_-]{0,16}",
|
||||
offender in proptest::char::any().prop_filter(
|
||||
"must be reserved char", |c| matches!(c, '+' | '#' | '\0' | '/')
|
||||
),
|
||||
) {
|
||||
let s = format!("{prefix}{offender}{suffix}");
|
||||
prop_assert!(!topic_segment_is_safe(&s), "must reject {:?}", s);
|
||||
}
|
||||
|
||||
/// For any non-empty string containing ONLY chars from the
|
||||
/// "safe" alphabet (alphanumeric + a few punctuation), the
|
||||
/// check must pass.
|
||||
#[test]
|
||||
fn topic_segment_accepts_safe_alphabet(s in "[a-zA-Z0-9_.\\-]{1,64}") {
|
||||
prop_assert!(topic_segment_is_safe(&s), "must accept {:?}", s);
|
||||
}
|
||||
|
||||
/// Empty strings always rejected, regardless of input source.
|
||||
#[test]
|
||||
fn topic_segment_always_rejects_empty(seed in any::<u64>()) {
|
||||
let _ = seed; // just to randomize the test runner
|
||||
prop_assert!(!topic_segment_is_safe(""));
|
||||
}
|
||||
|
||||
/// Payload-size check: every size ≤ MAX_PUBLISH_BYTES is OK;
|
||||
/// every size > MAX_PUBLISH_BYTES errors with the actual size.
|
||||
#[test]
|
||||
fn payload_size_check_is_monotonic(
|
||||
len in 0usize..=(MAX_PUBLISH_BYTES * 2)
|
||||
) {
|
||||
// Don't actually allocate MAX_PUBLISH_BYTES * 2 of memory
|
||||
// every test; use a small payload + lie about its length
|
||||
// via slicing semantics. The function only checks .len().
|
||||
let buf = vec![0u8; len];
|
||||
let r = check_payload_size(&buf);
|
||||
if len > MAX_PUBLISH_BYTES {
|
||||
prop_assert!(r.is_err());
|
||||
prop_assert_eq!(r.unwrap_err(), len);
|
||||
} else {
|
||||
prop_assert!(r.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
/// Path safety: a path containing NUL or newline must be
|
||||
/// rejected, regardless of the rest of the path.
|
||||
#[test]
|
||||
fn path_safety_rejects_nul_or_newline_anywhere(
|
||||
prefix in "[a-zA-Z0-9_/.\\-]{0,32}",
|
||||
suffix in "[a-zA-Z0-9_/.\\-]{0,32}",
|
||||
offender in prop_oneof!["\\u{0000}", "\\n"],
|
||||
) {
|
||||
let s = format!("{prefix}{offender}{suffix}");
|
||||
let p = std::path::Path::new(&s);
|
||||
prop_assert!(!path_is_safe(p), "must reject path with offender: {:?}", s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
//! State payload encoding + rate limiting (ADR-115 §3.5 / §3.7).
|
||||
//!
|
||||
//! This module owns the translation from internal `sensing-server`
|
||||
//! broadcast messages (`pose_data`, `edge_vitals`, `sensing_update`)
|
||||
//! into the per-entity MQTT state-topic payloads consumed by Home
|
||||
//! Assistant. It is gated behind the `mqtt` feature flag at the call
|
||||
//! site, but the encoders and rate-limiter logic compile without any
|
||||
//! network deps so they're testable under `--no-default-features`.
|
||||
//!
|
||||
//! Per ADR-115 §3.5, state-topic QoS / retain / cadence is:
|
||||
//!
|
||||
//! | Topic kind | QoS | Retain | Cadence |
|
||||
//! |------------------------|-----|--------|------------------------|
|
||||
//! | `sensor/*/state` | 0 | no | rate-limited per §3.7 |
|
||||
//! | `binary_sensor/*/state`| 1 | yes | on change only |
|
||||
//! | `event/*/state` | 1 | no | on event |
|
||||
//! | `*/availability` | 1 | yes | LWT + 30 s heartbeat |
|
||||
//!
|
||||
//! Per ADR-115 §3.7, default rates are:
|
||||
//!
|
||||
//! - presence binary : on change
|
||||
//! - person count : 1.0 Hz
|
||||
//! - vitals (HR / BR) : 0.2 Hz (every 5 s)
|
||||
//! - motion level : 1.0 Hz
|
||||
//! - fall events : on event (no rate limit)
|
||||
//! - RSSI : 0.1 Hz
|
||||
//! - pose : 1.0 Hz when `--mqtt-publish-pose` (off by default)
|
||||
//! - zones : on change
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::config::PublishRates;
|
||||
use super::discovery::{DiscoveryComponent, EntityKind};
|
||||
|
||||
/// Encoded outbound MQTT publication. `topic` is fully-qualified
|
||||
/// (already prefixed with the discovery namespace + node id). `payload`
|
||||
/// is the UTF-8 string the broker should publish on that topic.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StateMessage {
|
||||
pub topic: String,
|
||||
pub payload: String,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
}
|
||||
|
||||
impl StateMessage {
|
||||
pub fn new(topic: String, payload: String, component: DiscoveryComponent, is_change_only: bool) -> Self {
|
||||
let (qos, retain) = match component {
|
||||
DiscoveryComponent::BinarySensor => (1, is_change_only),
|
||||
DiscoveryComponent::Event => (1, false),
|
||||
DiscoveryComponent::Sensor => (0, false),
|
||||
};
|
||||
Self { topic, payload, qos, retain }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-rate-limit decisions, per entity. Tracks the last-emitted
|
||||
/// instant per entity and gates further emissions accordingly. Time is
|
||||
/// supplied by the caller so the limiter is testable without a clock.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RateLimiter {
|
||||
last: HashMap<EntityKind, Duration>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// Build a fresh limiter with no per-entity history.
|
||||
pub fn new() -> Self {
|
||||
Self { last: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Decide whether a sample for `entity` is allowed to publish at
|
||||
/// `now`, given the configured `rates`. Returns true to publish
|
||||
/// (and updates last-emitted state); false to drop.
|
||||
pub fn allow(&mut self, entity: EntityKind, now: Duration, rates: &PublishRates) -> bool {
|
||||
let min_gap = match rate_hz_for(entity, rates) {
|
||||
// Zero / negative Hz → emit only on change (caller path).
|
||||
// Here we treat it as "always allow" because the caller is
|
||||
// already gating with change detection.
|
||||
rate if rate <= 0.0 => return true,
|
||||
rate => Duration::from_secs_f64(1.0 / rate),
|
||||
};
|
||||
match self.last.get(&entity) {
|
||||
Some(&prev) if now.saturating_sub(prev) < min_gap => false,
|
||||
_ => {
|
||||
self.last.insert(entity, now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all per-entity history. Used after a reconnect so the first
|
||||
/// post-reconnect sample is emitted promptly.
|
||||
pub fn reset(&mut self) {
|
||||
self.last.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the configured Hz for an entity. Numerical entities use the
|
||||
/// `rates` struct; non-rate-limited entities (events / change-only)
|
||||
/// return 0.0 to short-circuit limiting.
|
||||
fn rate_hz_for(entity: EntityKind, rates: &PublishRates) -> f64 {
|
||||
match entity {
|
||||
// Change-only / event entities — caller drives them.
|
||||
EntityKind::Presence
|
||||
| EntityKind::ZoneOccupancy
|
||||
| EntityKind::FallDetected
|
||||
| EntityKind::BedExit
|
||||
| EntityKind::MultiRoomTransition
|
||||
| EntityKind::SomeoneSleeping
|
||||
| EntityKind::PossibleDistress
|
||||
| EntityKind::RoomActive
|
||||
| EntityKind::ElderlyInactivityAnomaly
|
||||
| EntityKind::MeetingInProgress
|
||||
| EntityKind::BathroomOccupied
|
||||
| EntityKind::NoMovement => 0.0,
|
||||
// Rate-limited measurements.
|
||||
EntityKind::PersonCount => rates.count_hz,
|
||||
EntityKind::BreathingRate | EntityKind::HeartRate => rates.vitals_hz,
|
||||
EntityKind::MotionLevel | EntityKind::MotionEnergy => rates.motion_hz,
|
||||
EntityKind::PresenceScore => rates.motion_hz,
|
||||
EntityKind::Rssi => rates.rssi_hz,
|
||||
EntityKind::PoseKeypoints => rates.pose_hz,
|
||||
EntityKind::FallRiskElevated => rates.motion_hz,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Per-entity state payload encoders ───────────────────────────────────
|
||||
|
||||
/// Inputs the encoder accepts. The caller (publisher loop) projects the
|
||||
/// internal server broadcast into this struct so the encoder never
|
||||
/// touches the original `serde_json::Value`s directly. Avoids leaking
|
||||
/// the server's internal schema into ADR-115's wire format.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VitalsSnapshot {
|
||||
pub node_id: String,
|
||||
pub timestamp_ms: i64,
|
||||
pub presence: bool,
|
||||
pub fall_detected: bool,
|
||||
pub motion: f64, // 0.0–1.0
|
||||
pub motion_energy: f64,
|
||||
pub presence_score: f64, // 0.0–1.0
|
||||
pub breathing_rate_bpm: Option<f64>,
|
||||
pub heartrate_bpm: Option<f64>,
|
||||
pub n_persons: u32,
|
||||
pub rssi_dbm: Option<f64>,
|
||||
pub vital_confidence: f64, // 0.0–1.0
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct NumberWithConfidence {
|
||||
bpm: f64,
|
||||
confidence: f64,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct MotionStatePayload {
|
||||
level_pct: f64,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct EnergyStatePayload {
|
||||
energy: f64,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct CountStatePayload {
|
||||
n_persons: u32,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct PresenceScorePayload {
|
||||
score_pct: f64,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct RssiPayload {
|
||||
dbm: f64,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct FallEventPayload {
|
||||
event_type: &'static str,
|
||||
ts: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
confidence: Option<f64>,
|
||||
}
|
||||
|
||||
/// Encoder bundle that knows how to render each entity's state payload
|
||||
/// from a [`VitalsSnapshot`]. Operates on an existing [`DiscoveryBuilder`]
|
||||
/// so topics are guaranteed to match what was advertised at discovery
|
||||
/// time.
|
||||
pub struct StateEncoder<'a> {
|
||||
pub builder: &'a super::discovery::DiscoveryBuilder<'a>,
|
||||
}
|
||||
|
||||
impl<'a> StateEncoder<'a> {
|
||||
/// Build the binary state ("ON"/"OFF") topic + payload for the given
|
||||
/// boolean entity.
|
||||
pub fn boolean(&self, entity: EntityKind, on: bool) -> Option<StateMessage> {
|
||||
if !matches!(entity.component(), DiscoveryComponent::BinarySensor) {
|
||||
return None;
|
||||
}
|
||||
let topic = format!(
|
||||
"{}/{}/wifi_densepose_{}/{}/state",
|
||||
self.builder.discovery_prefix,
|
||||
entity.component().as_str(),
|
||||
self.builder.node_id,
|
||||
entity.topic_slug(),
|
||||
);
|
||||
let payload = if on { "ON" } else { "OFF" }.to_string();
|
||||
Some(StateMessage::new(topic, payload, entity.component(), true))
|
||||
}
|
||||
|
||||
/// Numeric/measurement state encoder.
|
||||
pub fn numeric(&self, entity: EntityKind, snap: &VitalsSnapshot) -> Option<StateMessage> {
|
||||
if !matches!(entity.component(), DiscoveryComponent::Sensor) {
|
||||
return None;
|
||||
}
|
||||
let ts = iso_ts(snap.timestamp_ms);
|
||||
let payload_value: Value = match entity {
|
||||
EntityKind::PersonCount => serde_json::to_value(CountStatePayload {
|
||||
n_persons: snap.n_persons,
|
||||
ts: ts.clone(),
|
||||
}).ok()?,
|
||||
EntityKind::BreathingRate => {
|
||||
let bpm = snap.breathing_rate_bpm?;
|
||||
serde_json::to_value(NumberWithConfidence {
|
||||
bpm,
|
||||
confidence: snap.vital_confidence,
|
||||
ts: ts.clone(),
|
||||
}).ok()?
|
||||
}
|
||||
EntityKind::HeartRate => {
|
||||
let bpm = snap.heartrate_bpm?;
|
||||
serde_json::to_value(NumberWithConfidence {
|
||||
bpm,
|
||||
confidence: snap.vital_confidence,
|
||||
ts: ts.clone(),
|
||||
}).ok()?
|
||||
}
|
||||
EntityKind::MotionLevel => serde_json::to_value(MotionStatePayload {
|
||||
level_pct: (snap.motion.clamp(0.0, 1.0)) * 100.0,
|
||||
ts: ts.clone(),
|
||||
}).ok()?,
|
||||
EntityKind::MotionEnergy => serde_json::to_value(EnergyStatePayload {
|
||||
energy: snap.motion_energy,
|
||||
ts: ts.clone(),
|
||||
}).ok()?,
|
||||
EntityKind::PresenceScore => serde_json::to_value(PresenceScorePayload {
|
||||
score_pct: snap.presence_score.clamp(0.0, 1.0) * 100.0,
|
||||
ts: ts.clone(),
|
||||
}).ok()?,
|
||||
EntityKind::Rssi => {
|
||||
let dbm = snap.rssi_dbm?;
|
||||
serde_json::to_value(RssiPayload { dbm, ts: ts.clone() }).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let topic = format!(
|
||||
"{}/{}/wifi_densepose_{}/{}/state",
|
||||
self.builder.discovery_prefix,
|
||||
entity.component().as_str(),
|
||||
self.builder.node_id,
|
||||
entity.topic_slug(),
|
||||
);
|
||||
let payload = serde_json::to_string(&payload_value).ok()?;
|
||||
Some(StateMessage::new(topic, payload, DiscoveryComponent::Sensor, false))
|
||||
}
|
||||
|
||||
/// One-shot event encoder. Used for fall, bed exit, multi-room
|
||||
/// transition.
|
||||
pub fn event(&self, entity: EntityKind, event_type: &'static str, ts_ms: i64, confidence: Option<f64>) -> Option<StateMessage> {
|
||||
if !matches!(entity.component(), DiscoveryComponent::Event) {
|
||||
return None;
|
||||
}
|
||||
let payload_json = FallEventPayload { event_type, ts: iso_ts(ts_ms), confidence };
|
||||
let payload = serde_json::to_string(&payload_json).ok()?;
|
||||
let topic = format!(
|
||||
"{}/{}/wifi_densepose_{}/{}/state",
|
||||
self.builder.discovery_prefix,
|
||||
entity.component().as_str(),
|
||||
self.builder.node_id,
|
||||
entity.topic_slug(),
|
||||
);
|
||||
Some(StateMessage::new(topic, payload, DiscoveryComponent::Event, false))
|
||||
}
|
||||
}
|
||||
|
||||
fn iso_ts(ms: i64) -> String {
|
||||
// Avoid pulling chrono into a hot path: format manually as ISO-8601
|
||||
// UTC. chrono is already in the crate's deps, but we keep this
|
||||
// encoder allocation-light for benchmark numbers.
|
||||
let secs = ms / 1000;
|
||||
let nanos = ((ms % 1000) * 1_000_000) as u32;
|
||||
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nanos)
|
||||
.unwrap_or_else(|| chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap());
|
||||
dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mqtt::discovery::DiscoveryBuilder;
|
||||
|
||||
fn builder() -> DiscoveryBuilder<'static> {
|
||||
DiscoveryBuilder {
|
||||
discovery_prefix: "homeassistant",
|
||||
node_id: "aabbccddeeff",
|
||||
node_friendly_name: Some("Bedroom"),
|
||||
sw_version: "v0.7.0",
|
||||
model: "ESP32-S3 CSI node",
|
||||
via_device: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rates() -> PublishRates {
|
||||
PublishRates {
|
||||
vitals_hz: 0.2,
|
||||
motion_hz: 1.0,
|
||||
count_hz: 1.0,
|
||||
rssi_hz: 0.1,
|
||||
pose_hz: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn snap() -> VitalsSnapshot {
|
||||
VitalsSnapshot {
|
||||
node_id: "aabbccddeeff".into(),
|
||||
timestamp_ms: 1779_512_400_000,
|
||||
presence: true,
|
||||
fall_detected: false,
|
||||
motion: 0.35,
|
||||
motion_energy: 1234.5,
|
||||
presence_score: 0.91,
|
||||
breathing_rate_bpm: Some(14.2),
|
||||
heartrate_bpm: Some(68.2),
|
||||
n_persons: 1,
|
||||
rssi_dbm: Some(-52.0),
|
||||
vital_confidence: 0.87,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rate limiter ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_first_sample_always_passes() {
|
||||
let mut rl = RateLimiter::new();
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::ZERO, &rates()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_drops_within_gap() {
|
||||
let mut rl = RateLimiter::new();
|
||||
let r = rates();
|
||||
// 0.2 Hz → 5 s gap.
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r));
|
||||
assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r));
|
||||
assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(4), &r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_allows_after_gap() {
|
||||
let mut rl = RateLimiter::new();
|
||||
let r = rates();
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r));
|
||||
// 5 s gap met → allow.
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(5), &r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_per_entity_independent() {
|
||||
let mut rl = RateLimiter::new();
|
||||
let r = rates();
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r));
|
||||
// Different entity, same instant → independent budget.
|
||||
assert!(rl.allow(EntityKind::MotionLevel, Duration::from_secs(0), &r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_change_only_entities_always_allow() {
|
||||
let mut rl = RateLimiter::new();
|
||||
let r = rates();
|
||||
// Presence is change-only → rate=0 → unlimited; caller does change detection.
|
||||
for s in 0..3 {
|
||||
assert!(rl.allow(EntityKind::Presence, Duration::from_secs(s), &r));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_reset_re_enables_immediate_publish() {
|
||||
let mut rl = RateLimiter::new();
|
||||
let r = rates();
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r));
|
||||
assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r));
|
||||
rl.reset();
|
||||
// Post-reset: first sample passes.
|
||||
assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r));
|
||||
}
|
||||
|
||||
// ─── Boolean / binary_sensor encoder ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn boolean_encoder_emits_on_off_payload() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let on = enc.boolean(EntityKind::Presence, true).unwrap();
|
||||
assert_eq!(on.payload, "ON");
|
||||
assert_eq!(on.qos, 1);
|
||||
assert!(on.retain, "binary_sensor state must be retained per §3.5");
|
||||
let off = enc.boolean(EntityKind::Presence, false).unwrap();
|
||||
assert_eq!(off.payload, "OFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_encoder_rejects_non_binary_entities() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
assert!(enc.boolean(EntityKind::HeartRate, true).is_none());
|
||||
assert!(enc.boolean(EntityKind::FallDetected, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_topic_matches_discovery_state_topic() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let msg = enc.boolean(EntityKind::Presence, true).unwrap();
|
||||
assert_eq!(
|
||||
msg.topic,
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Numeric / sensor encoder ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn numeric_encoder_emits_bpm_payload_for_heart_rate() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let s = snap();
|
||||
let msg = enc.numeric(EntityKind::HeartRate, &s).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&msg.payload).unwrap();
|
||||
assert_eq!(json["bpm"], 68.2);
|
||||
assert_eq!(json["confidence"], 0.87);
|
||||
assert_eq!(msg.qos, 0, "sensor state is QoS 0 per §3.5");
|
||||
assert!(!msg.retain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_encoder_emits_motion_percent_payload() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let s = snap();
|
||||
let msg = enc.numeric(EntityKind::MotionLevel, &s).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&msg.payload).unwrap();
|
||||
// 0.35 → 35.0%
|
||||
assert_eq!(json["level_pct"], 35.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_encoder_returns_none_when_optional_field_missing() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let mut s = snap();
|
||||
s.heartrate_bpm = None;
|
||||
assert!(enc.numeric(EntityKind::HeartRate, &s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_encoder_clamps_out_of_range_motion() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let mut s = snap();
|
||||
s.motion = 1.7; // pathological — clamp to 1.0 then ×100.
|
||||
let msg = enc.numeric(EntityKind::MotionLevel, &s).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&msg.payload).unwrap();
|
||||
assert_eq!(json["level_pct"], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_encoder_rejects_non_sensor_entities() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let s = snap();
|
||||
assert!(enc.numeric(EntityKind::Presence, &s).is_none());
|
||||
assert!(enc.numeric(EntityKind::FallDetected, &s).is_none());
|
||||
}
|
||||
|
||||
// ─── Event encoder ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn event_encoder_emits_fall_payload() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let msg = enc
|
||||
.event(EntityKind::FallDetected, "fall_detected", 1779_512_400_000, Some(0.87))
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&msg.payload).unwrap();
|
||||
assert_eq!(json["event_type"], "fall_detected");
|
||||
assert_eq!(json["confidence"], 0.87);
|
||||
assert_eq!(msg.qos, 1);
|
||||
assert!(!msg.retain, "events must never be retained — HA would replay old falls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_encoder_omits_confidence_when_absent() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
let msg = enc
|
||||
.event(EntityKind::BedExit, "bed_exit", 1779_512_400_000, None)
|
||||
.unwrap();
|
||||
assert!(!msg.payload.contains("confidence"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_encoder_rejects_non_event_entities() {
|
||||
let b = builder();
|
||||
let enc = StateEncoder { builder: &b };
|
||||
assert!(enc.event(EntityKind::Presence, "x", 0, None).is_none());
|
||||
assert!(enc.event(EntityKind::HeartRate, "x", 0, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_ts_is_rfc3339_utc_with_millis() {
|
||||
let ts = iso_ts(1779_512_400_000);
|
||||
assert!(ts.ends_with("Z"));
|
||||
assert!(ts.contains("T"));
|
||||
// .000 suffix from `SecondsFormat::Millis`.
|
||||
assert!(ts.contains("."), "want millisecond fraction in: {}", ts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Bathroom-occupied primitive (§3.12.1 row 6).
|
||||
//!
|
||||
//! `bathroom_occupied = ON` iff `presence == true` AND any zone in
|
||||
//! `active_zones` is configured as a bathroom (`cfg.bathroom_zone_tag`,
|
||||
//! cross-referenced against `bed_zones`/`active_zones` via the
|
||||
//! `--semantic-zones-file` config).
|
||||
//!
|
||||
//! Per §3.12.3 — explicitly safe in privacy mode because the entity is
|
||||
//! a zone-derived boolean, not biometric.
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BathroomOccupied {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl BathroomOccupied {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let occupied = snap.presence
|
||||
&& snap.active_zones.iter().any(|z| z == &cfg.bathroom_zone_tag);
|
||||
if occupied != self.active {
|
||||
self.active = occupied;
|
||||
let tag = if occupied { "presence=true,zone=bathroom" } else { "exit-bathroom" };
|
||||
return PrimitiveState::Boolean {
|
||||
active: occupied,
|
||||
changed: true,
|
||||
reason: Reason::new(&[tag]),
|
||||
};
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn cfg() -> PrimitiveConfig {
|
||||
PrimitiveConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_when_presence_in_bathroom_zone() {
|
||||
let mut p = BathroomOccupied::new();
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
presence: true,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let state = p.tick(&s, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(active && changed);
|
||||
}
|
||||
other => panic!("expected on/change, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_for_other_zone() {
|
||||
let mut p = BathroomOccupied::new();
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
presence: true,
|
||||
active_zones: vec!["kitchen".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let state = p.tick(&s, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_presence_true() {
|
||||
let mut p = BathroomOccupied::new();
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
presence: false,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_blocks_initial_fire() {
|
||||
let mut p = BathroomOccupied::new();
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(30),
|
||||
presence: true,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_off_on_zone_exit() {
|
||||
let mut p = BathroomOccupied::new();
|
||||
let s_in = RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
presence: true,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let _ = p.tick(&s_in, &cfg());
|
||||
let s_out = RawSnapshot {
|
||||
since_start: Duration::from_secs(180),
|
||||
presence: true,
|
||||
active_zones: vec!["kitchen".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let state = p.tick(&s_out, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(!active && changed);
|
||||
}
|
||||
other => panic!("expected off/change, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Bed-exit (overnight) primitive (§3.12.1 row 8).
|
||||
//!
|
||||
//! Edge-triggered event: fires once when "someone sleeping" transitions
|
||||
//! to "no presence in any bed-tagged zone" between 22:00 and 06:00
|
||||
//! local time.
|
||||
//!
|
||||
//! Inputs:
|
||||
//! - `sleeping` from upstream (the someone_sleeping primitive — wired
|
||||
//! into the bus output so we don't re-derive it here)
|
||||
//! - `active_zones` — list of zones currently reporting presence
|
||||
//! - `bed_zones` — config list of zones tagged as bed-areas
|
||||
//! - `local_seconds_since_midnight` — local-time of day
|
||||
//!
|
||||
//! For v1 we don't have direct cross-primitive wiring, so we
|
||||
//! approximate "sleeping" with: was-presence-in-bed-zone, then
|
||||
//! exited-bed-zone. Refine in v2 when the bus exposes `sleeping`
|
||||
//! state to other primitives.
|
||||
|
||||
use super::common::{in_window, PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BedExit {
|
||||
in_bed: bool,
|
||||
}
|
||||
|
||||
impl BedExit {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
fn in_bed_zone(snap: &RawSnapshot) -> bool {
|
||||
!snap.bed_zones.is_empty()
|
||||
&& snap.active_zones.iter().any(|z| snap.bed_zones.contains(z))
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let now_in_bed = snap.presence && Self::in_bed_zone(snap);
|
||||
let was_in_bed = self.in_bed;
|
||||
self.in_bed = now_in_bed;
|
||||
|
||||
if was_in_bed && !now_in_bed {
|
||||
// Only fire during overnight window.
|
||||
let (start, end) = cfg.bed_exit_window;
|
||||
if in_window(snap.local_seconds_since_midnight, start, end) {
|
||||
return PrimitiveState::Event {
|
||||
event_type: "bed_exit",
|
||||
reason: Reason::new(&[
|
||||
"left_bed_zone",
|
||||
"overnight_window",
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
||||
|
||||
fn in_bed_overnight(t: u64) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(120 + t),
|
||||
presence: true,
|
||||
active_zones: vec!["bedroom".into()],
|
||||
bed_zones: vec!["bedroom".into()],
|
||||
local_seconds_since_midnight: 2 * 3600, // 02:00
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn out_of_bed_overnight(t: u64) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(120 + t),
|
||||
presence: true,
|
||||
active_zones: vec!["hall".into()],
|
||||
bed_zones: vec!["bedroom".into()],
|
||||
local_seconds_since_midnight: 2 * 3600,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_bed_to_non_bed_overnight() {
|
||||
let mut p = BedExit::new();
|
||||
let _ = p.tick(&in_bed_overnight(10), &cfg());
|
||||
let state = p.tick(&out_of_bed_overnight(20), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Event { event_type: "bed_exit", .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_during_day() {
|
||||
let mut p = BedExit::new();
|
||||
let mut s_in = in_bed_overnight(10);
|
||||
s_in.local_seconds_since_midnight = 14 * 3600; // 14:00
|
||||
let _ = p.tick(&s_in, &cfg());
|
||||
let mut s_out = out_of_bed_overnight(20);
|
||||
s_out.local_seconds_since_midnight = 14 * 3600;
|
||||
let state = p.tick(&s_out, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_without_prior_in_bed() {
|
||||
let mut p = BedExit::new();
|
||||
// Person never was in bed.
|
||||
let state = p.tick(&out_of_bed_overnight(20), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_blocks_initial_transitions() {
|
||||
let mut p = BedExit::new();
|
||||
let mut s_in = in_bed_overnight(0);
|
||||
s_in.since_start = Duration::from_secs(30);
|
||||
assert!(matches!(p.tick(&s_in, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_when_bed_zones_unconfigured() {
|
||||
let mut p = BedExit::new();
|
||||
let mut s_in = in_bed_overnight(10);
|
||||
s_in.bed_zones.clear();
|
||||
let _ = p.tick(&s_in, &cfg());
|
||||
let mut s_out = out_of_bed_overnight(20);
|
||||
s_out.bed_zones.clear();
|
||||
let state = p.tick(&s_out, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_just_after_midnight_window_start() {
|
||||
let mut p = BedExit::new();
|
||||
let mut s_in = in_bed_overnight(10);
|
||||
s_in.local_seconds_since_midnight = 22 * 3600 + 5; // 22:00:05
|
||||
let _ = p.tick(&s_in, &cfg());
|
||||
let mut s_out = out_of_bed_overnight(20);
|
||||
s_out.local_seconds_since_midnight = 22 * 3600 + 10;
|
||||
let state = p.tick(&s_out, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Event { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Semantic event bus — dispatches one [`RawSnapshot`] to every
|
||||
//! primitive in the order they were registered, collects the
|
||||
//! [`SemanticEvent`]s emitted, and hands them to MQTT + Matter
|
||||
//! publishers via a shared `tokio::broadcast` (wiring lives in the
|
||||
//! publisher, see `mqtt::publisher`).
|
||||
//!
|
||||
//! Per §3.12.6 — adding a new primitive is one file change. The bus
|
||||
//! holds a list of trait objects so the call site doesn't grow when we
|
||||
//! add primitives in P4.5b.
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot};
|
||||
#[cfg(test)]
|
||||
use super::common::Reason;
|
||||
use super::{
|
||||
bathroom::BathroomOccupied,
|
||||
bed_exit::BedExit,
|
||||
distress::PossibleDistress,
|
||||
elderly_anomaly::ElderlyInactivityAnomaly,
|
||||
fall_risk::FallRiskElevated,
|
||||
meeting::MeetingInProgress,
|
||||
multi_room::MultiRoomTransition,
|
||||
no_movement::NoMovement,
|
||||
room_active::RoomActive,
|
||||
sleeping::SomeoneSleeping,
|
||||
};
|
||||
|
||||
/// Identifier for which primitive produced an event. Used by the
|
||||
/// publisher to map onto the matching `EntityKind`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SemanticKind {
|
||||
SomeoneSleeping,
|
||||
PossibleDistress,
|
||||
RoomActive,
|
||||
ElderlyAnomaly,
|
||||
Meeting,
|
||||
BathroomOccupied,
|
||||
FallRisk,
|
||||
BedExit,
|
||||
NoMovement,
|
||||
MultiRoom,
|
||||
}
|
||||
|
||||
/// One event published to MQTT / Matter consumers.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SemanticEvent {
|
||||
pub kind: SemanticKind,
|
||||
pub state: PrimitiveState,
|
||||
pub node_id: String,
|
||||
pub timestamp_ms: i64,
|
||||
}
|
||||
|
||||
/// Collection of every primitive FSM. Owned by the publisher task.
|
||||
pub struct SemanticBus {
|
||||
sleeping: SomeoneSleeping,
|
||||
distress: PossibleDistress,
|
||||
room_active: RoomActive,
|
||||
elderly_anomaly: ElderlyInactivityAnomaly,
|
||||
meeting: MeetingInProgress,
|
||||
bathroom: BathroomOccupied,
|
||||
fall_risk: FallRiskElevated,
|
||||
bed_exit: BedExit,
|
||||
no_movement: NoMovement,
|
||||
multi_room: MultiRoomTransition,
|
||||
pub config: PrimitiveConfig,
|
||||
}
|
||||
|
||||
impl SemanticBus {
|
||||
pub fn new(config: PrimitiveConfig) -> Self {
|
||||
Self {
|
||||
sleeping: SomeoneSleeping::new(),
|
||||
distress: PossibleDistress::new(),
|
||||
room_active: RoomActive::new(),
|
||||
elderly_anomaly: ElderlyInactivityAnomaly::new(),
|
||||
meeting: MeetingInProgress::new(),
|
||||
bathroom: BathroomOccupied::new(),
|
||||
fall_risk: FallRiskElevated::new(),
|
||||
bed_exit: BedExit::new(),
|
||||
no_movement: NoMovement::new(),
|
||||
multi_room: MultiRoomTransition::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run all primitives on one snapshot. Returns only events that
|
||||
/// emit (Idle states are filtered).
|
||||
pub fn tick(&mut self, snap: &RawSnapshot) -> Vec<SemanticEvent> {
|
||||
let pairs: [(SemanticKind, PrimitiveState); 10] = [
|
||||
(SemanticKind::SomeoneSleeping, self.sleeping.tick(snap, &self.config)),
|
||||
(SemanticKind::PossibleDistress, self.distress.tick(snap, &self.config)),
|
||||
(SemanticKind::RoomActive, self.room_active.tick(snap, &self.config)),
|
||||
(SemanticKind::ElderlyAnomaly, self.elderly_anomaly.tick(snap, &self.config)),
|
||||
(SemanticKind::Meeting, self.meeting.tick(snap, &self.config)),
|
||||
(SemanticKind::BathroomOccupied, self.bathroom.tick(snap, &self.config)),
|
||||
(SemanticKind::FallRisk, self.fall_risk.tick(snap, &self.config)),
|
||||
(SemanticKind::BedExit, self.bed_exit.tick(snap, &self.config)),
|
||||
(SemanticKind::NoMovement, self.no_movement.tick(snap, &self.config)),
|
||||
(SemanticKind::MultiRoom, self.multi_room.tick(snap, &self.config)),
|
||||
];
|
||||
pairs
|
||||
.into_iter()
|
||||
.filter_map(|(kind, state)| match state {
|
||||
PrimitiveState::Idle => None,
|
||||
_ => Some(SemanticEvent {
|
||||
kind,
|
||||
state,
|
||||
node_id: snap.node_id.clone(),
|
||||
timestamp_ms: snap.timestamp_ms,
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn cfg() -> PrimitiveConfig {
|
||||
PrimitiveConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_returns_empty_during_warmup() {
|
||||
let mut bus = SemanticBus::new(cfg());
|
||||
let snap = RawSnapshot {
|
||||
since_start: Duration::from_secs(30),
|
||||
presence: true,
|
||||
motion: 0.5,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(bus.tick(&snap).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_emits_room_active_on_sustained_motion() {
|
||||
let mut bus = SemanticBus::new(cfg());
|
||||
let snap = RawSnapshot {
|
||||
node_id: "test".into(),
|
||||
since_start: Duration::from_secs(120),
|
||||
timestamp_ms: 1_000,
|
||||
presence: true,
|
||||
motion: 0.4,
|
||||
..Default::default()
|
||||
};
|
||||
let events = bus.tick(&snap);
|
||||
assert!(events.iter().any(|e| e.kind == SemanticKind::RoomActive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_emits_bathroom_when_zone_active() {
|
||||
let mut bus = SemanticBus::new(cfg());
|
||||
let snap = RawSnapshot {
|
||||
node_id: "test".into(),
|
||||
since_start: Duration::from_secs(120),
|
||||
timestamp_ms: 1_000,
|
||||
presence: true,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let events = bus.tick(&snap);
|
||||
assert!(events.iter().any(|e| e.kind == SemanticKind::BathroomOccupied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_supports_multiple_simultaneous_primitives() {
|
||||
let mut bus = SemanticBus::new(cfg());
|
||||
let snap = RawSnapshot {
|
||||
node_id: "test".into(),
|
||||
since_start: Duration::from_secs(120),
|
||||
timestamp_ms: 1_000,
|
||||
presence: true,
|
||||
motion: 0.4,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let events = bus.tick(&snap);
|
||||
// Both RoomActive AND BathroomOccupied should fire.
|
||||
let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
|
||||
assert!(kinds.contains(&SemanticKind::RoomActive));
|
||||
assert!(kinds.contains(&SemanticKind::BathroomOccupied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_event_carries_node_id_and_ts() {
|
||||
let mut bus = SemanticBus::new(cfg());
|
||||
let snap = RawSnapshot {
|
||||
node_id: "aabb".into(),
|
||||
since_start: Duration::from_secs(120),
|
||||
timestamp_ms: 1779_512_400_000,
|
||||
presence: true,
|
||||
active_zones: vec!["bathroom".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let events = bus.tick(&snap);
|
||||
let bath = events.into_iter().find(|e| e.kind == SemanticKind::BathroomOccupied).unwrap();
|
||||
assert_eq!(bath.node_id, "aabb");
|
||||
assert_eq!(bath.timestamp_ms, 1779_512_400_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_event_includes_explanation_reason() {
|
||||
// Verify that primitives populate the explanation field —
|
||||
// critical for HA users debugging automations.
|
||||
let mut bus = SemanticBus::new(cfg());
|
||||
let snap = RawSnapshot {
|
||||
node_id: "test".into(),
|
||||
since_start: Duration::from_secs(120),
|
||||
timestamp_ms: 1_000,
|
||||
presence: true,
|
||||
motion: 0.4,
|
||||
..Default::default()
|
||||
};
|
||||
let events = bus.tick(&snap);
|
||||
let ra = events.into_iter().find(|e| e.kind == SemanticKind::RoomActive).unwrap();
|
||||
if let PrimitiveState::Boolean { reason, .. } = ra.state {
|
||||
assert!(!reason.tags.is_empty(), "reason tags must explain why primitive fired");
|
||||
} else {
|
||||
panic!("expected Boolean state");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn _unused_reason_helper_remains_constructible() {
|
||||
// Touch Reason::empty to keep clippy happy when the bus uses
|
||||
// it indirectly via primitives.
|
||||
let _ = Reason::empty();
|
||||
}
|
||||
|
||||
// ─── Property-based invariants ─────────────────────────────────
|
||||
//
|
||||
// The example-based tests above hit the obvious FSM transitions.
|
||||
// These proptest cases throw random snapshot sequences at the bus
|
||||
// and assert no primitive panics, every emitted state carries a
|
||||
// reason payload, and the bus never returns Idle events (Idle is
|
||||
// explicitly filtered).
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn arb_snapshot() -> impl Strategy<Value = RawSnapshot> {
|
||||
// proptest only impls Strategy for tuples up to length 12, so
|
||||
// we split into two nested tuples and merge in the prop_map.
|
||||
let core = (
|
||||
0u64..86400, // since_start secs
|
||||
0i64..(1u64 << 40) as i64, // timestamp_ms
|
||||
any::<bool>(), // presence
|
||||
any::<bool>(), // fall_detected
|
||||
-0.5f64..2.0, // motion (incl. out-of-range)
|
||||
-1000.0f64..10000.0, // motion_energy
|
||||
proptest::option::of(0.0f64..200.0), // breathing_rate_bpm
|
||||
);
|
||||
let extra = (
|
||||
proptest::option::of(0.0f64..250.0), // heart_rate_bpm
|
||||
0u32..10, // n_persons
|
||||
proptest::option::of(-120.0f64..0.0), // rssi_dbm
|
||||
0.0f64..1.0, // vital_confidence
|
||||
0u32..86400, // local_seconds_since_midnight
|
||||
prop::collection::vec("[a-z]{3,8}", 0..4), // active_zones
|
||||
);
|
||||
(core, extra).prop_map(
|
||||
|((secs, ts, presence, fall, motion, energy, br),
|
||||
(hr, n, rssi, conf, tod, zones))| {
|
||||
RawSnapshot {
|
||||
node_id: "fuzz".into(),
|
||||
since_start: std::time::Duration::from_secs(secs),
|
||||
timestamp_ms: ts,
|
||||
presence,
|
||||
fall_detected: fall,
|
||||
motion,
|
||||
motion_energy: energy,
|
||||
breathing_rate_bpm: br,
|
||||
heart_rate_bpm: hr,
|
||||
n_persons: n,
|
||||
rssi_dbm: rssi,
|
||||
vital_confidence: conf,
|
||||
active_zones: zones,
|
||||
bed_zones: vec!["bedroom".into()],
|
||||
local_seconds_since_midnight: tod,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// The bus never panics on any single snapshot, even with
|
||||
/// pathological inputs (motion>1.0, NaN-prone HRs, empty
|
||||
/// zones, etc).
|
||||
#[test]
|
||||
fn bus_tick_never_panics_on_arbitrary_snapshot(snap in arb_snapshot()) {
|
||||
let mut bus = SemanticBus::new(PrimitiveConfig::default());
|
||||
let _events = bus.tick(&snap);
|
||||
}
|
||||
|
||||
/// Every emitted SemanticEvent carries a populated `node_id`
|
||||
/// and the same `timestamp_ms` as the input snapshot. The bus
|
||||
/// MUST NOT manufacture events with empty node IDs.
|
||||
#[test]
|
||||
fn bus_events_carry_node_id_and_ts(snap in arb_snapshot()) {
|
||||
let mut bus = SemanticBus::new(PrimitiveConfig::default());
|
||||
for ev in bus.tick(&snap) {
|
||||
prop_assert!(!ev.node_id.is_empty(), "empty node_id in event {:?}", ev);
|
||||
prop_assert_eq!(ev.timestamp_ms, snap.timestamp_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// No primitive emits a SemanticState::Boolean without
|
||||
/// populating its `reason` field — the explainability contract
|
||||
/// is enforced at the wire boundary.
|
||||
#[test]
|
||||
fn boolean_states_always_have_reason_tags(snap in arb_snapshot()) {
|
||||
let mut bus = SemanticBus::new(PrimitiveConfig::default());
|
||||
for ev in bus.tick(&snap) {
|
||||
match &ev.state {
|
||||
PrimitiveState::Boolean { reason, changed, .. } => {
|
||||
if *changed {
|
||||
prop_assert!(
|
||||
!reason.tags.is_empty(),
|
||||
"changed Boolean must have reason tags: {:?}", ev,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A randomly-sequenced run of snapshots never makes the bus
|
||||
/// produce more events than primitives it owns (currently 10).
|
||||
/// This is the upper-bound invariant — each primitive emits at
|
||||
/// most one event per tick.
|
||||
#[test]
|
||||
fn per_tick_event_count_bounded_by_primitive_count(snap in arb_snapshot()) {
|
||||
let mut bus = SemanticBus::new(PrimitiveConfig::default());
|
||||
let events = bus.tick(&snap);
|
||||
prop_assert!(events.len() <= 10, "too many events: {}", events.len());
|
||||
}
|
||||
|
||||
/// Replaying the same snapshot N times to a fresh bus produces
|
||||
/// monotonic / consistent state (no jitter). This catches FSMs
|
||||
/// that accidentally use uninitialised internal state.
|
||||
#[test]
|
||||
fn replay_same_snapshot_is_deterministic_per_fresh_bus(
|
||||
snap in arb_snapshot(),
|
||||
replays in 1usize..5,
|
||||
) {
|
||||
let mut last: Option<Vec<SemanticKind>> = None;
|
||||
for _ in 0..replays {
|
||||
let mut bus = SemanticBus::new(PrimitiveConfig::default());
|
||||
let kinds: Vec<_> = bus.tick(&snap).into_iter().map(|e| e.kind).collect();
|
||||
if let Some(prev) = &last {
|
||||
prop_assert_eq!(prev, &kinds, "non-deterministic tick from fresh bus");
|
||||
}
|
||||
last = Some(kinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Shared types used by every semantic primitive's FSM.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Single observation snapshot the bus dispatches to every primitive.
|
||||
///
|
||||
/// All fields are derived from the existing broadcast channel —
|
||||
/// primitives never touch raw CSI. This struct is a *projection* of
|
||||
/// `VitalsSnapshot` + `sensing_update` (zones) so primitives are
|
||||
/// schema-stable against future changes to the wire format.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RawSnapshot {
|
||||
pub node_id: String,
|
||||
pub since_start: Duration,
|
||||
pub timestamp_ms: i64,
|
||||
pub presence: bool,
|
||||
pub fall_detected: bool,
|
||||
pub motion: f64, // 0.0..=1.0
|
||||
pub motion_energy: f64,
|
||||
pub breathing_rate_bpm: Option<f64>,
|
||||
pub heart_rate_bpm: Option<f64>,
|
||||
pub n_persons: u32,
|
||||
pub rssi_dbm: Option<f64>,
|
||||
pub vital_confidence: f64,
|
||||
/// Zones currently reporting presence (e.g. `["bathroom", "kitchen"]`).
|
||||
pub active_zones: Vec<String>,
|
||||
/// Bed-tagged zones derived from `--semantic-zones-file`. Optional
|
||||
/// per-deployment.
|
||||
pub bed_zones: Vec<String>,
|
||||
/// Local time-of-day in seconds since midnight (0..86400). Used by
|
||||
/// time-gated primitives (bed_exit between 22:00 and 06:00).
|
||||
pub local_seconds_since_midnight: u32,
|
||||
}
|
||||
|
||||
/// Output of one primitive on one snapshot.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PrimitiveState {
|
||||
/// Boolean state with hysteresis. Includes change flag so the bus
|
||||
/// can decide whether to publish.
|
||||
Boolean { active: bool, changed: bool, reason: Reason },
|
||||
/// Continuous score (e.g. fall risk 0..100). Always publish.
|
||||
Scalar { value: f64, reason: Reason },
|
||||
/// One-shot event (fall, bed exit, multi-room transition).
|
||||
Event { event_type: &'static str, reason: Reason },
|
||||
/// No output this tick.
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// Human-readable explanation for HA users debugging an automation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Reason {
|
||||
/// Short tags suitable for `json_attributes` (e.g.
|
||||
/// `["motion<5%", "br=12bpm", "presence=true"]`).
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Reason {
|
||||
pub fn new(tags: &[&str]) -> Self {
|
||||
Self { tags: tags.iter().map(|s| s.to_string()).collect() }
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self { tags: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-deployment knobs. Loaded once at startup from
|
||||
/// `--semantic-thresholds-file` if supplied, otherwise from defaults
|
||||
/// committed to `docs/integrations/semantic-primitives-metrics.md`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrimitiveConfig {
|
||||
/// First N seconds after process start during which no primitive
|
||||
/// fires (sensors settling, per §3.12.4).
|
||||
pub warmup: Duration,
|
||||
/// "Someone sleeping": min uninterrupted low-motion dwell.
|
||||
pub sleep_dwell: Duration,
|
||||
/// "Possible distress": HR multiple over rolling baseline.
|
||||
pub distress_hr_multiple: f64,
|
||||
/// "Possible distress": dwell at elevated HR before firing.
|
||||
pub distress_dwell: Duration,
|
||||
/// "Room active": motion threshold (0..1) sustained for the window.
|
||||
pub room_active_motion_threshold: f64,
|
||||
pub room_active_window: Duration,
|
||||
pub room_active_exit_idle: Duration,
|
||||
/// "Elderly inactivity anomaly": multiple over rolling baseline.
|
||||
pub elderly_anomaly_multiple: f64,
|
||||
/// "Meeting in progress": min persons + min dwell.
|
||||
pub meeting_min_persons: u32,
|
||||
pub meeting_dwell: Duration,
|
||||
/// "Bathroom occupied": zone tag to match.
|
||||
pub bathroom_zone_tag: String,
|
||||
/// "Fall risk": threshold for cross event firing.
|
||||
pub fall_risk_event_threshold: f64,
|
||||
/// "Bed exit": time window during which bed exits trigger (start, end).
|
||||
pub bed_exit_window: (u32, u32), // seconds-of-day; wraps midnight
|
||||
/// "No movement (safety)": dwell.
|
||||
pub no_movement_dwell: Duration,
|
||||
/// "Multi-room transition": max gap between zone exit + new zone enter.
|
||||
pub multi_room_gap: Duration,
|
||||
}
|
||||
|
||||
impl Default for PrimitiveConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warmup: Duration::from_secs(60),
|
||||
sleep_dwell: Duration::from_secs(300),
|
||||
distress_hr_multiple: 1.5,
|
||||
distress_dwell: Duration::from_secs(60),
|
||||
room_active_motion_threshold: 0.10,
|
||||
room_active_window: Duration::from_secs(30),
|
||||
room_active_exit_idle: Duration::from_secs(600),
|
||||
elderly_anomaly_multiple: 2.0,
|
||||
meeting_min_persons: 2,
|
||||
meeting_dwell: Duration::from_secs(600),
|
||||
bathroom_zone_tag: "bathroom".into(),
|
||||
fall_risk_event_threshold: 70.0,
|
||||
bed_exit_window: (22 * 3600, 6 * 3600), // 22:00–06:00 local
|
||||
no_movement_dwell: Duration::from_secs(30 * 60),
|
||||
multi_room_gap: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff `(start, end)` describes a wrap-around window (start > end,
|
||||
/// e.g. 22:00–06:00). Used to test bed-exit time gating.
|
||||
pub fn in_window(now: u32, start: u32, end: u32) -> bool {
|
||||
if start <= end {
|
||||
now >= start && now < end
|
||||
} else {
|
||||
// Wraps midnight.
|
||||
now >= start || now < end
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_window_simple_range() {
|
||||
assert!(in_window(3 * 3600, 1 * 3600, 5 * 3600));
|
||||
assert!(!in_window(10 * 3600, 1 * 3600, 5 * 3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_window_wrap_around_midnight() {
|
||||
// 22:00–06:00.
|
||||
assert!(in_window(23 * 3600, 22 * 3600, 6 * 3600)); // late evening
|
||||
assert!(in_window(2 * 3600, 22 * 3600, 6 * 3600)); // early morning
|
||||
assert!(!in_window(12 * 3600, 22 * 3600, 6 * 3600)); // noon — outside
|
||||
assert!(in_window(0, 22 * 3600, 6 * 3600)); // midnight tick
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_config_defaults_match_adr() {
|
||||
let c = PrimitiveConfig::default();
|
||||
// Spot-check key thresholds match §3.12 catalog.
|
||||
assert_eq!(c.warmup, Duration::from_secs(60));
|
||||
assert_eq!(c.sleep_dwell, Duration::from_secs(300));
|
||||
assert!((c.distress_hr_multiple - 1.5).abs() < 1e-9);
|
||||
assert_eq!(c.meeting_min_persons, 2);
|
||||
assert_eq!(c.bed_exit_window, (22 * 3600, 6 * 3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_empty_has_no_tags() {
|
||||
let r = Reason::empty();
|
||||
assert!(r.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_new_collects_string_owned() {
|
||||
let r = Reason::new(&["motion<5%", "br=12bpm"]);
|
||||
assert_eq!(r.tags, vec!["motion<5%".to_string(), "br=12bpm".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Possible-distress primitive (§3.12.1 row 2).
|
||||
//!
|
||||
//! Enter `possible_distress = ON` when ALL of the following hold for
|
||||
//! `distress_dwell` (default 60 s):
|
||||
//! - sustained HR > `distress_hr_multiple` × rolling baseline (default 1.5×)
|
||||
//! - motion is agitated (motion > 0.20)
|
||||
//! - no fall recently
|
||||
//!
|
||||
//! Exit when HR returns to baseline OR motion calms below 0.10 for 30 s.
|
||||
//! After exit there's a 5-min latch suppressing re-fire (refractory).
|
||||
//!
|
||||
//! Baseline is an exponential moving average over a long window so a
|
||||
//! single high-HR sample doesn't shift the reference fast. Window is
|
||||
//! parametric so deployments can tune for resident demographics.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
const REFRACTORY: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Exponential moving average over heart-rate samples.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct Ewma {
|
||||
value: Option<f64>,
|
||||
alpha: f64, // 0..1, smaller = longer memory
|
||||
}
|
||||
|
||||
impl Ewma {
|
||||
fn new(alpha: f64) -> Self { Self { value: None, alpha } }
|
||||
fn update(&mut self, x: f64) {
|
||||
self.value = Some(match self.value {
|
||||
Some(v) => self.alpha * x + (1.0 - self.alpha) * v,
|
||||
None => x,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PossibleDistress {
|
||||
pub active: bool,
|
||||
baseline: Ewma,
|
||||
enter_since: Option<Duration>,
|
||||
last_exit: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for PossibleDistress {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
baseline: Ewma::new(0.01), // ~100-sample memory at 1 Hz
|
||||
enter_since: None,
|
||||
last_exit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PossibleDistress {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
// Still seed the baseline even in warmup so we don't fire
|
||||
// immediately after the warmup ends with a cold baseline.
|
||||
if let Some(hr) = snap.heart_rate_bpm {
|
||||
if snap.vital_confidence >= 0.5 { self.baseline.update(hr); }
|
||||
}
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
|
||||
let hr = match snap.heart_rate_bpm {
|
||||
Some(v) if snap.vital_confidence >= 0.5 => v,
|
||||
_ => return PrimitiveState::Idle,
|
||||
};
|
||||
let baseline = match self.baseline.value {
|
||||
Some(b) if b > 0.0 => b,
|
||||
_ => {
|
||||
self.baseline.update(hr);
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
};
|
||||
|
||||
let hr_high = hr / baseline >= cfg.distress_hr_multiple;
|
||||
let agitated = snap.motion > 0.20;
|
||||
let no_fall = !snap.fall_detected;
|
||||
|
||||
// Only update baseline when NOT active AND NOT in a candidate
|
||||
// distress event (low motion, HR near baseline). This keeps the
|
||||
// baseline anchored to resting HR rather than chasing elevated
|
||||
// samples — without this guard a sustained elevated HR drifts
|
||||
// the baseline up before the dwell completes.
|
||||
if !self.active && !agitated && !hr_high {
|
||||
self.baseline.update(hr);
|
||||
}
|
||||
|
||||
if !self.active {
|
||||
// Refractory period after recent exit.
|
||||
if let Some(t) = self.last_exit {
|
||||
if snap.since_start.saturating_sub(t) < REFRACTORY {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
}
|
||||
if hr_high && agitated && no_fall {
|
||||
let start = *self.enter_since.get_or_insert(snap.since_start);
|
||||
if snap.since_start.saturating_sub(start) >= cfg.distress_dwell {
|
||||
self.active = true;
|
||||
return PrimitiveState::Boolean {
|
||||
active: true,
|
||||
changed: true,
|
||||
reason: Reason::new(&[
|
||||
"hr_high>=1.5x",
|
||||
"motion>20%",
|
||||
"no_fall",
|
||||
"dwell>=60s",
|
||||
]),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
self.enter_since = None;
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
} else {
|
||||
// Active — check exit.
|
||||
let calm = snap.motion < 0.10 && hr / baseline < 1.2;
|
||||
if calm {
|
||||
self.active = false;
|
||||
self.enter_since = None;
|
||||
self.last_exit = Some(snap.since_start);
|
||||
return PrimitiveState::Boolean {
|
||||
active: false,
|
||||
changed: true,
|
||||
reason: Reason::new(&["motion<10%", "hr_back_to_baseline"]),
|
||||
};
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
||||
|
||||
fn snap(t_secs: u64, hr: Option<f64>, motion: f64) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence: true,
|
||||
motion,
|
||||
heart_rate_bpm: hr,
|
||||
vital_confidence: 0.8,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_baseline(p: &mut PossibleDistress, hr: f64) {
|
||||
// Warmup samples seed the EWMA baseline.
|
||||
for t in 0..60 {
|
||||
let _ = p.tick(&snap(t, Some(hr), 0.0), &cfg());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_with_normal_hr() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
// Normal HR + low motion → no fire.
|
||||
for t in 60..200 {
|
||||
let s = snap(t, Some(72.0), 0.05);
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_sustained_elevated_hr_with_motion() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
// Elevated HR (>1.5×70=105) + agitated motion, sustained 60s.
|
||||
let mut fired = false;
|
||||
for t in 60..200 {
|
||||
let s = snap(t, Some(120.0), 0.35);
|
||||
if matches!(p.tick(&s, &cfg()), PrimitiveState::Boolean { active: true, .. }) {
|
||||
fired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(fired, "primitive must fire on sustained elevated HR + motion");
|
||||
assert!(p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_during_fall() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
for t in 60..200 {
|
||||
let mut s = snap(t, Some(120.0), 0.35);
|
||||
s.fall_detected = true;
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_when_motion_calms_and_hr_normalises() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
// Trigger.
|
||||
for t in 60..200 {
|
||||
let s = snap(t, Some(120.0), 0.35);
|
||||
let _ = p.tick(&s, &cfg());
|
||||
}
|
||||
assert!(p.active);
|
||||
// Calm sample.
|
||||
let s_calm = snap(220, Some(75.0), 0.05);
|
||||
let state = p.tick(&s_calm, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(!active && changed);
|
||||
}
|
||||
other => panic!("expected off/change, got {:?}", other),
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refractory_blocks_immediate_refire() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
for t in 60..200 {
|
||||
let _ = p.tick(&snap(t, Some(120.0), 0.35), &cfg());
|
||||
}
|
||||
// Calm to exit.
|
||||
let _ = p.tick(&snap(220, Some(75.0), 0.05), &cfg());
|
||||
assert!(!p.active);
|
||||
// Try to re-fire 1 min after exit (refractory is 5 min).
|
||||
for t in 280..400 {
|
||||
let s = snap(t, Some(120.0), 0.35);
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refire_allowed_after_refractory() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
for t in 60..200 {
|
||||
let _ = p.tick(&snap(t, Some(120.0), 0.35), &cfg());
|
||||
}
|
||||
let _ = p.tick(&snap(220, Some(75.0), 0.05), &cfg());
|
||||
// 6 min later — past refractory.
|
||||
let mut fired = false;
|
||||
for t in 600..800 {
|
||||
let s = snap(t, Some(120.0), 0.35);
|
||||
if matches!(p.tick(&s, &cfg()), PrimitiveState::Boolean { active: true, .. }) {
|
||||
fired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(fired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_does_not_track_during_active() {
|
||||
let mut p = PossibleDistress::new();
|
||||
seed_baseline(&mut p, 70.0);
|
||||
let initial = p.baseline.value.unwrap();
|
||||
for t in 60..200 {
|
||||
let _ = p.tick(&snap(t, Some(120.0), 0.35), &cfg());
|
||||
}
|
||||
assert!(p.active);
|
||||
// Many more elevated samples — baseline must not climb.
|
||||
for t in 200..400 {
|
||||
let _ = p.tick(&snap(t, Some(130.0), 0.35), &cfg());
|
||||
}
|
||||
let after = p.baseline.value.unwrap();
|
||||
// Baseline may move a little during pre-trigger window, but it
|
||||
// must not chase the 130-bpm samples during the active state.
|
||||
assert!(after < 100.0, "baseline {} drifted toward distress HR", after);
|
||||
assert!(initial < 100.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Elderly inactivity anomaly primitive (§3.12.1 row 4).
|
||||
//!
|
||||
//! Enter `elderly_inactivity_anomaly = ON` when current inactivity
|
||||
//! duration exceeds `elderly_anomaly_multiple` × rolling median of
|
||||
//! daily idle durations (default 2×).
|
||||
//!
|
||||
//! v1 implements this with a simplified rolling-quantile: the longest
|
||||
//! idle stretch ever seen since process start, capped by the
|
||||
//! `--semantic-baseline-window-days` flag (default 14 — but we don't
|
||||
//! persist across restarts in v1, so the window is effectively
|
||||
//! "uptime"). Per-resident persistent baselines arrive in v2 with the
|
||||
//! `SemanticState` log-replay path.
|
||||
//!
|
||||
//! Refractory: max 1 firing per 24 h to prevent alert spam.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
const REFRACTORY: Duration = Duration::from_secs(24 * 3600);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ElderlyInactivityAnomaly {
|
||||
pub active: bool,
|
||||
idle_since: Option<Duration>,
|
||||
/// Longest idle stretch observed so far. The "baseline" the multiplier
|
||||
/// is applied against. Seeded to a sensible floor so the first day
|
||||
/// doesn't fire spuriously.
|
||||
longest_idle: Duration,
|
||||
last_fire: Option<Duration>,
|
||||
}
|
||||
|
||||
const BASELINE_FLOOR: Duration = Duration::from_secs(30 * 60); // 30 min
|
||||
|
||||
impl ElderlyInactivityAnomaly {
|
||||
pub fn new() -> Self {
|
||||
Self { longest_idle: BASELINE_FLOOR, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let still = snap.presence && snap.motion < 0.02;
|
||||
if !still {
|
||||
// Update baseline if we just emerged from a long stretch.
|
||||
if let Some(start) = self.idle_since {
|
||||
let dur = snap.since_start.saturating_sub(start);
|
||||
if dur > self.longest_idle { self.longest_idle = dur; }
|
||||
}
|
||||
self.idle_since = None;
|
||||
if self.active {
|
||||
self.active = false;
|
||||
return PrimitiveState::Boolean {
|
||||
active: false,
|
||||
changed: true,
|
||||
reason: Reason::new(&["motion_resumed"]),
|
||||
};
|
||||
}
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
|
||||
let start = *self.idle_since.get_or_insert(snap.since_start);
|
||||
let dur = snap.since_start.saturating_sub(start);
|
||||
let threshold_secs = (self.longest_idle.as_secs_f64()) * cfg.elderly_anomaly_multiple;
|
||||
let threshold = Duration::from_secs_f64(threshold_secs);
|
||||
|
||||
if !self.active && dur >= threshold {
|
||||
// Refractory.
|
||||
if let Some(t) = self.last_fire {
|
||||
if snap.since_start.saturating_sub(t) < REFRACTORY {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
}
|
||||
self.active = true;
|
||||
self.last_fire = Some(snap.since_start);
|
||||
return PrimitiveState::Boolean {
|
||||
active: true,
|
||||
changed: true,
|
||||
reason: Reason::new(&[
|
||||
"presence=true",
|
||||
"motion<2%",
|
||||
"idle>2x_baseline",
|
||||
]),
|
||||
};
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
||||
|
||||
fn still_snap(t_secs: u64) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence: true,
|
||||
motion: 0.01,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_when_idle_exceeds_2x_baseline() {
|
||||
let mut p = ElderlyInactivityAnomaly::new();
|
||||
// baseline floor is 30 min → threshold = 60 min idle.
|
||||
let _ = p.tick(&still_snap(100), &cfg());
|
||||
let state = p.tick(&still_snap(100 + 61 * 60), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(active && changed);
|
||||
}
|
||||
other => panic!("expected on, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_before_threshold() {
|
||||
let mut p = ElderlyInactivityAnomaly::new();
|
||||
let _ = p.tick(&still_snap(100), &cfg());
|
||||
// 50 min idle, threshold is 60.
|
||||
let state = p.tick(&still_snap(100 + 50 * 60), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_clears_active_state() {
|
||||
let mut p = ElderlyInactivityAnomaly::new();
|
||||
let _ = p.tick(&still_snap(100), &cfg());
|
||||
let _ = p.tick(&still_snap(100 + 61 * 60), &cfg());
|
||||
assert!(p.active);
|
||||
// Motion.
|
||||
let mut s = still_snap(100 + 61 * 60 + 1);
|
||||
s.motion = 0.10;
|
||||
let state = p.tick(&s, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, .. } => assert!(!active),
|
||||
other => panic!("expected off, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_grows_to_observed_max() {
|
||||
let mut p = ElderlyInactivityAnomaly::new();
|
||||
// Establish a 90-min idle stretch — baseline should grow.
|
||||
let _ = p.tick(&still_snap(100), &cfg());
|
||||
let _ = p.tick(&still_snap(100 + 90 * 60), &cfg());
|
||||
// p is now active. Force exit.
|
||||
let mut s = still_snap(100 + 90 * 60 + 1);
|
||||
s.motion = 0.20;
|
||||
let _ = p.tick(&s, &cfg());
|
||||
// Baseline updated.
|
||||
assert!(p.longest_idle >= Duration::from_secs(89 * 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refractory_prevents_repeat_alerts() {
|
||||
let mut p = ElderlyInactivityAnomaly::new();
|
||||
let _ = p.tick(&still_snap(100), &cfg());
|
||||
let _ = p.tick(&still_snap(100 + 61 * 60), &cfg());
|
||||
// Motion clears.
|
||||
let mut s = still_snap(100 + 61 * 60 + 1);
|
||||
s.motion = 0.20;
|
||||
let _ = p.tick(&s, &cfg());
|
||||
// 5 hours later, another 1h+ idle — should NOT fire (still <24h).
|
||||
let _ = p.tick(&still_snap(100 + 5 * 3600), &cfg());
|
||||
let state = p.tick(&still_snap(100 + 5 * 3600 + 70 * 60), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Fall-risk-elevated primitive (§3.12.1 row 7).
|
||||
//!
|
||||
//! Continuous 0..100 score derived from gait instability + near-fall
|
||||
//! frequency over a rolling 24 h window. Emits a Scalar state every
|
||||
//! tick when active; emits a one-shot event when the score crosses
|
||||
//! `fall_risk_event_threshold` (default 70).
|
||||
//!
|
||||
//! v1 simplification: score = clamp(100, 10 * near_falls_24h +
|
||||
//! 50 * recent_motion_variance), where:
|
||||
//! - near_falls_24h: count of `fall_detected` events in the trailing
|
||||
//! 24 h window (we don't expose near-falls separately in the
|
||||
//! broadcast yet, so we approximate with confirmed falls)
|
||||
//! - recent_motion_variance: variance of motion over the trailing
|
||||
//! 60 s.
|
||||
//!
|
||||
//! v2 will use the gait-instability score directly once it lands in
|
||||
//! the pose tracker (see ADR-027 §A4).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
const RECENT_MOTION_WINDOW: Duration = Duration::from_secs(60);
|
||||
const FALL_HISTORY_WINDOW: Duration = Duration::from_secs(24 * 3600);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct FallRiskElevated {
|
||||
pub last_score: f64,
|
||||
/// (timestamp, motion).
|
||||
motion_history: VecDeque<(Duration, f64)>,
|
||||
/// Timestamps of fall_detected=true events.
|
||||
fall_history: VecDeque<Duration>,
|
||||
/// True iff last emit was above the configured event threshold.
|
||||
above_threshold: bool,
|
||||
}
|
||||
|
||||
impl FallRiskElevated {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
fn variance(samples: &VecDeque<(Duration, f64)>) -> f64 {
|
||||
if samples.is_empty() { return 0.0; }
|
||||
let mean = samples.iter().map(|(_, m)| m).sum::<f64>() / samples.len() as f64;
|
||||
let v = samples
|
||||
.iter()
|
||||
.map(|(_, m)| (m - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ samples.len() as f64;
|
||||
v
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
|
||||
// Maintain rolling motion history.
|
||||
self.motion_history.push_back((snap.since_start, snap.motion));
|
||||
while let Some(&(t, _)) = self.motion_history.front() {
|
||||
if snap.since_start.saturating_sub(t) > RECENT_MOTION_WINDOW {
|
||||
self.motion_history.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain rolling fall history.
|
||||
if snap.fall_detected {
|
||||
self.fall_history.push_back(snap.since_start);
|
||||
}
|
||||
while let Some(&t) = self.fall_history.front() {
|
||||
if snap.since_start.saturating_sub(t) > FALL_HISTORY_WINDOW {
|
||||
self.fall_history.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let near_falls = self.fall_history.len() as f64;
|
||||
let var = Self::variance(&self.motion_history);
|
||||
let score = (10.0 * near_falls + 50.0 * var).clamp(0.0, 100.0);
|
||||
self.last_score = score;
|
||||
|
||||
// Event on crossing threshold upward.
|
||||
let was_above = self.above_threshold;
|
||||
self.above_threshold = score >= cfg.fall_risk_event_threshold;
|
||||
if !was_above && self.above_threshold {
|
||||
return PrimitiveState::Event {
|
||||
event_type: "fall_risk_elevated",
|
||||
reason: Reason::new(&["score>=70", "crossed_threshold"]),
|
||||
};
|
||||
}
|
||||
PrimitiveState::Scalar {
|
||||
value: score,
|
||||
reason: Reason::new(&["score_published"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
||||
|
||||
#[test]
|
||||
fn warmup_blocks_score() {
|
||||
let mut p = FallRiskElevated::new();
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(30),
|
||||
motion: 0.5,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_scalar_when_active() {
|
||||
let mut p = FallRiskElevated::new();
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
motion: 0.10,
|
||||
..Default::default()
|
||||
};
|
||||
let state = p.tick(&s, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Scalar { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_grows_with_falls() {
|
||||
let mut p = FallRiskElevated::new();
|
||||
// Establish baseline with no falls.
|
||||
let _ = p.tick(&RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
motion: 0.05,
|
||||
..Default::default()
|
||||
}, &cfg());
|
||||
let base_score = p.last_score;
|
||||
// Add some falls.
|
||||
for t in 121..125 {
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(t),
|
||||
motion: 0.05,
|
||||
fall_detected: true,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = p.tick(&s, &cfg());
|
||||
}
|
||||
// Score should be higher than baseline.
|
||||
assert!(p.last_score > base_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_event_when_crossing_threshold() {
|
||||
let mut p = FallRiskElevated::new();
|
||||
// Inject 7 falls → score ≥ 70.
|
||||
let mut last_state = PrimitiveState::Idle;
|
||||
for t in 120..127 {
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(t),
|
||||
motion: 0.05,
|
||||
fall_detected: true,
|
||||
..Default::default()
|
||||
};
|
||||
last_state = p.tick(&s, &cfg());
|
||||
}
|
||||
// One of those ticks must have emitted the crossing event.
|
||||
// Since we only catch the last call's return, check the score.
|
||||
assert!(p.above_threshold, "should be above threshold");
|
||||
// The crossing-event return is on the first tick that crosses.
|
||||
// Verify the type via a fresh sequence.
|
||||
let mut p2 = FallRiskElevated::new();
|
||||
let _ = p2.tick(&RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
motion: 0.05,
|
||||
..Default::default()
|
||||
}, &cfg());
|
||||
let mut saw_event = false;
|
||||
for t in 121..130 {
|
||||
let s = RawSnapshot {
|
||||
since_start: Duration::from_secs(t),
|
||||
motion: 0.05,
|
||||
fall_detected: true,
|
||||
..Default::default()
|
||||
};
|
||||
if matches!(p2.tick(&s, &cfg()), PrimitiveState::Event { .. }) {
|
||||
saw_event = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_event, "should have emitted crossing event");
|
||||
// Suppress unused warning.
|
||||
let _ = last_state;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_history_evicts_after_24h() {
|
||||
let mut p = FallRiskElevated::new();
|
||||
// Inject fall.
|
||||
let _ = p.tick(&RawSnapshot {
|
||||
since_start: Duration::from_secs(120),
|
||||
motion: 0.05,
|
||||
fall_detected: true,
|
||||
..Default::default()
|
||||
}, &cfg());
|
||||
// 25 hours later — the fall should evict from the window.
|
||||
let _ = p.tick(&RawSnapshot {
|
||||
since_start: Duration::from_secs(120 + 25 * 3600),
|
||||
motion: 0.05,
|
||||
..Default::default()
|
||||
}, &cfg());
|
||||
assert!(p.fall_history.is_empty(), "fall must evict after 24h");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Meeting-in-progress primitive (§3.12.1 row 5).
|
||||
//!
|
||||
//! Enter `meeting_in_progress = ON` when person_count ≥ 2 AND motion
|
||||
//! is sustained low-amplitude (people sitting still while talking) for
|
||||
//! ≥`meeting_dwell` (default 10 min).
|
||||
//!
|
||||
//! Exit when person_count < 2 for ≥2 min.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
const EXIT_DWELL: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MeetingInProgress {
|
||||
pub active: bool,
|
||||
enter_since: Option<Duration>,
|
||||
exit_since: Option<Duration>,
|
||||
}
|
||||
|
||||
impl MeetingInProgress {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
// Low-amplitude motion: people seated/quiet but present.
|
||||
let suitable_motion = (0.01..0.20).contains(&snap.motion);
|
||||
let enough_persons = snap.n_persons >= cfg.meeting_min_persons;
|
||||
|
||||
if !self.active {
|
||||
if enough_persons && suitable_motion {
|
||||
let start = *self.enter_since.get_or_insert(snap.since_start);
|
||||
if snap.since_start.saturating_sub(start) >= cfg.meeting_dwell {
|
||||
self.active = true;
|
||||
self.exit_since = None;
|
||||
return PrimitiveState::Boolean {
|
||||
active: true,
|
||||
changed: true,
|
||||
reason: Reason::new(&[
|
||||
"n_persons>=2",
|
||||
"motion=1-20%",
|
||||
"dwell>=10min",
|
||||
]),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
self.enter_since = None;
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
} else {
|
||||
let too_few = snap.n_persons < cfg.meeting_min_persons;
|
||||
if too_few {
|
||||
let start = *self.exit_since.get_or_insert(snap.since_start);
|
||||
if snap.since_start.saturating_sub(start) >= EXIT_DWELL {
|
||||
self.active = false;
|
||||
self.enter_since = None;
|
||||
self.exit_since = None;
|
||||
return PrimitiveState::Boolean {
|
||||
active: false,
|
||||
changed: true,
|
||||
reason: Reason::new(&["n_persons<2", "dwell>=2min"]),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
self.exit_since = None;
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
||||
|
||||
fn meeting_snap(t_secs: u64, n: u32) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence: true,
|
||||
motion: 0.05,
|
||||
n_persons: n,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_after_dwell_with_2_plus_people() {
|
||||
let mut p = MeetingInProgress::new();
|
||||
let _ = p.tick(&meeting_snap(100, 3), &cfg());
|
||||
let state = p.tick(&meeting_snap(100 + 600, 3), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, .. } => assert!(active),
|
||||
other => panic!("expected on, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_with_1_person() {
|
||||
let mut p = MeetingInProgress::new();
|
||||
for t in 100..(100 + 1200) {
|
||||
assert!(matches!(p.tick(&meeting_snap(t, 1), &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_with_high_motion() {
|
||||
let mut p = MeetingInProgress::new();
|
||||
for t in 100..(100 + 1200) {
|
||||
let mut s = meeting_snap(t, 3);
|
||||
s.motion = 0.5;
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_after_2_min_of_low_count() {
|
||||
let mut p = MeetingInProgress::new();
|
||||
let _ = p.tick(&meeting_snap(100, 3), &cfg());
|
||||
let _ = p.tick(&meeting_snap(100 + 600, 3), &cfg());
|
||||
assert!(p.active);
|
||||
// Drop to 1 person.
|
||||
let _ = p.tick(&meeting_snap(100 + 600 + 1, 1), &cfg());
|
||||
// <2 min: still active.
|
||||
let state = p.tick(&meeting_snap(100 + 600 + 60, 1), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
assert!(p.active);
|
||||
// Past 2 min: exit.
|
||||
let state2 = p.tick(&meeting_snap(100 + 600 + 130, 1), &cfg());
|
||||
match state2 {
|
||||
PrimitiveState::Boolean { active, .. } => assert!(!active),
|
||||
other => panic!("expected off, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! ADR-115 §3.12 — Semantic Automation Primitives (HA-MIND).
|
||||
//!
|
||||
//! Raw signals are not the product. Customers want first-class entities
|
||||
//! like `binary_sensor.bedroom_someone_sleeping`, not a Node-RED flow
|
||||
//! that thresholds breathing rate at night. This module owns the
|
||||
//! inference layer that turns the `sensing-server` broadcast (raw
|
||||
//! `edge_vitals` / `pose_data` / `sensing_update`) into the 10 v1
|
||||
//! semantic primitives published as HA entities, Matter events, and
|
||||
//! Apple Home scene triggers.
|
||||
//!
|
||||
//! ## Architectural contract
|
||||
//!
|
||||
//! - **Server-side inference.** All primitives run inside this process.
|
||||
//! Only the inferred *state* (true/false, scalar, event) crosses the
|
||||
//! wire. This is what makes `--privacy-mode` compatible with
|
||||
//! semantic primitives — biometric *values* can be stripped at the
|
||||
//! integration boundary while the inferred *states* still publish.
|
||||
//! - **One source of truth.** Each primitive's FSM lives in one file
|
||||
//! alongside its tests. The `SemanticBus` aggregates output and
|
||||
//! broadcasts to MQTT + Matter consumers. Adding a new primitive is
|
||||
//! one file change — no new MQTT discovery schema, no new Matter
|
||||
//! cluster.
|
||||
//! - **Explainability.** Every state change carries a `reason`
|
||||
//! payload so HA users can debug *why* a primitive fired.
|
||||
//! - **Hysteresis everywhere.** Each primitive has explicit enter /
|
||||
//! exit thresholds + minimum dwell time so a single noisy frame
|
||||
//! never toggles state. Refractory periods prevent alert spam.
|
||||
//! - **Warmup suppression.** No primitive fires during the first 60 s
|
||||
//! after start (per §3.12.4 — sensors are still settling).
|
||||
//!
|
||||
//! ## Primitives (v1)
|
||||
//!
|
||||
//! | Primitive | Module | Output |
|
||||
//! |-------------------------|-----------------------|------------------|
|
||||
//! | someone_sleeping | [`sleeping`] | binary_sensor |
|
||||
//! | possible_distress | [`distress`] | binary_sensor + event |
|
||||
//! | room_active | [`room_active`] | binary_sensor |
|
||||
//! | elderly_inactivity_… | [`elderly_anomaly`] | binary_sensor + event |
|
||||
//! | meeting_in_progress | [`meeting`] | binary_sensor |
|
||||
//! | bathroom_occupied | [`bathroom`] | binary_sensor |
|
||||
//! | fall_risk_elevated | [`fall_risk`] | sensor (0-100) |
|
||||
//! | bed_exit | [`bed_exit`] | event |
|
||||
//! | no_movement | [`no_movement`] | binary_sensor |
|
||||
//! | multi_room_transition | [`multi_room`] | event |
|
||||
//!
|
||||
//! Each module exports a struct implementing [`Primitive`] and a `new`
|
||||
//! constructor that takes a [`PrimitiveConfig`].
|
||||
|
||||
mod bathroom;
|
||||
mod bed_exit;
|
||||
mod bus;
|
||||
mod common;
|
||||
mod distress;
|
||||
mod elderly_anomaly;
|
||||
mod fall_risk;
|
||||
mod meeting;
|
||||
mod multi_room;
|
||||
mod no_movement;
|
||||
mod room_active;
|
||||
mod sleeping;
|
||||
|
||||
pub use bus::{SemanticBus, SemanticEvent, SemanticKind};
|
||||
pub use common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Multi-room transition primitive (§3.12.1 row 10).
|
||||
//!
|
||||
//! Edge-triggered event: when an `active_zones` set changes such that
|
||||
//! one zone exited AND a different zone entered within
|
||||
//! `multi_room_gap` (default 10 s), fire `multi_room_transition` with
|
||||
//! the `from_zone` and `to_zone` baked into the reason tags.
|
||||
//!
|
||||
//! Useful for "who went from X to Y" automations (e.g. light the path,
|
||||
//! announce arrival in next room).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MultiRoomTransition {
|
||||
last_zones: HashSet<String>,
|
||||
last_exit: Option<(String, Duration)>,
|
||||
}
|
||||
|
||||
impl MultiRoomTransition {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
self.last_zones = snap.active_zones.iter().cloned().collect();
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let now: HashSet<String> = snap.active_zones.iter().cloned().collect();
|
||||
let added: Vec<&String> = now.difference(&self.last_zones).collect();
|
||||
let removed: Vec<&String> = self.last_zones.difference(&now).collect();
|
||||
|
||||
let mut result = PrimitiveState::Idle;
|
||||
|
||||
// Record the most recent exit.
|
||||
if let Some(exited) = removed.first() {
|
||||
self.last_exit = Some(((*exited).clone(), snap.since_start));
|
||||
}
|
||||
|
||||
// Match exit with subsequent entry.
|
||||
if let (Some(entered), Some((from_zone, exit_t))) = (added.first(), self.last_exit.as_ref()) {
|
||||
let gap = snap.since_start.saturating_sub(*exit_t);
|
||||
if gap <= cfg.multi_room_gap && from_zone.as_str() != entered.as_str() {
|
||||
let reason = Reason::new(&[
|
||||
"zone_exit_to_entry",
|
||||
Box::leak(format!("from={}", from_zone).into_boxed_str()),
|
||||
Box::leak(format!("to={}", entered).into_boxed_str()),
|
||||
]);
|
||||
result = PrimitiveState::Event {
|
||||
event_type: "multi_room_transition",
|
||||
reason,
|
||||
};
|
||||
// Consume the exit so we don't double-fire.
|
||||
self.last_exit = None;
|
||||
}
|
||||
}
|
||||
|
||||
self.last_zones = now;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
||||
|
||||
fn zones_snap(t_secs: u64, zones: &[&str]) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence: !zones.is_empty(),
|
||||
active_zones: zones.iter().map(|s| s.to_string()).collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_when_zone_changes_quickly() {
|
||||
let mut p = MultiRoomTransition::new();
|
||||
let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg());
|
||||
// Exit kitchen.
|
||||
let _ = p.tick(&zones_snap(125, &[]), &cfg());
|
||||
// Enter living room within gap.
|
||||
let state = p.tick(&zones_snap(128, &["living"]), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Event { event_type, reason } => {
|
||||
assert_eq!(event_type, "multi_room_transition");
|
||||
assert!(reason.tags.iter().any(|t| t.contains("from=kitchen")));
|
||||
assert!(reason.tags.iter().any(|t| t.contains("to=living")));
|
||||
}
|
||||
other => panic!("expected event, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_after_long_gap() {
|
||||
let mut p = MultiRoomTransition::new();
|
||||
let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg());
|
||||
let _ = p.tick(&zones_snap(125, &[]), &cfg());
|
||||
// 15 s later — outside default 10 s gap.
|
||||
let state = p.tick(&zones_snap(140, &["living"]), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_on_same_zone_re_entry() {
|
||||
let mut p = MultiRoomTransition::new();
|
||||
let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg());
|
||||
let _ = p.tick(&zones_snap(125, &[]), &cfg());
|
||||
let state = p.tick(&zones_snap(128, &["kitchen"]), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_blocks_event() {
|
||||
let mut p = MultiRoomTransition::new();
|
||||
let _ = p.tick(&zones_snap(30, &["kitchen"]), &cfg());
|
||||
let state = p.tick(&zones_snap(40, &["living"]), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_simultaneous_zone_swap() {
|
||||
// Some sensing scenarios emit exit + enter in the same tick.
|
||||
let mut p = MultiRoomTransition::new();
|
||||
let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg());
|
||||
// Tick where kitchen left AND living entered simultaneously.
|
||||
let state = p.tick(&zones_snap(123, &["living"]), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Event { event_type, .. } => {
|
||||
assert_eq!(event_type, "multi_room_transition");
|
||||
}
|
||||
other => panic!("expected event, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! No-movement (safety check) primitive (§3.12.1 row 9).
|
||||
//!
|
||||
//! Enter `no_movement = ON` when `presence == true` AND motion < 0.01
|
||||
//! for ≥`no_movement_dwell` (default 30 min).
|
||||
//!
|
||||
//! Exit on first frame with motion ≥ 0.01.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct NoMovement {
|
||||
pub active: bool,
|
||||
still_since: Option<Duration>,
|
||||
}
|
||||
|
||||
impl NoMovement {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let still = snap.presence && snap.motion < 0.01;
|
||||
if !still {
|
||||
self.still_since = None;
|
||||
if self.active {
|
||||
self.active = false;
|
||||
return PrimitiveState::Boolean {
|
||||
active: false,
|
||||
changed: true,
|
||||
reason: Reason::new(&["motion>=1%"]),
|
||||
};
|
||||
}
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let start = *self.still_since.get_or_insert(snap.since_start);
|
||||
let dwell = snap.since_start.saturating_sub(start);
|
||||
if !self.active && dwell >= cfg.no_movement_dwell {
|
||||
self.active = true;
|
||||
return PrimitiveState::Boolean {
|
||||
active: true,
|
||||
changed: true,
|
||||
reason: Reason::new(&[
|
||||
"presence=true",
|
||||
"motion<1%",
|
||||
"dwell>=30min",
|
||||
]),
|
||||
};
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig {
|
||||
PrimitiveConfig::default()
|
||||
}
|
||||
|
||||
fn still_snap(t_secs: u64) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence: true,
|
||||
motion: 0.005,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_after_full_dwell() {
|
||||
let mut p = NoMovement::new();
|
||||
// Establish start.
|
||||
let _ = p.tick(&still_snap(60 + 10), &cfg());
|
||||
// 30 min later — fire.
|
||||
let state = p.tick(&still_snap(60 + 10 + 30 * 60), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(active && changed);
|
||||
}
|
||||
other => panic!("expected on/change, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_with_motion() {
|
||||
let mut p = NoMovement::new();
|
||||
let mut s = still_snap(60 + 10);
|
||||
s.motion = 0.02;
|
||||
for t in 0..(30 * 60 + 5) {
|
||||
let mut s2 = s.clone();
|
||||
s2.since_start = Duration::from_secs(60 + 10 + t as u64);
|
||||
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brief_motion_resets_timer() {
|
||||
let mut p = NoMovement::new();
|
||||
let _ = p.tick(&still_snap(60 + 10), &cfg());
|
||||
// 25 min in — almost there.
|
||||
let _ = p.tick(&still_snap(60 + 10 + 25 * 60), &cfg());
|
||||
// Motion blip resets.
|
||||
let mut blip = still_snap(60 + 10 + 25 * 60 + 1);
|
||||
blip.motion = 0.05;
|
||||
let _ = p.tick(&blip, &cfg());
|
||||
// 5 min more — should NOT fire because timer reset.
|
||||
let state = p.tick(&still_snap(60 + 10 + 30 * 60 + 2), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_on_motion_after_active() {
|
||||
let mut p = NoMovement::new();
|
||||
let _ = p.tick(&still_snap(60 + 10), &cfg());
|
||||
let _ = p.tick(&still_snap(60 + 10 + 30 * 60), &cfg());
|
||||
assert!(p.active);
|
||||
let mut s = still_snap(60 + 10 + 30 * 60 + 1);
|
||||
s.motion = 0.10;
|
||||
let state = p.tick(&s, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(!active && changed);
|
||||
}
|
||||
other => panic!("expected off/change, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Room-active primitive (§3.12.1 row 3).
|
||||
//!
|
||||
//! Enter `room_active = ON` when presence is true and motion has been
|
||||
//! above `room_active_motion_threshold` (default 10 %) at any point in
|
||||
//! a rolling `room_active_window` (default 30 s).
|
||||
//!
|
||||
//! Exit when no motion above threshold for `room_active_exit_idle`
|
||||
//! (default 10 min) OR presence drops false.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RoomActive {
|
||||
pub active: bool,
|
||||
last_motion: Option<Duration>,
|
||||
}
|
||||
|
||||
impl RoomActive {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let above_thresh = snap.motion >= cfg.room_active_motion_threshold;
|
||||
if above_thresh && snap.presence {
|
||||
self.last_motion = Some(snap.since_start);
|
||||
}
|
||||
|
||||
let recent_motion = matches!(
|
||||
self.last_motion,
|
||||
Some(t) if snap.since_start.saturating_sub(t) < cfg.room_active_window
|
||||
);
|
||||
|
||||
if !self.active && recent_motion && snap.presence {
|
||||
self.active = true;
|
||||
return PrimitiveState::Boolean {
|
||||
active: true,
|
||||
changed: true,
|
||||
reason: Reason::new(&["motion>10%", "presence=true", "window<30s"]),
|
||||
};
|
||||
}
|
||||
if self.active {
|
||||
let idle_long = matches!(
|
||||
self.last_motion,
|
||||
Some(t) if snap.since_start.saturating_sub(t) >= cfg.room_active_exit_idle
|
||||
) || self.last_motion.is_none();
|
||||
if !snap.presence || idle_long {
|
||||
self.active = false;
|
||||
let mut tags = Vec::new();
|
||||
if !snap.presence { tags.push("presence=false"); }
|
||||
if idle_long { tags.push("idle>=10min"); }
|
||||
return PrimitiveState::Boolean {
|
||||
active: false,
|
||||
changed: true,
|
||||
reason: Reason::new(&tags),
|
||||
};
|
||||
}
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig {
|
||||
PrimitiveConfig::default()
|
||||
}
|
||||
|
||||
fn snap(t_secs: u64, motion: f64, presence: bool) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence,
|
||||
motion,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_during_warmup() {
|
||||
let mut p = RoomActive::new();
|
||||
let s = snap(30, 0.5, true);
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_high_motion_with_presence() {
|
||||
let mut p = RoomActive::new();
|
||||
let s = snap(120, 0.4, true);
|
||||
let state = p.tick(&s, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(active);
|
||||
assert!(changed);
|
||||
}
|
||||
other => panic!("expected on/change, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_without_presence() {
|
||||
let mut p = RoomActive::new();
|
||||
let state = p.tick(&snap(120, 0.4, false), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_below_threshold() {
|
||||
let mut p = RoomActive::new();
|
||||
let state = p.tick(&snap(120, 0.05, true), &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_on_presence_drop() {
|
||||
let mut p = RoomActive::new();
|
||||
let _ = p.tick(&snap(120, 0.4, true), &cfg());
|
||||
let state = p.tick(&snap(125, 0.4, false), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(!active);
|
||||
assert!(changed);
|
||||
}
|
||||
other => panic!("expected off/change, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_on_extended_idle() {
|
||||
let mut p = RoomActive::new();
|
||||
let _ = p.tick(&snap(120, 0.4, true), &cfg());
|
||||
// Idle below threshold for >10 min.
|
||||
let state = p.tick(&snap(120 + 600, 0.02, true), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, .. } => assert!(!active),
|
||||
other => panic!("expected off, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Someone-sleeping primitive (§3.12.1 row 1).
|
||||
//!
|
||||
//! **Definition (v1):**
|
||||
//!
|
||||
//! Enter `someone_sleeping = ON` when ALL of the following hold for
|
||||
//! `sleep_dwell` (default 300 s):
|
||||
//! - `presence == true`
|
||||
//! - `motion < 0.05` (rolling)
|
||||
//! - `breathing_rate_bpm ∈ [8.0, 20.0]` (rolling, conf ≥ 0.5)
|
||||
//!
|
||||
//! Exit when `motion > 0.15` for ≥30 s OR presence drops false.
|
||||
//!
|
||||
//! Heart-rate variability check is deferred to v2 because the broadcast
|
||||
//! channel doesn't yet emit HRV; v1 fires on motion + BR + presence
|
||||
//! which is the minimum that detects sleep cleanly in the ADR-079
|
||||
//! paired-capture validation set.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SomeoneSleeping {
|
||||
pub active: bool,
|
||||
enter_since: Option<Duration>,
|
||||
exit_since: Option<Duration>,
|
||||
}
|
||||
|
||||
impl SomeoneSleeping {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Process one snapshot, return state change (if any).
|
||||
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
||||
if snap.since_start < cfg.warmup {
|
||||
return PrimitiveState::Idle;
|
||||
}
|
||||
let br_ok = matches!(snap.breathing_rate_bpm, Some(bpm) if (8.0..=20.0).contains(&bpm))
|
||||
&& snap.vital_confidence >= 0.5;
|
||||
let motion_low = snap.motion < 0.05;
|
||||
let presence_ok = snap.presence;
|
||||
|
||||
if !self.active {
|
||||
if presence_ok && motion_low && br_ok {
|
||||
let start = *self.enter_since.get_or_insert(snap.since_start);
|
||||
if snap.since_start.saturating_sub(start) >= cfg.sleep_dwell {
|
||||
self.active = true;
|
||||
self.exit_since = None;
|
||||
return PrimitiveState::Boolean {
|
||||
active: true,
|
||||
changed: true,
|
||||
reason: Reason::new(&[
|
||||
"presence=true",
|
||||
"motion<5%",
|
||||
"br=8-20bpm",
|
||||
"dwell>=5min",
|
||||
]),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
self.enter_since = None;
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
} else {
|
||||
// Active — check exit conditions.
|
||||
let exiting = !presence_ok || snap.motion > 0.15;
|
||||
if exiting {
|
||||
let start = *self.exit_since.get_or_insert(snap.since_start);
|
||||
// Presence-drop is immediate; motion-spike requires 30s dwell.
|
||||
if !presence_ok || snap.since_start.saturating_sub(start) >= Duration::from_secs(30) {
|
||||
self.active = false;
|
||||
self.enter_since = None;
|
||||
self.exit_since = None;
|
||||
let mut tags = Vec::new();
|
||||
if !presence_ok { tags.push("presence=false"); }
|
||||
if snap.motion > 0.15 { tags.push("motion>15%"); }
|
||||
return PrimitiveState::Boolean {
|
||||
active: false,
|
||||
changed: true,
|
||||
reason: Reason::new(&tags),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
self.exit_since = None;
|
||||
}
|
||||
PrimitiveState::Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PrimitiveConfig {
|
||||
PrimitiveConfig::default()
|
||||
}
|
||||
|
||||
fn sleeping_snap(t_secs: u64) -> RawSnapshot {
|
||||
RawSnapshot {
|
||||
since_start: Duration::from_secs(t_secs),
|
||||
presence: true,
|
||||
motion: 0.02,
|
||||
breathing_rate_bpm: Some(13.0),
|
||||
vital_confidence: 0.85,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_during_warmup() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
let s = sleeping_snap(30);
|
||||
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_after_dwell_post_warmup() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
// Tick after warmup but before dwell — idle.
|
||||
assert!(matches!(p.tick(&sleeping_snap(60 + 100), &cfg()), PrimitiveState::Idle));
|
||||
// Tick after warmup + dwell — should activate (start was at t=160).
|
||||
let state = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(active);
|
||||
assert!(changed);
|
||||
}
|
||||
other => panic!("expected boolean on/change, got {:?}", other),
|
||||
}
|
||||
assert!(p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_when_motion_high() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
let mut s = sleeping_snap(60 + 100);
|
||||
s.motion = 0.30;
|
||||
for t in 0..600u64 {
|
||||
let mut s2 = s.clone();
|
||||
s2.since_start = Duration::from_secs(60 + 100 + t);
|
||||
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_when_br_out_of_range() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
let mut s = sleeping_snap(60 + 100);
|
||||
s.breathing_rate_bpm = Some(30.0); // too fast
|
||||
let s2 = {
|
||||
let mut x = s.clone();
|
||||
x.since_start = Duration::from_secs(60 + 100 + 600);
|
||||
x
|
||||
};
|
||||
let _ = p.tick(&s, &cfg());
|
||||
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_on_presence_false_immediately() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
|
||||
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
||||
assert!(p.active);
|
||||
// Presence drops.
|
||||
let mut s = sleeping_snap(60 + 100 + 301);
|
||||
s.presence = false;
|
||||
let state = p.tick(&s, &cfg());
|
||||
match state {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(!active);
|
||||
assert!(changed);
|
||||
}
|
||||
other => panic!("expected boolean off/change, got {:?}", other),
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exits_on_sustained_motion_only_after_30s() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
|
||||
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
||||
assert!(p.active);
|
||||
// Motion spikes for 10 s — too short to exit.
|
||||
let mut s = sleeping_snap(60 + 100 + 310);
|
||||
s.motion = 0.20;
|
||||
let state = p.tick(&s, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
assert!(p.active);
|
||||
// Motion sustained 30 s → exit.
|
||||
let mut s2 = sleeping_snap(60 + 100 + 340);
|
||||
s2.motion = 0.20;
|
||||
let state2 = p.tick(&s2, &cfg());
|
||||
match state2 {
|
||||
PrimitiveState::Boolean { active, changed, .. } => {
|
||||
assert!(!active);
|
||||
assert!(changed);
|
||||
}
|
||||
other => panic!("expected boolean off/change, got {:?}", other),
|
||||
}
|
||||
assert!(!p.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brief_motion_blip_does_not_exit() {
|
||||
let mut p = SomeoneSleeping::new();
|
||||
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
|
||||
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
||||
assert!(p.active);
|
||||
// Motion spikes briefly then returns to low.
|
||||
let mut s_spike = sleeping_snap(60 + 100 + 305);
|
||||
s_spike.motion = 0.20;
|
||||
let _ = p.tick(&s_spike, &cfg());
|
||||
// Back to low motion within 30s.
|
||||
let s_calm = sleeping_snap(60 + 100 + 315);
|
||||
let state = p.tick(&s_calm, &cfg());
|
||||
assert!(matches!(state, PrimitiveState::Idle));
|
||||
// Still active because exit dwell was reset by calm sample.
|
||||
assert!(p.active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! ADR-115 P4 — MQTT integration tests against a real broker.
|
||||
//!
|
||||
//! These tests require an MQTT broker reachable at `localhost:11883`
|
||||
//! (overridable via `RUVIEW_TEST_MQTT_PORT`). They are gated behind the
|
||||
//! `mqtt` feature (which pulls in `rumqttc`) **and** behind the
|
||||
//! `RUVIEW_RUN_INTEGRATION` env var so the default test run on
|
||||
//! developer machines doesn't break when there's no broker.
|
||||
//!
|
||||
//! In CI, the `.github/workflows/mqtt-integration.yml` workflow spins
|
||||
//! up a Mosquitto sidecar container, sets `RUVIEW_RUN_INTEGRATION=1`,
|
||||
//! and runs `cargo test -p wifi-densepose-sensing-server --features mqtt
|
||||
//! --test mqtt_integration`.
|
||||
//!
|
||||
//! ## What these tests prove
|
||||
//!
|
||||
//! 1. The publisher connects to a real broker and emits HA discovery
|
||||
//! `config` topics for every enabled entity.
|
||||
//! 2. The discovery payloads round-trip back via `mosquitto_sub`-style
|
||||
//! subscription with the exact JSON shape `mqtt::discovery` produces.
|
||||
//! 3. Availability is published `online` retained on connect and
|
||||
//! `offline` on graceful disconnect (the LWT/disconnect path).
|
||||
//! 4. Privacy mode strips heart-rate / breathing-rate / pose discovery
|
||||
//! from the wire entirely — the integration confirms the strip
|
||||
//! happens at the broker boundary, not just in unit-test logic.
|
||||
//!
|
||||
//! ## Why this is gated
|
||||
//!
|
||||
//! We need a live broker. Pulling `rumqttd` into the dev-dep tree as an
|
||||
//! embedded broker would work in theory but adds 60+ transitive deps
|
||||
//! and 1+ min compile time to every `cargo test` invocation on every
|
||||
//! developer's machine. Gating behind an env var keeps the default
|
||||
//! `cargo test --workspace` fast.
|
||||
|
||||
#![cfg(feature = "mqtt")]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet, QoS};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use wifi_densepose_sensing_server::mqtt::{
|
||||
config::{MqttConfig, PublishRates, TlsConfig},
|
||||
publisher::{spawn, OwnedDiscoveryBuilder},
|
||||
state::VitalsSnapshot,
|
||||
};
|
||||
|
||||
fn should_run() -> Option<u16> {
|
||||
if std::env::var("RUVIEW_RUN_INTEGRATION").is_err() {
|
||||
eprintln!("[skip] set RUVIEW_RUN_INTEGRATION=1 + run a broker on the test port");
|
||||
return None;
|
||||
}
|
||||
let port = std::env::var("RUVIEW_TEST_MQTT_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(11883);
|
||||
Some(port)
|
||||
}
|
||||
|
||||
fn make_cfg(port: u16, privacy_mode: bool, label: &str) -> std::sync::Arc<MqttConfig> {
|
||||
std::sync::Arc::new(MqttConfig {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
username: None,
|
||||
password: None,
|
||||
// Per-test client_id so cargo test --test-threads=1 doesn't make
|
||||
// mosquitto kick the previous session when the next test connects
|
||||
// with the same client_id (default MQTT session-takeover behaviour).
|
||||
client_id: format!("ruview-int-test-{}-{}", std::process::id(), label),
|
||||
discovery_prefix: "homeassistant".into(),
|
||||
tls: TlsConfig::Off,
|
||||
refresh_secs: 60,
|
||||
rates: PublishRates {
|
||||
// Fast rates so the test gets a sample quickly.
|
||||
vitals_hz: 5.0,
|
||||
motion_hz: 5.0,
|
||||
count_hz: 5.0,
|
||||
rssi_hz: 5.0,
|
||||
pose_hz: 5.0,
|
||||
},
|
||||
publish_pose: false,
|
||||
privacy_mode,
|
||||
})
|
||||
}
|
||||
|
||||
fn make_builder(node: &str) -> OwnedDiscoveryBuilder {
|
||||
OwnedDiscoveryBuilder {
|
||||
discovery_prefix: "homeassistant".into(),
|
||||
node_id: node.into(),
|
||||
node_friendly_name: Some(format!("Test {}", node)),
|
||||
sw_version: "0.7.0-test".into(),
|
||||
model: "integration".into(),
|
||||
via_device: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn subscribe_client(port: u16, topics: &[&str]) -> (AsyncClient, EventLoop) {
|
||||
// Per-call unique client_id so subscribers across tests don't take
|
||||
// each other over.
|
||||
let suffix: u64 = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut opts = MqttOptions::new(
|
||||
format!("ruview-test-sub-{}-{}", std::process::id(), suffix),
|
||||
"127.0.0.1",
|
||||
port,
|
||||
);
|
||||
opts.set_keep_alive(Duration::from_secs(10));
|
||||
opts.set_clean_session(true);
|
||||
let (client, mut eventloop) = AsyncClient::new(opts, 256);
|
||||
for t in topics {
|
||||
client.subscribe(*t, QoS::AtLeastOnce).await.unwrap();
|
||||
}
|
||||
|
||||
// Drive the eventloop until we see the SubAck for our last subscribe.
|
||||
// Without this the SUBSCRIBE packet is only queued in rumqttc's
|
||||
// outbound channel; it doesn't reach the broker until something
|
||||
// pumps the eventloop. The caller's `collect_published` does that,
|
||||
// but by then the publisher may already have emitted state
|
||||
// messages — including the retained ones that won't be re-sent.
|
||||
let until = tokio::time::Instant::now() + Duration::from_secs(3);
|
||||
while tokio::time::Instant::now() < until {
|
||||
let remain = until - tokio::time::Instant::now();
|
||||
match timeout(remain, eventloop.poll()).await {
|
||||
Ok(Ok(Event::Incoming(Packet::SubAck(_)))) => break,
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[subscribe_client] eventloop error before SubAck: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
(client, eventloop)
|
||||
}
|
||||
|
||||
async fn collect_published(
|
||||
eventloop: &mut EventLoop,
|
||||
deadline: Duration,
|
||||
) -> Vec<(String, Vec<u8>, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let until = tokio::time::Instant::now() + deadline;
|
||||
while tokio::time::Instant::now() < until {
|
||||
let remain = until - tokio::time::Instant::now();
|
||||
match timeout(remain, eventloop.poll()).await {
|
||||
Ok(Ok(Event::Incoming(Packet::Publish(p)))) => {
|
||||
out.push((p.topic, p.payload.to_vec(), p.retain));
|
||||
}
|
||||
Ok(Ok(_)) => {} // ignore other events
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[test] eventloop error: {}", e);
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovery_topics_appear_on_broker() {
|
||||
let Some(port) = should_run() else { return; };
|
||||
|
||||
// Subscriber wired first so we don't miss the initial discovery burst.
|
||||
let (sub, mut sub_loop) =
|
||||
subscribe_client(port, &["homeassistant/#"]).await;
|
||||
|
||||
// Spawn the publisher.
|
||||
let cfg = make_cfg(port, false, "discovery");
|
||||
let builder = make_builder("inttest1");
|
||||
let (_tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let _handle = spawn(cfg, builder, rx);
|
||||
|
||||
// Drain the subscriber for up to 6 s — enough for initial discovery
|
||||
// + first availability publication.
|
||||
let msgs = collect_published(&mut sub_loop, Duration::from_secs(6)).await;
|
||||
let _ = sub.disconnect().await;
|
||||
|
||||
// Assertions: at least the presence + heart_rate + fall discovery
|
||||
// configs should have landed.
|
||||
let topics: Vec<&str> = msgs.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||
let presence_cfg = topics
|
||||
.iter()
|
||||
.any(|t| t.ends_with("/wifi_densepose_inttest1/presence/config"));
|
||||
let hr_cfg = topics
|
||||
.iter()
|
||||
.any(|t| t.ends_with("/wifi_densepose_inttest1/heart_rate/config"));
|
||||
let fall_cfg = topics
|
||||
.iter()
|
||||
.any(|t| t.ends_with("/wifi_densepose_inttest1/fall/config"));
|
||||
|
||||
assert!(presence_cfg, "missing presence discovery topic in {:?}", topics);
|
||||
assert!(hr_cfg, "missing heart_rate discovery topic in {:?}", topics);
|
||||
assert!(fall_cfg, "missing fall discovery topic in {:?}", topics);
|
||||
|
||||
// Spot-check the JSON shape of one discovery payload.
|
||||
let presence_payload = msgs
|
||||
.iter()
|
||||
.find(|(t, _, _)| t.ends_with("/presence/config"))
|
||||
.map(|(_, p, _)| p.clone())
|
||||
.unwrap();
|
||||
let json: Value = serde_json::from_slice(&presence_payload).unwrap();
|
||||
assert_eq!(json["device_class"], "occupancy");
|
||||
assert_eq!(json["payload_on"], "ON");
|
||||
assert_eq!(json["payload_off"], "OFF");
|
||||
assert!(json["unique_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("wifi_densepose_"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn privacy_mode_suppresses_biometric_discovery() {
|
||||
let Some(port) = should_run() else { return; };
|
||||
|
||||
let (sub, mut sub_loop) =
|
||||
subscribe_client(port, &["homeassistant/#"]).await;
|
||||
|
||||
let cfg = make_cfg(port, /* privacy_mode = */ true, "privacy");
|
||||
let builder = make_builder("inttest2");
|
||||
let (_tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let _handle = spawn(cfg, builder, rx);
|
||||
|
||||
let msgs = collect_published(&mut sub_loop, Duration::from_secs(6)).await;
|
||||
let _ = sub.disconnect().await;
|
||||
|
||||
let topics: Vec<&str> = msgs.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||
|
||||
// Biometric discovery must NOT appear.
|
||||
let leaked_hr = topics
|
||||
.iter()
|
||||
.any(|t| t.contains("/inttest2/heart_rate/"));
|
||||
let leaked_br = topics
|
||||
.iter()
|
||||
.any(|t| t.contains("/inttest2/breathing_rate/"));
|
||||
let leaked_pose = topics.iter().any(|t| t.contains("/inttest2/pose/"));
|
||||
|
||||
assert!(!leaked_hr, "heart_rate leaked under privacy mode: {:?}", topics);
|
||||
assert!(!leaked_br, "breathing_rate leaked under privacy mode");
|
||||
assert!(!leaked_pose, "pose leaked under privacy mode");
|
||||
|
||||
// Non-biometric entities + semantic primitives still appear.
|
||||
let presence_cfg = topics
|
||||
.iter()
|
||||
.any(|t| t.ends_with("/wifi_densepose_inttest2/presence/config"));
|
||||
let sleeping_cfg = topics.iter().any(|t| {
|
||||
t.ends_with("/wifi_densepose_inttest2/someone_sleeping/config")
|
||||
});
|
||||
|
||||
assert!(presence_cfg, "presence missing in privacy mode");
|
||||
assert!(
|
||||
sleeping_cfg,
|
||||
"someone_sleeping must remain in privacy mode (it's inferred, not biometric)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn state_messages_published_on_snapshot_broadcast() {
|
||||
let Some(port) = should_run() else { return; };
|
||||
|
||||
// Subscribe to the entire homeassistant tree so the diagnostic
|
||||
// capture shows EVERYTHING the publisher is doing, not just
|
||||
// the narrow presence/state filter — narrow filters can hide
|
||||
// ordering issues (e.g., if the publisher is publishing only
|
||||
// discovery and not state, a narrow filter on state can't tell
|
||||
// us that).
|
||||
let (sub, mut sub_loop) = subscribe_client(port, &["homeassistant/#"]).await;
|
||||
|
||||
let cfg = make_cfg(port, false, "state");
|
||||
let builder = make_builder("inttest3");
|
||||
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
|
||||
let _handle = spawn(cfg, builder, rx);
|
||||
|
||||
// Iter 46 — instead of front-loading 6 snapshots and hoping the
|
||||
// publisher's startup beats them, drive snapshots in a background
|
||||
// task THROUGHOUT the capture window. CI runners can be slow to
|
||||
// boot the publisher (mosquitto sidecar + cold cargo cache + slow
|
||||
// QoS-1 discovery publishes), so a "publisher must be ready by
|
||||
// t=3s" assumption is fragile. Steady-state ON/OFF traffic for the
|
||||
// full 14 s window guarantees both states appear in the capture
|
||||
// even if the first 3-5 s of publishes are missed.
|
||||
let tx_bg = tx.clone();
|
||||
let drive = tokio::spawn(async move {
|
||||
// Brief warm-up before first publish.
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
for i in 0..40 {
|
||||
let _ = tx_bg.send(VitalsSnapshot {
|
||||
node_id: "inttest3".into(),
|
||||
timestamp_ms: 1779_512_400_000 + (i as i64) * 300,
|
||||
presence: i % 2 == 0,
|
||||
fall_detected: false,
|
||||
motion: if i % 2 == 0 { 0.40 } else { 0.02 },
|
||||
motion_energy: 800.0,
|
||||
presence_score: if i % 2 == 0 { 0.95 } else { 0.10 },
|
||||
breathing_rate_bpm: Some(14.0),
|
||||
heartrate_bpm: Some(72.0),
|
||||
n_persons: if i % 2 == 0 { 1 } else { 0 },
|
||||
rssi_dbm: Some(-48.0),
|
||||
vital_confidence: 0.9,
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// 14 s window covers warm-up + 12 s of steady-state ON/OFF traffic.
|
||||
let msgs = collect_published(&mut sub_loop, Duration::from_secs(14)).await;
|
||||
drive.abort();
|
||||
let _ = sub.disconnect().await;
|
||||
|
||||
// Diagnostic: dump every captured topic so we can see what (if
|
||||
// anything) the subscriber received. CI runs with --nocapture, so
|
||||
// this lands in the workflow log when the test fails.
|
||||
eprintln!("[diag] subscriber captured {} messages:", msgs.len());
|
||||
for (t, p, retain) in &msgs {
|
||||
eprintln!(
|
||||
"[diag] retain={} topic={} payload={}",
|
||||
retain,
|
||||
t,
|
||||
String::from_utf8_lossy(p).chars().take(80).collect::<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter for THIS test's presence state messages. The topic format
|
||||
// is `homeassistant/binary_sensor/wifi_densepose_<node>/presence/state`
|
||||
// — `wifi_densepose_inttest3` is one path segment with an underscore
|
||||
// separator, NOT slash-separated. The previous version looked for
|
||||
// `/inttest3/presence/state` (with leading slash) which is the bug
|
||||
// that took 5 commits + a diagnostic dump to find.
|
||||
let presence_states: Vec<String> = msgs
|
||||
.iter()
|
||||
.filter(|(t, _, _)| t.contains("wifi_densepose_inttest3/presence/state"))
|
||||
.map(|(_, p, _)| String::from_utf8_lossy(p).into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
presence_states.iter().any(|p| p == "ON"),
|
||||
"expected ON state, got {:?} (of {} total captured)",
|
||||
presence_states,
|
||||
msgs.len(),
|
||||
);
|
||||
assert!(
|
||||
presence_states.iter().any(|p| p == "OFF"),
|
||||
"expected OFF state, got {:?}",
|
||||
presence_states
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user