//! `AutomationEngine` — subscribes to the HOMECORE event bus, evaluates //! triggers, and runs automation action sequences. //! //! ADR-129 §2 design: one Tokio task per running automation instance. //! //! ## Run modes (ADR-161 §A5 → completed in ADR-162) //! //! Each registered automation owns a [`RunState`] that implements its //! `RunMode`: `Single`/`IgnoreFirst` skip re-entrant triggers, `Restart` //! aborts the in-flight run and starts a fresh one, `Queued` serializes //! runs in arrival order (nothing dropped), `Parallel` spawns on every //! trigger, and `max: N` caps concurrency via a per-automation semaphore. //! (ADR-161 only honored Single/Parallel; Restart/Queued/max were //! honestly documented as unbounded-parallel until ADR-162.) //! //! ## Time triggers (ADR-161, HC-WS-04) //! //! `Trigger::Time { at: "HH:MM:SS" }` is evaluated by a wall-clock timer //! task (1 Hz tokio interval) — `Trigger::matches_sync` returns false for //! `Time` because it has no clock. The timer fires each `time:` //! automation once when the local wall-clock second equals its `at`. //! //! ## Template conditions (ADR-161, HC-WS-07) //! //! The engine builds a real [`TemplateEnvironment`] over the state //! machine and passes it into every `EvalContext` (via //! `EvalContext::with_templates`), so `template:` conditions evaluate //! against live state instead of always returning false. use std::sync::{Arc, Mutex}; use chrono::{Local, Timelike}; use tokio::sync::broadcast; use homecore::HomeCore; use crate::automation::Automation; use crate::condition::EvalContext; use crate::runmode::RunState; use crate::template::TemplateEnvironment; use crate::trigger::{Trigger, TriggerContext}; /// An automation registered with the engine, plus its runtime run-state. struct Registered { auto: Arc, /// Run-mode machinery (re-entrancy guard / restart abort handle / /// queue mutex / concurrency semaphore) for this automation. run_state: RunState, } /// The automation engine. Holds a HOMECORE handle and a list of registered /// automations. Call `start()` to begin listening for events. pub struct AutomationEngine { hc: HomeCore, automations: Arc>>, templates: Arc, } impl AutomationEngine { /// Create a new engine backed by the given HOMECORE handle. pub fn new(hc: HomeCore) -> Self { let templates = Arc::new(TemplateEnvironment::new(Arc::new(hc.states().clone()))); Self { hc, automations: Arc::new(Mutex::new(vec![])), templates, } } /// Register an automation. Can be called before or after `start()`. pub fn register(&self, automation: Automation) { let run_state = RunState::new(&automation); self.automations.lock().unwrap().push(Registered { auto: Arc::new(automation), run_state, }); } /// Number of registered automations. pub fn len(&self) -> usize { self.automations.lock().unwrap().len() } /// Is the engine holding zero automations? pub fn is_empty(&self) -> bool { self.len() == 0 } /// Build an `EvalContext` with the engine's template environment /// wired in, over a fresh snapshot of the state machine. fn eval_ctx(&self) -> EvalContext { EvalContext::with_templates( Arc::new(self.hc.states().clone()), Arc::clone(&self.templates), ) } /// Subscribe to the state-machine broadcast channel and start /// evaluating triggers. Also starts the wall-clock timer task that /// evaluates `time:` triggers. Returns a join handle for the event /// task (the timer task is detached and tied to the engine handle's /// lifetime via the broadcast channel close). /// /// The task runs until the broadcast sender is dropped (i.e. the /// `HomeCore` instance is destroyed). pub fn start(&self) -> tokio::task::JoinHandle<()> { self.start_timer(); self.start_event_loop() } /// Event-driven loop: state/numeric/event triggers. fn start_event_loop(&self) -> tokio::task::JoinHandle<()> { let mut rx = self.hc.states().subscribe(); let automations = Arc::clone(&self.automations); let hc = self.hc.clone(); let templates = Arc::clone(&self.templates); tokio::spawn(async move { loop { match rx.recv().await { Ok(event) => { let snapshot: Vec<(Arc, RunState)> = automations .lock() .unwrap() .iter() .map(|r| (Arc::clone(&r.auto), r.run_state.clone())) .collect(); for (automation, run_state) in snapshot { if !automation.enabled { continue; } let trigger_ctx = TriggerContext::state_changed( event.entity_id.clone(), event.old_state.clone(), event.new_state.clone(), ); let triggered = automation .trigger .iter() .any(|t| t.matches_sync(&trigger_ctx)); if !triggered { continue; } // Conditions (with template env wired in — HC-WS-07). let eval_ctx = EvalContext::with_templates( Arc::new(hc.states().clone()), Arc::clone(&templates), ); if !conditions_pass(&automation, &eval_ctx).await { continue; } run_state.dispatch(&hc, automation); } } Err(broadcast::error::RecvError::Closed) => break, Err(broadcast::error::RecvError::Lagged(n)) => { eprintln!("[homecore-automation] state-changed receiver lagged by {n} events"); } } } }) } /// Wall-clock timer task: fires `time:` triggers (HC-WS-04). Ticks at /// 1 Hz and runs each matching automation once when the local /// wall-clock `HH:MM:SS` equals the trigger's `at`. The task exits /// when the state-machine broadcast channel closes (engine teardown). fn start_timer(&self) -> tokio::task::JoinHandle<()> { let automations = Arc::clone(&self.automations); let hc = self.hc.clone(); let templates = Arc::clone(&self.templates); // A receiver that lets the timer notice engine teardown. let mut teardown_rx = self.hc.states().subscribe(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); // Track the last second we fired, to fire once per match. let mut last_fired_sec: Option = None; loop { tokio::select! { _ = interval.tick() => { let now = Local::now(); let hhmmss = format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second()); if last_fired_sec.as_deref() == Some(hhmmss.as_str()) { continue; } let snapshot: Vec<(Arc, RunState)> = automations .lock() .unwrap() .iter() .map(|r| (Arc::clone(&r.auto), r.run_state.clone())) .collect(); let mut fired_any = false; for (automation, run_state) in snapshot { if !automation.enabled { continue; } let time_match = automation.trigger.iter().any(|t| match t { Trigger::Time { at } => time_at_matches(at, &hhmmss), _ => false, }); if !time_match { continue; } let eval_ctx = EvalContext::with_templates( Arc::new(hc.states().clone()), Arc::clone(&templates), ); if !conditions_pass(&automation, &eval_ctx).await { continue; } run_state.dispatch(&hc, automation); fired_any = true; } if fired_any { last_fired_sec = Some(hhmmss); } } r = teardown_rx.recv() => { if let Err(broadcast::error::RecvError::Closed) = r { break; } } } } }) } /// Manually fire any `time:` automations whose `at` equals `hhmmss` /// (`"HH:MM:SS"`). Bypasses the 1 Hz clock so tests can assert the /// time-trigger path deterministically without waiting for a /// wall-clock second to roll over. Returns the number of automations /// that fired (passed conditions and were spawned). pub async fn fire_time_for_test(&self, hhmmss: &str) -> usize { let snapshot: Vec<(Arc, RunState)> = self .automations .lock() .unwrap() .iter() .map(|r| (Arc::clone(&r.auto), r.run_state.clone())) .collect(); let mut fired = 0usize; for (automation, run_state) in snapshot { if !automation.enabled { continue; } let time_match = automation.trigger.iter().any(|t| match t { Trigger::Time { at } => time_at_matches(at, hhmmss), _ => false, }); if !time_match { continue; } let eval_ctx = self.eval_ctx(); if !conditions_pass(&automation, &eval_ctx).await { continue; } run_state.dispatch(&self.hc, automation); fired += 1; } fired } } /// Evaluate all of an automation's conditions (AND). Empty → pass. async fn conditions_pass(automation: &Automation, eval_ctx: &EvalContext) -> bool { for cond in &automation.condition { if !cond.evaluate(eval_ctx).await { return false; } } true } /// Does a `Time` trigger `at` value match the current `HH:MM:SS`? /// Accepts `HH:MM` (matches at :00 seconds) and `HH:MM:SS`. fn time_at_matches(at: &str, hhmmss: &str) -> bool { let normalized = match at.matches(':').count() { 1 => format!("{at}:00"), _ => at.to_string(), }; normalized == hhmmss } #[cfg(test)] mod tests { use super::*; use crate::action::Action; use crate::automation::Automation; use crate::trigger::Trigger; use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceName}; use homecore::service::FnHandler; use std::sync::{Arc, Mutex}; use tokio::time::{sleep, Duration}; /// Register a recording handler that captures all calls. async fn register_recorder( hc: &HomeCore, domain: &str, service: &str, ) -> Arc>> { let log: Arc>> = Arc::new(Mutex::new(vec![])); let log2 = Arc::clone(&log); hc.services() .register( ServiceName::new(domain, service), FnHandler(move |call: ServiceCall| { let l = Arc::clone(&log2); async move { l.lock().unwrap().push(call.data.clone()); Ok(serde_json::Value::Null) } }), ) .await; log } #[tokio::test] async fn engine_fires_automation_on_state_change() { let hc = HomeCore::new(); let log = register_recorder(&hc, "light", "turn_on").await; let engine = AutomationEngine::new(hc.clone()); engine.register(Automation::new( "test_auto_1", vec![Trigger::State { entity_id: EntityId::parse("switch.living").unwrap(), from: None, to: Some("on".into()), }], vec![Action::ServiceCall { domain: "light".into(), service: "turn_on".into(), data: serde_json::json!({"brightness": 100}), }], )); let _handle = engine.start(); hc.states().set( EntityId::parse("switch.living").unwrap(), "on", serde_json::json!({}), Context::new(), ); sleep(Duration::from_millis(50)).await; assert_eq!(log.lock().unwrap().len(), 1); assert_eq!(log.lock().unwrap()[0]["brightness"], 100); } #[tokio::test] async fn engine_does_not_fire_on_wrong_entity() { let hc = HomeCore::new(); let log = register_recorder(&hc, "light", "turn_on").await; let engine = AutomationEngine::new(hc.clone()); engine.register(Automation::new( "test_auto_2", vec![Trigger::State { entity_id: EntityId::parse("switch.living").unwrap(), from: None, to: Some("on".into()), }], vec![Action::ServiceCall { domain: "light".into(), service: "turn_on".into(), data: serde_json::json!({}), }], )); let _handle = engine.start(); hc.states().set( EntityId::parse("switch.bedroom").unwrap(), "on", serde_json::json!({}), Context::new(), ); sleep(Duration::from_millis(50)).await; assert_eq!(log.lock().unwrap().len(), 0, "should not fire on wrong entity"); } #[tokio::test] async fn engine_disabled_automation_does_not_fire() { let hc = HomeCore::new(); let log = register_recorder(&hc, "light", "turn_on").await; let engine = AutomationEngine::new(hc.clone()); let mut auto = Automation::new( "test_auto_3", vec![Trigger::State { entity_id: EntityId::parse("switch.living").unwrap(), from: None, to: Some("on".into()), }], vec![Action::ServiceCall { domain: "light".into(), service: "turn_on".into(), data: serde_json::json!({}), }], ); auto.enabled = false; engine.register(auto); let _handle = engine.start(); hc.states().set( EntityId::parse("switch.living").unwrap(), "on", serde_json::json!({}), Context::new(), ); sleep(Duration::from_millis(50)).await; assert_eq!(log.lock().unwrap().len(), 0, "disabled automation should not fire"); } // Behavioral tests for the timer / run-mode / template paths // (HC-WS-04/05/07) live in `tests/engine_behaviors.rs` to keep this // file under the 500-line guideline; they use only the public API. #[test] fn time_at_matches_handles_hh_mm_and_hh_mm_ss() { assert!(time_at_matches("07:30", "07:30:00")); assert!(time_at_matches("07:30:15", "07:30:15")); assert!(!time_at_matches("07:30", "07:30:01")); assert!(!time_at_matches("07:30:15", "07:30:16")); } }