//! 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, 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 = 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), } } }