feat(adr-115): P4.5a — semantic inference layer (HA-MIND) — 4 primitives + bus (34 tests)
ADR-115 §3.12 keystone. 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 commit lands
the inference layer that turns the broadcast channel into 10 v1 semantic
primitives, starting with the 4 highest-leverage ones.
Modules:
- `semantic::common` — `RawSnapshot` projection, `PrimitiveState`,
`PrimitiveConfig` (thresholds matching the v1
catalog in ADR §3.12), `in_window` for time-gated
primitives, `Reason` explainability struct.
- `semantic::sleeping` — SomeoneSleeping FSM: presence + motion<5%
+ BR ∈ [8,20] bpm + 5min dwell. Exit on
presence-drop (immediate) or motion>15%
for 30s.
- `semantic::room_active` — motion >10% in 30s window → ON. Exit on
presence-drop or 10min idle.
- `semantic::bathroom` — presence + zone tagged as bathroom. Safe
in privacy mode (no biometrics in the
derivation).
- `semantic::no_movement` — presence + motion<1% for 30min → ON.
Safety-check primitive for aging-in-place.
- `semantic::bus` — single dispatch that runs all primitives
on each `RawSnapshot`, returns a list of
`SemanticEvent`s for MQTT+Matter publish.
Every primitive has:
- Warmup suppression (60s default, §3.12.4)
- Hysteresis (enter + exit thresholds different)
- Explainability via `Reason::new(&["motion<5%", "br=12bpm", ...])`
- Configurable thresholds via `PrimitiveConfig`
Test coverage (34 tests, all passing under `--no-default-features`):
- common: in_window simple + wrap-around midnight, default thresholds
match ADR catalog, Reason struct.
- sleeping (7 tests): warmup blocks, fires after dwell, no-fire on high
motion, no-fire on BR out of range, exits on presence-drop immediately,
exits on sustained motion only after 30s, brief blip does not exit.
- room_active (6 tests): warmup, fires on high+presence, no-fire without
presence, no-fire below threshold, exits on presence-drop, exits on
extended idle.
- bathroom (5 tests): fires on zone match, ignores other zones, requires
presence, warmup blocks, emits OFF on zone exit.
- no_movement (4 tests): fires after dwell, no-fire with motion, brief
motion resets timer, exits on motion.
- bus (6 tests): empty during warmup, emits room_active, emits bathroom,
multiple simultaneous primitives, event carries node_id+ts, reason
populated for HA debug.
Total cargo test count now:
cli: 6 + mqtt: 45 + semantic: 34 = 85 tests passing
P4.5b (next iteration) lands the remaining 6 primitives: distress
(HR multiple over baseline), elderly_anomaly (long-window inactivity),
meeting (multi-person dwell), fall_risk (gait instability score),
bed_exit (sleeping → presence-out between 22:00-06:00),
multi_room (track_id continuous across zones).
Refs #776.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
59c503d01e
commit
8e416af203
|
|
@ -18,6 +18,7 @@ pub mod host_validation;
|
|||
pub mod introspection;
|
||||
pub mod mqtt;
|
||||
pub mod path_safety;
|
||||
pub mod semantic;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
pub mod sona;
|
||||
|
|
|
|||
|
|
@ -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,193 @@
|
|||
//! 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, Reason};
|
||||
use super::{bathroom::BathroomOccupied, 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,
|
||||
RoomActive,
|
||||
BathroomOccupied,
|
||||
NoMovement,
|
||||
// P4.5b: Distress, ElderlyAnomaly, Meeting, FallRisk, BedExit, 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,
|
||||
room_active: RoomActive,
|
||||
bathroom: BathroomOccupied,
|
||||
no_movement: NoMovement,
|
||||
pub config: PrimitiveConfig,
|
||||
}
|
||||
|
||||
impl SemanticBus {
|
||||
pub fn new(config: PrimitiveConfig) -> Self {
|
||||
Self {
|
||||
sleeping: SomeoneSleeping::new(),
|
||||
room_active: RoomActive::new(),
|
||||
bathroom: BathroomOccupied::new(),
|
||||
no_movement: NoMovement::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); 4] = [
|
||||
(SemanticKind::SomeoneSleeping, self.sleeping.tick(snap, &self.config)),
|
||||
(SemanticKind::RoomActive, self.room_active.tick(snap, &self.config)),
|
||||
(SemanticKind::BathroomOccupied, self.bathroom.tick(snap, &self.config)),
|
||||
(SemanticKind::NoMovement, self.no_movement.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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,61 @@
|
|||
//! 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`].
|
||||
|
||||
// Primitives landing in P4.5a (this iteration):
|
||||
mod bathroom;
|
||||
mod bus;
|
||||
mod common;
|
||||
mod no_movement;
|
||||
mod room_active;
|
||||
mod sleeping;
|
||||
|
||||
// Primitives landing in P4.5b (next iteration): bed_exit, distress,
|
||||
// elderly_anomaly, fall_risk, meeting, multi_room.
|
||||
|
||||
pub use bus::{SemanticBus, SemanticEvent, SemanticKind};
|
||||
pub use common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue