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