//! `Trigger` enum and `EvaluateTrigger` trait. //! //! Covers the four most common HA trigger platforms as required by ADR-129 P1: //! `state`, `numeric_state`, `time`, and `event`. Additional platforms land //! in P2 (template, zone, sun, MQTT, webhook, etc.). use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use homecore::{EntityId, State}; /// Context produced by a fired trigger. Passed into condition evaluation and /// template rendering as `trigger.*` variables. #[derive(Clone, Debug)] pub struct TriggerContext { /// Which trigger platform fired. pub platform: String, /// Entity ID (for state / numeric_state triggers). pub entity_id: Option, /// New state snapshot (for state / numeric_state triggers). pub to_state: Option>, /// Previous state snapshot (for state / numeric_state triggers). pub from_state: Option>, /// When the trigger fired. pub fired_at: DateTime, /// Event type (for event triggers). pub event_type: Option, } impl TriggerContext { pub fn state_changed( entity_id: EntityId, from: Option>, to: Option>, ) -> Self { Self { platform: "state".into(), entity_id: Some(entity_id), to_state: to, from_state: from, fired_at: Utc::now(), event_type: None, } } pub fn event(event_type: impl Into) -> Self { Self { platform: "event".into(), entity_id: None, to_state: None, from_state: None, fired_at: Utc::now(), event_type: Some(event_type.into()), } } } /// Async evaluation trait. Each trigger variant implements this to decide /// whether a given `TriggerContext` matches its configuration. #[async_trait] pub trait EvaluateTrigger: Send + Sync { async fn matches(&self, ctx: &TriggerContext) -> bool; } /// Trigger configuration. Deserialized from YAML `trigger:` blocks. /// /// Only four platforms are implemented in P1 (ADR-129 §6 Phase 1). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "platform", rename_all = "snake_case")] pub enum Trigger { /// Fires when an entity's state changes. State { entity_id: EntityId, /// Optional: only fire if state was previously this value. #[serde(default)] from: Option, /// Optional: only fire if state transitions to this value. #[serde(default)] to: Option, }, /// Fires when an entity's numeric state crosses a threshold. NumericState { entity_id: EntityId, /// Fire when value rises above this threshold. #[serde(default)] above: Option, /// Fire when value drops below this threshold. #[serde(default)] below: Option, }, /// Fires at a specific time of day (HH:MM:SS). Time { at: String, }, /// Fires when a named domain event is published on the event bus. Event { event_type: String, }, } impl Trigger { /// Synchronous check — does this trigger configuration match the provided /// context? Used directly in tests and by the engine's event loop. pub fn matches_sync(&self, ctx: &TriggerContext) -> bool { match self { Trigger::State { entity_id, from, to } => { let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id); if !eid_match { return false; } if let Some(expected_from) = from { let actual_from = ctx.from_state.as_ref().map(|s| s.state.as_str()).unwrap_or("unavailable"); if actual_from != expected_from.as_str() { return false; } } if let Some(expected_to) = to { let actual_to = ctx.to_state.as_ref().map(|s| s.state.as_str()).unwrap_or("unavailable"); if actual_to != expected_to.as_str() { return false; } } true } Trigger::NumericState { entity_id, above, below } => { let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id); if !eid_match { return false; } let value: f64 = ctx .to_state .as_ref() .and_then(|s| s.state.parse().ok()) .unwrap_or(f64::NAN); if value.is_nan() { return false; } if let Some(a) = above { if value <= *a { return false; } } if let Some(b) = below { if value >= *b { return false; } } true } Trigger::Time { .. } => { // Time triggers are evaluated by the engine's timer task, not here. false } Trigger::Event { event_type } => { ctx.event_type.as_deref() == Some(event_type.as_str()) } } } } #[async_trait] impl EvaluateTrigger for Trigger { async fn matches(&self, ctx: &TriggerContext) -> bool { self.matches_sync(ctx) } } #[cfg(test)] mod tests { use super::*; use homecore::{Context, EntityId, State}; use std::sync::Arc; fn make_state(entity_id: &str, state: &str) -> Arc { Arc::new(State::new( EntityId::parse(entity_id).unwrap(), state, serde_json::json!({}), Context::new(), )) } fn state_ctx(entity_id: &str, from: &str, to: &str) -> TriggerContext { let eid = EntityId::parse(entity_id).unwrap(); TriggerContext::state_changed( eid, Some(make_state(entity_id, from)), Some(make_state(entity_id, to)), ) } #[test] fn state_trigger_exact_from_to_match() { let trigger = Trigger::State { entity_id: EntityId::parse("light.kitchen").unwrap(), from: Some("off".into()), to: Some("on".into()), }; let ctx = state_ctx("light.kitchen", "off", "on"); assert!(trigger.matches_sync(&ctx)); } #[test] fn state_trigger_wrong_entity_no_match() { let trigger = Trigger::State { entity_id: EntityId::parse("light.kitchen").unwrap(), from: None, to: Some("on".into()), }; let ctx = state_ctx("switch.hallway", "off", "on"); assert!(!trigger.matches_sync(&ctx)); } #[test] fn state_trigger_wrong_to_no_match() { let trigger = Trigger::State { entity_id: EntityId::parse("light.kitchen").unwrap(), from: None, to: Some("on".into()), }; let ctx = state_ctx("light.kitchen", "on", "off"); assert!(!trigger.matches_sync(&ctx)); } #[test] fn state_trigger_no_constraints_matches_any_change() { let trigger = Trigger::State { entity_id: EntityId::parse("light.kitchen").unwrap(), from: None, to: None, }; let ctx = state_ctx("light.kitchen", "off", "on"); assert!(trigger.matches_sync(&ctx)); } #[test] fn numeric_trigger_above_threshold_fires() { let trigger = Trigger::NumericState { entity_id: EntityId::parse("sensor.temperature").unwrap(), above: Some(25.0), below: None, }; let mut ctx = state_ctx("sensor.temperature", "20", "26"); ctx.to_state = Some(make_state("sensor.temperature", "26")); assert!(trigger.matches_sync(&ctx)); } #[test] fn numeric_trigger_below_threshold_no_fire() { let trigger = Trigger::NumericState { entity_id: EntityId::parse("sensor.temperature").unwrap(), above: Some(25.0), below: None, }; let mut ctx = state_ctx("sensor.temperature", "20", "24"); ctx.to_state = Some(make_state("sensor.temperature", "24")); assert!(!trigger.matches_sync(&ctx)); } #[test] fn numeric_trigger_between_bounds() { let trigger = Trigger::NumericState { entity_id: EntityId::parse("sensor.humidity").unwrap(), above: Some(30.0), below: Some(80.0), }; let mut ctx = state_ctx("sensor.humidity", "20", "50"); ctx.to_state = Some(make_state("sensor.humidity", "50")); assert!(trigger.matches_sync(&ctx)); } #[test] fn event_trigger_matches_type() { let trigger = Trigger::Event { event_type: "my_custom_event".into() }; let ctx = TriggerContext::event("my_custom_event"); assert!(trigger.matches_sync(&ctx)); } #[test] fn event_trigger_no_match_wrong_type() { let trigger = Trigger::Event { event_type: "my_custom_event".into() }; let ctx = TriggerContext::event("other_event"); assert!(!trigger.matches_sync(&ctx)); } #[tokio::test] async fn evaluate_trigger_trait_object() { let trigger: Box = Box::new(Trigger::Event { event_type: "boot".into(), }); let ctx = TriggerContext::event("boot"); assert!(trigger.matches(&ctx).await); } }