mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
249d6c327f
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
136 lines
4.1 KiB
Rust
136 lines
4.1 KiB
Rust
//! 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),
|
|
}
|
|
}
|
|
}
|