131 lines
4.0 KiB
Rust
131 lines
4.0 KiB
Rust
//! 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),
|
|
}
|
|
}
|
|
}
|