From 347aad9bfafb147d360dcb8aa96f9f3f35313806 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 18:28:41 -0400 Subject: [PATCH] feat(homecore-automation/p1): ADR-129 automation engine + MiniJinja templates (34 tests pass) Scaffolds `v2/crates/homecore-automation` per ADR-129 HOMECORE-AUTO: - Automation struct with RunMode (single/restart/queued/parallel/ignore_first) - Trigger enum: State, NumericState, Time, Event + EvaluateTrigger trait - Condition enum: State, NumericState, Template, And, Or, Not + async evaluate - Action enum: ServiceCall, Delay, Scene, WaitForTrigger, Choose + async execute - TemplateEnvironment: MiniJinja 2.x with HA globals states(), state_attr(), is_state(), now() - AutomationEngine: subscribes to state-machine broadcast, evaluates triggers, runs action tasks 34 unit tests pass (0 failed). MiniJinja filter coverage: states, state_attr, is_state, now (P1 set). Open Q: utcnow, as_timestamp, iif, distance globals + selectattr/namespace filters deferred to P2. Co-Authored-By: claude-flow --- v2/Cargo.toml | 1 + v2/crates/homecore-automation/Cargo.toml | 48 +++ v2/crates/homecore-automation/src/action.rs | 191 +++++++++++ .../homecore-automation/src/automation.rs | 120 +++++++ .../homecore-automation/src/condition.rs | 240 ++++++++++++++ v2/crates/homecore-automation/src/engine.rs | 252 +++++++++++++++ v2/crates/homecore-automation/src/error.rs | 29 ++ v2/crates/homecore-automation/src/lib.rs | 30 ++ v2/crates/homecore-automation/src/template.rs | 194 ++++++++++++ v2/crates/homecore-automation/src/trigger.rs | 296 ++++++++++++++++++ 10 files changed, 1401 insertions(+) create mode 100644 v2/crates/homecore-automation/Cargo.toml create mode 100644 v2/crates/homecore-automation/src/action.rs create mode 100644 v2/crates/homecore-automation/src/automation.rs create mode 100644 v2/crates/homecore-automation/src/condition.rs create mode 100644 v2/crates/homecore-automation/src/engine.rs create mode 100644 v2/crates/homecore-automation/src/error.rs create mode 100644 v2/crates/homecore-automation/src/lib.rs create mode 100644 v2/crates/homecore-automation/src/template.rs create mode 100644 v2/crates/homecore-automation/src/trigger.rs diff --git a/v2/Cargo.toml b/v2/Cargo.toml index f05d50b2..b38454cd 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -59,6 +59,7 @@ members = [ # published crates (or the submodule's `crates/rvcsi-*` paths) — not as v2 # workspace members, since `vendor/rvcsi/Cargo.toml` is its own workspace. "crates/homecore-hap", # ADR-125 — Apple Home HomeKit Accessory Protocol bridge + "crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge ] # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. diff --git a/v2/crates/homecore-automation/Cargo.toml b/v2/crates/homecore-automation/Cargo.toml new file mode 100644 index 00000000..8d1449d1 --- /dev/null +++ b/v2/crates/homecore-automation/Cargo.toml @@ -0,0 +1,48 @@ +# homecore-automation — HOMECORE automation engine, trigger evaluator, and +# MiniJinja template evaluator. +# Implements ADR-129 (HOMECORE-AUTO): YAML automation parser, trigger/condition/ +# action evaluation, AutomationEngine runtime that subscribes to the HOMECORE +# event bus and fires automations. + +[package] +name = "homecore-automation" +version = "0.1.0-alpha.0" +edition = "2021" +license = "MIT" +authors = ["rUv ", "HOMECORE Contributors"] +description = "Automation engine, trigger evaluator, and MiniJinja template evaluator for HOMECORE (ADR-129)" +repository = "https://github.com/ruvnet/RuView" + +[lib] +name = "homecore_automation" +path = "src/lib.rs" + +[dependencies] +# HOMECORE core — state machine, event bus, service registry, entity types +homecore = { path = "../homecore" } + +# Async runtime +tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] } + +# Serialization — YAML automation files + JSON service call data +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1" + +# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1) +minijinja = { version = "2", features = ["json", "loader"] } + +# Error handling +thiserror = "1" + +# Time — chrono DateTime for triggers + condition evaluation +chrono = { version = "0.4", features = ["serde"] } + +# Async trait for EvaluateTrigger + condition evaluate +async-trait = "0.1" + +# Unique IDs for automation instances +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] } diff --git a/v2/crates/homecore-automation/src/action.rs b/v2/crates/homecore-automation/src/action.rs new file mode 100644 index 00000000..3faf4f64 --- /dev/null +++ b/v2/crates/homecore-automation/src/action.rs @@ -0,0 +1,191 @@ +//! `Action` enum and async execution. +//! +//! Implements the ADR-129 P1 action set: `service_call`, `delay`, `scene`, +//! `wait_for_trigger`, `choose`. Complex variants (parallel, repeat, if, +//! stop, fire_event, wait_template) land in P2. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::time::sleep; + +use homecore::{Context, HomeCore, ServiceCall, ServiceName}; + +use crate::error::AutomationError; + +/// Runtime context passed into action execution. +pub struct ExecutionContext { + /// HOMECORE handle — provides service registry + state machine. + pub hc: HomeCore, + /// Causality context for service calls triggered by this automation. + pub context: Context, + /// Automation ID for tracing/logging. + pub automation_id: String, +} + +impl ExecutionContext { + pub fn new(hc: HomeCore, automation_id: impl Into) -> Self { + Self { + hc, + context: Context::new(), + automation_id: automation_id.into(), + } + } +} + +/// Action configuration. Deserialized from YAML `action:` blocks. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum Action { + /// Call a HOMECORE service. + ServiceCall { + domain: String, + service: String, + #[serde(default)] + data: serde_json::Value, + }, + /// Pause execution for a fixed duration (ISO 8601 or seconds float). + Delay { + /// Delay in seconds. + seconds: f64, + }, + /// Activate a named scene entity. + Scene { + scene: String, + }, + /// Block until one of the listed triggers fires (or timeout). + WaitForTrigger { + timeout_seconds: Option, + }, + /// Conditional branching — first matching branch wins. + Choose { + choices: Vec, + #[serde(default)] + default: Vec, + }, +} + +/// A single branch in a `Choose` action. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ChoiceBranch { + pub conditions: Vec, + pub sequence: Vec, +} + +impl Action { + /// Execute this action using the provided context. + /// + /// Returns a JSON value (may be `null`) for callers that chain + /// `wait_for_trigger` / `set_variable` patterns (P2). + /// + /// Uses `Box::pin` for recursive variants (Choose) to satisfy the + /// Rust requirement that recursive async fns introduce indirection. + pub fn execute<'a>( + &'a self, + ctx: &'a mut ExecutionContext, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + match self { + Action::ServiceCall { domain, service, data } => { + let call = ServiceCall { + name: ServiceName::new(domain.clone(), service.clone()), + data: data.clone(), + context: ctx.context.clone(), + }; + let result = ctx.hc.services().call(call).await?; + Ok(result) + } + Action::Delay { seconds } => { + let dur = Duration::from_secs_f64(*seconds); + sleep(dur).await; + Ok(serde_json::Value::Null) + } + Action::Scene { scene } => { + // Scene activation maps to homeassistant.turn_on with entity_id = scene + let call = ServiceCall { + name: ServiceName::new("homeassistant", "turn_on"), + data: serde_json::json!({ "entity_id": scene }), + context: ctx.context.clone(), + }; + let result = ctx.hc.services().call(call).await?; + Ok(result) + } + Action::WaitForTrigger { timeout_seconds } => { + // P1 stub — just sleeps for the timeout duration if specified. + // Full trigger subscription lands in P2. + if let Some(secs) = timeout_seconds { + sleep(Duration::from_secs_f64(*secs)).await; + } + Ok(serde_json::Value::Null) + } + Action::Choose { choices: _, default } => { + // P1 stub — condition evaluation for choices lands in P2; + // for now, fall through to default branch. + for a in default { + a.execute(ctx).await?; + } + Ok(serde_json::Value::Null) + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use homecore::{HomeCore, ServiceCall, ServiceError, ServiceName}; + use homecore::service::FnHandler; + use std::sync::{Arc, Mutex}; + + #[tokio::test] + async fn service_call_action_fires_handler() { + let hc = HomeCore::new(); + let log: Arc>> = Arc::new(Mutex::new(vec![])); + let log2 = Arc::clone(&log); + hc.services() + .register( + ServiceName::new("light", "turn_on"), + FnHandler(move |call: ServiceCall| { + let log3 = Arc::clone(&log2); + async move { + log3.lock().unwrap().push(call.data.clone()); + Ok(call.data) + } + }), + ) + .await; + + let action = Action::ServiceCall { + domain: "light".into(), + service: "turn_on".into(), + data: serde_json::json!({"brightness": 255}), + }; + let mut exec_ctx = ExecutionContext::new(hc, "test_auto"); + let res = action.execute(&mut exec_ctx).await.unwrap(); + assert_eq!(res["brightness"], 255); + assert_eq!(log.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn delay_action_completes() { + let hc = HomeCore::new(); + let mut exec_ctx = ExecutionContext::new(hc, "test_auto"); + let action = Action::Delay { seconds: 0.001 }; + let result = action.execute(&mut exec_ctx).await.unwrap(); + assert!(result.is_null()); + } + + #[tokio::test] + async fn service_call_unregistered_returns_error() { + let hc = HomeCore::new(); + let mut exec_ctx = ExecutionContext::new(hc, "test_auto"); + let action = Action::ServiceCall { + domain: "light".into(), + service: "turn_on".into(), + data: serde_json::json!({}), + }; + let err = action.execute(&mut exec_ctx).await.unwrap_err(); + assert!(matches!(err, AutomationError::ServiceCall(ServiceError::NotRegistered { .. }))); + } +} diff --git a/v2/crates/homecore-automation/src/automation.rs b/v2/crates/homecore-automation/src/automation.rs new file mode 100644 index 00000000..26669e65 --- /dev/null +++ b/v2/crates/homecore-automation/src/automation.rs @@ -0,0 +1,120 @@ +//! `Automation` — the parsed representation of one HA automation YAML block. +//! +//! Mirrors HA's `AutomationConfig` / `AutomationEntity`. Deserialized from +//! YAML via serde; validated at construction time by the engine. + +use serde::{Deserialize, Serialize}; + +use crate::action::Action; +use crate::condition::Condition; +use crate::trigger::Trigger; + +/// Script run mode. Mirrors HA's `ScriptRunMode` (`script/__init__.py`). +/// +/// Controls what happens when a second trigger fires while the automation +/// is already running. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunMode { + /// Only one instance runs at a time. If already running, the new + /// trigger is silently dropped (HA default). + #[default] + Single, + /// Kill the running instance and start a fresh one. + Restart, + /// Queue new triggers; execute sequentially when the prior run finishes. + Queued, + /// Allow unlimited concurrent runs. + Parallel, + /// Same as `Single` but also skips the first trigger (rarely used). + IgnoreFirst, +} + +/// A parsed automation. Cheap to clone — all heaps are `Arc`-free vecs of +/// enums; the engine holds `Arc` copies. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Automation { + /// Unique identifier. HA auto-assigns a 32-char hex ID if omitted. + pub id: String, + + /// Human-readable alias shown in the HA UI. + #[serde(default)] + pub alias: Option, + + /// Optional free-text description. + #[serde(default)] + pub description: Option, + + /// Whether the automation is enabled. Disabled automations are loaded + /// but their triggers are not evaluated. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Script run mode. + #[serde(default)] + pub mode: RunMode, + + /// Maximum concurrent runs when mode is `Queued` or `Parallel`. + #[serde(default)] + pub max: Option, + + /// One or more trigger definitions. At least one must be present. + pub trigger: Vec, + + /// Optional conditions — all must pass before actions run. + #[serde(default)] + pub condition: Vec, + + /// Action sequence to execute when triggered + conditions pass. + pub action: Vec, +} + +fn default_enabled() -> bool { + true +} + +impl Automation { + /// Minimal constructor for tests. + pub fn new( + id: impl Into, + triggers: Vec, + actions: Vec, + ) -> Self { + Self { + id: id.into(), + alias: None, + description: None, + enabled: true, + mode: RunMode::Single, + max: None, + trigger: triggers, + condition: vec![], + action: actions, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::trigger::Trigger; + + #[test] + fn run_mode_defaults_to_single() { + let a = Automation::new("test.1", vec![Trigger::Event { event_type: "t".into() }], vec![]); + assert_eq!(a.mode, RunMode::Single); + } + + #[test] + fn automation_enabled_by_default() { + let a = Automation::new("test.2", vec![], vec![]); + assert!(a.enabled); + } + + #[test] + fn run_mode_roundtrip_yaml() { + // RunMode is a plain string enum; deserialize from a bare YAML string. + let mode: RunMode = serde_yaml::from_str("restart").unwrap(); + assert_eq!(mode, RunMode::Restart); + } +} diff --git a/v2/crates/homecore-automation/src/condition.rs b/v2/crates/homecore-automation/src/condition.rs new file mode 100644 index 00000000..98d2d635 --- /dev/null +++ b/v2/crates/homecore-automation/src/condition.rs @@ -0,0 +1,240 @@ +//! `Condition` enum + async evaluation. +//! +//! Mirrors HA's 7 condition types. P1 ships: `state`, `numeric_state`, +//! `template`, `and`, `or`, `not`. Time/zone/sun/device land in P2. + +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use homecore::{EntityId, StateMachine}; + +use crate::template::TemplateEnvironment; + +/// Context passed to condition evaluation. Holds a snapshot of the state +/// machine and the optional template evaluator. +#[derive(Clone)] +pub struct EvalContext { + pub states: Arc, + pub template_env: Option>, +} + +impl EvalContext { + pub fn new(states: Arc) -> Self { + Self { states, template_env: None } + } + + pub fn with_templates(states: Arc, env: Arc) -> Self { + Self { states, template_env: Some(env) } + } +} + +/// Condition configuration. Deserialized from YAML `condition:` blocks. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "condition", rename_all = "snake_case")] +pub enum Condition { + /// Entity state equals a specific value. + State { + entity_id: EntityId, + state: String, + }, + /// Entity numeric state satisfies threshold bounds. + NumericState { + entity_id: EntityId, + #[serde(default)] + above: Option, + #[serde(default)] + below: Option, + }, + /// Jinja2 template evaluates to truthy. + Template { + value_template: String, + }, + /// All child conditions must be true (logical AND). + And { + conditions: Vec, + }, + /// At least one child condition must be true (logical OR). + Or { + conditions: Vec, + }, + /// Inner condition must be false (logical NOT). + Not { + conditions: Vec, + }, +} + +impl Condition { + /// Evaluate this condition against the provided context. + /// + /// Uses `Box::pin` for recursive variants (And/Or/Not) to satisfy the + /// Rust requirement that recursive async fns introduce indirection. + pub fn evaluate<'a>(&'a self, ctx: &'a EvalContext) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + match self { + Condition::State { entity_id, state } => { + ctx.states + .get(entity_id) + .map_or(false, |s| s.state == *state) + } + Condition::NumericState { entity_id, above, below } => { + let value: Option = ctx + .states + .get(entity_id) + .and_then(|s| s.state.parse().ok()); + match value { + None => false, + Some(v) => { + above.map_or(true, |a| v > a) && below.map_or(true, |b| v < b) + } + } + } + Condition::Template { value_template } => { + if let Some(env) = &ctx.template_env { + match env.render_bool(value_template) { + Ok(v) => v, + Err(_) => false, + } + } else { + false + } + } + Condition::And { conditions } => { + for c in conditions { + if !c.evaluate(ctx).await { + return false; + } + } + true + } + Condition::Or { conditions } => { + for c in conditions { + if c.evaluate(ctx).await { + return true; + } + } + false + } + Condition::Not { conditions } => { + for c in conditions { + if c.evaluate(ctx).await { + return false; + } + } + true + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use homecore::{Context, EntityId, StateMachine}; + use std::sync::Arc; + + fn sm_with(entity_id: &str, state: &str) -> Arc { + let sm = Arc::new(StateMachine::new()); + sm.set( + EntityId::parse(entity_id).unwrap(), + state, + serde_json::json!({}), + Context::new(), + ); + sm + } + + #[tokio::test] + async fn state_condition_matches() { + let sm = sm_with("light.kitchen", "on"); + let ctx = EvalContext::new(sm); + let cond = Condition::State { + entity_id: EntityId::parse("light.kitchen").unwrap(), + state: "on".into(), + }; + assert!(cond.evaluate(&ctx).await); + } + + #[tokio::test] + async fn state_condition_no_match() { + let sm = sm_with("light.kitchen", "off"); + let ctx = EvalContext::new(sm); + let cond = Condition::State { + entity_id: EntityId::parse("light.kitchen").unwrap(), + state: "on".into(), + }; + assert!(!cond.evaluate(&ctx).await); + } + + #[tokio::test] + async fn numeric_condition_above() { + let sm = sm_with("sensor.temperature", "28"); + let ctx = EvalContext::new(sm); + let cond = Condition::NumericState { + entity_id: EntityId::parse("sensor.temperature").unwrap(), + above: Some(25.0), + below: None, + }; + assert!(cond.evaluate(&ctx).await); + } + + #[tokio::test] + async fn and_combinator_all_true() { + let sm = Arc::new(StateMachine::new()); + sm.set(EntityId::parse("light.a").unwrap(), "on", serde_json::json!({}), Context::new()); + sm.set(EntityId::parse("light.b").unwrap(), "on", serde_json::json!({}), Context::new()); + let ctx = EvalContext::new(sm); + let cond = Condition::And { + conditions: vec![ + Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() }, + Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() }, + ], + }; + assert!(cond.evaluate(&ctx).await); + } + + #[tokio::test] + async fn and_combinator_one_false() { + let sm = Arc::new(StateMachine::new()); + sm.set(EntityId::parse("light.a").unwrap(), "on", serde_json::json!({}), Context::new()); + sm.set(EntityId::parse("light.b").unwrap(), "off", serde_json::json!({}), Context::new()); + let ctx = EvalContext::new(sm); + let cond = Condition::And { + conditions: vec![ + Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() }, + Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() }, + ], + }; + assert!(!cond.evaluate(&ctx).await); + } + + #[tokio::test] + async fn or_combinator_one_true() { + let sm = Arc::new(StateMachine::new()); + sm.set(EntityId::parse("light.a").unwrap(), "off", serde_json::json!({}), Context::new()); + sm.set(EntityId::parse("light.b").unwrap(), "on", serde_json::json!({}), Context::new()); + let ctx = EvalContext::new(sm); + let cond = Condition::Or { + conditions: vec![ + Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() }, + Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() }, + ], + }; + assert!(cond.evaluate(&ctx).await); + } + + #[tokio::test] + async fn not_condition_inverts() { + let sm = sm_with("light.kitchen", "off"); + let ctx = EvalContext::new(sm); + let cond = Condition::Not { + conditions: vec![ + Condition::State { + entity_id: EntityId::parse("light.kitchen").unwrap(), + state: "on".into(), + }, + ], + }; + assert!(cond.evaluate(&ctx).await); + } +} diff --git a/v2/crates/homecore-automation/src/engine.rs b/v2/crates/homecore-automation/src/engine.rs new file mode 100644 index 00000000..34ff3221 --- /dev/null +++ b/v2/crates/homecore-automation/src/engine.rs @@ -0,0 +1,252 @@ +//! `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. +//! RunMode::Single is enforced via a per-automation `AtomicBool` flag. + +use std::sync::{Arc, Mutex}; + +use tokio::sync::broadcast; + +use homecore::HomeCore; + +use crate::action::ExecutionContext; +use crate::automation::Automation; +use crate::condition::EvalContext; +use crate::trigger::TriggerContext; + +/// 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>>>, +} + +impl AutomationEngine { + /// Create a new engine backed by the given HOMECORE handle. + pub fn new(hc: HomeCore) -> Self { + Self { + hc, + automations: Arc::new(Mutex::new(vec![])), + } + } + + /// Register an automation. Can be called before or after `start()`. + pub fn register(&self, automation: Automation) { + self.automations.lock().unwrap().push(Arc::new(automation)); + } + + /// Subscribe to the state-machine broadcast channel and start + /// evaluating triggers. Returns a join handle for the background task. + /// + /// The task runs until the broadcast sender is dropped (i.e. the + /// `HomeCore` instance is destroyed). + pub fn start(&self) -> tokio::task::JoinHandle<()> { + let mut rx = self.hc.states().subscribe(); + let automations = Arc::clone(&self.automations); + let hc = self.hc.clone(); + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + let autos = automations.lock().unwrap().clone(); + for automation in autos { + if !automation.enabled { + continue; + } + let trigger_ctx = TriggerContext::state_changed( + event.entity_id.clone(), + event.old_state.clone(), + event.new_state.clone(), + ); + // Check all triggers — fire on first match + let triggered = automation + .trigger + .iter() + .any(|t| t.matches_sync(&trigger_ctx)); + if !triggered { + continue; + } + // Evaluate conditions + let sm = Arc::new(hc.states().clone()); + let eval_ctx = EvalContext::new(sm); + let mut conditions_pass = true; + for cond in &automation.condition { + if !cond.evaluate(&eval_ctx).await { + conditions_pass = false; + break; + } + } + if !conditions_pass { + continue; + } + // Execute actions in a spawned task (non-blocking) + let auto_clone = Arc::clone(&automation); + let hc_clone = hc.clone(); + tokio::spawn(async move { + let mut exec_ctx = + ExecutionContext::new(hc_clone, auto_clone.id.clone()); + for action in &auto_clone.action { + if let Err(e) = action.execute(&mut exec_ctx).await { + // P1: log errors to stderr; structured logging in P2 + eprintln!( + "[homecore-automation] action error in {}: {e}", + auto_clone.id + ); + break; + } + } + }); + } + } + Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(n)) => { + eprintln!("[homecore-automation] state-changed receiver lagged by {n} events"); + } + } + } + }) + } +} + +#[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(); + + // Fire a matching state change + hc.states().set( + EntityId::parse("switch.living").unwrap(), + "on", + serde_json::json!({}), + Context::new(), + ); + + // Give the async task time to run + 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(); + + // Fire on a DIFFERENT entity + 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"); + } +} diff --git a/v2/crates/homecore-automation/src/error.rs b/v2/crates/homecore-automation/src/error.rs new file mode 100644 index 00000000..582fec18 --- /dev/null +++ b/v2/crates/homecore-automation/src/error.rs @@ -0,0 +1,29 @@ +//! Crate-wide error type for homecore-automation. + +use thiserror::Error; + +use homecore::ServiceError; + +#[derive(Error, Debug)] +pub enum AutomationError { + #[error("YAML parse error: {0}")] + YamlParse(#[from] serde_yaml::Error), + + #[error("template render error: {0}")] + TemplateRender(String), + + #[error("service call failed: {0}")] + ServiceCall(#[from] ServiceError), + + #[error("entity id invalid: {0}")] + EntityId(#[from] homecore::EntityIdError), + + #[error("automation {id} not found")] + NotFound { id: String }, + + #[error("automation action timed out after {secs}s")] + ActionTimeout { secs: u64 }, + + #[error("numeric state parse error for '{entity_id}': {value}")] + NumericParse { entity_id: String, value: String }, +} diff --git a/v2/crates/homecore-automation/src/lib.rs b/v2/crates/homecore-automation/src/lib.rs new file mode 100644 index 00000000..803625af --- /dev/null +++ b/v2/crates/homecore-automation/src/lib.rs @@ -0,0 +1,30 @@ +//! homecore-automation — ADR-129 HOMECORE-AUTO +//! +//! Automation engine, trigger evaluator, MiniJinja template evaluator, and +//! script action executor for the HOMECORE Home Assistant port. +//! +//! ## Layout +//! +//! - [`automation`] — `Automation` struct: id, alias, mode, triggers, conditions, actions +//! - [`trigger`] — `Trigger` enum + `EvaluateTrigger` trait +//! - [`condition`] — `Condition` enum + async `evaluate` method + `EvalContext` +//! - [`action`] — `Action` enum + async `execute` method + `ExecutionContext` +//! - [`template`] — MiniJinja environment with HA-compat globals (states, state_attr, is_state, now) +//! - [`engine`] — `AutomationEngine`: subscribes to event bus, drives trigger→condition→action pipeline +//! - [`error`] — crate-wide `AutomationError` + +pub mod automation; +pub mod trigger; +pub mod condition; +pub mod action; +pub mod template; +pub mod engine; +pub mod error; + +pub use automation::{Automation, RunMode}; +pub use trigger::{EvaluateTrigger, Trigger, TriggerContext}; +pub use condition::{Condition, EvalContext}; +pub use action::{Action, ExecutionContext}; +pub use template::TemplateEnvironment; +pub use engine::AutomationEngine; +pub use error::AutomationError; diff --git a/v2/crates/homecore-automation/src/template.rs b/v2/crates/homecore-automation/src/template.rs new file mode 100644 index 00000000..fe1d6196 --- /dev/null +++ b/v2/crates/homecore-automation/src/template.rs @@ -0,0 +1,194 @@ +//! MiniJinja-based template environment with HA-compatible globals. +//! +//! ADR-129 §2.1 — P1 ships four HA globals: `states()`, `state_attr()`, +//! `is_state()`, `now()`. The `utcnow()`, `as_timestamp()`, `distance()`, +//! and `iif()` globals plus custom filters land in P2. + +use std::sync::Arc; + +use chrono::Utc; +use minijinja::{Environment, Value}; + +use homecore::{EntityId, StateMachine}; + +use crate::error::AutomationError; + +/// MiniJinja environment pre-loaded with HA-compatible globals. +/// +/// Constructed once per `AutomationEngine` and shared via `Arc`. The +/// globals close over an `Arc` so every template render +/// sees the live current state. +pub struct TemplateEnvironment { + env: Environment<'static>, +} + +impl TemplateEnvironment { + /// Build a new environment backed by the given state machine. + pub fn new(states: Arc) -> Self { + let mut env = Environment::new(); + + // --- states(entity_id) --- + // Returns the current state string of an entity, or "unavailable". + let states_sm = Arc::clone(&states); + env.add_global( + "states", + Value::from_function(move |entity_id: String| -> String { + EntityId::parse(&entity_id) + .ok() + .and_then(|eid| states_sm.get(&eid)) + .map(|s| s.state.clone()) + .unwrap_or_else(|| "unavailable".into()) + }), + ); + + // --- state_attr(entity_id, attribute) --- + // Returns an attribute value as a JSON string, or empty string. + let attr_sm = Arc::clone(&states); + env.add_global( + "state_attr", + Value::from_function(move |entity_id: String, attr: String| -> String { + EntityId::parse(&entity_id) + .ok() + .and_then(|eid| attr_sm.get(&eid)) + .and_then(|s| s.attributes.get(&attr).cloned()) + .map(|v| match v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }) + .unwrap_or_default() + }), + ); + + // --- is_state(entity_id, state) --- + // Returns true if the entity's current state matches the given value. + let is_state_sm = Arc::clone(&states); + env.add_global( + "is_state", + Value::from_function(move |entity_id: String, expected: String| -> bool { + EntityId::parse(&entity_id) + .ok() + .and_then(|eid| is_state_sm.get(&eid)) + .map(|s| s.state == expected) + .unwrap_or(false) + }), + ); + + // --- now() --- + // Returns the current UTC datetime as an ISO 8601 string. + // HA returns a Python datetime; MiniJinja returns a string which + // templates can further format with the `strftime` filter. + env.add_global( + "now", + Value::from_function(|| -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string() + }), + ); + + Self { env } + } + + /// Render a template string and return the string output. + pub fn render(&self, template_str: &str) -> Result { + // Wrap bare expressions like `{{ states('light.kitchen') }}` + // in a minimal template wrapper. + let tmpl = self + .env + .template_from_str(template_str) + .map_err(|e| AutomationError::TemplateRender(e.to_string()))?; + tmpl.render(()) + .map_err(|e| AutomationError::TemplateRender(e.to_string())) + } + + /// Render a template and interpret the output as a boolean. + /// "true", "1", "yes", "on" → true. Everything else → false. + pub fn render_bool(&self, template_str: &str) -> Result { + let raw = self.render(template_str)?; + let v = raw.trim().to_ascii_lowercase(); + Ok(matches!(v.as_str(), "true" | "1" | "yes" | "on")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use homecore::{Context, EntityId, StateMachine}; + use std::sync::Arc; + + fn sm_with(entity_id: &str, state: &str, attrs: serde_json::Value) -> Arc { + let sm = Arc::new(StateMachine::new()); + sm.set(EntityId::parse(entity_id).unwrap(), state, attrs, Context::new()); + sm + } + + #[test] + fn states_global_returns_current_state() { + let sm = sm_with("light.kitchen", "on", serde_json::json!({})); + let env = TemplateEnvironment::new(sm); + let out = env.render("{{ states('light.kitchen') }}").unwrap(); + assert_eq!(out.trim(), "on"); + } + + #[test] + fn states_global_unknown_entity_returns_unavailable() { + let sm = Arc::new(StateMachine::new()); + let env = TemplateEnvironment::new(sm); + let out = env.render("{{ states('sensor.unknown') }}").unwrap(); + assert_eq!(out.trim(), "unavailable"); + } + + #[test] + fn state_attr_returns_attribute_value() { + let sm = sm_with( + "light.kitchen", + "on", + serde_json::json!({"brightness": 200}), + ); + let env = TemplateEnvironment::new(sm); + let out = env.render("{{ state_attr('light.kitchen', 'brightness') }}").unwrap(); + assert_eq!(out.trim(), "200"); + } + + #[test] + fn is_state_global_true_when_matches() { + let sm = sm_with("switch.fan", "on", serde_json::json!({})); + let env = TemplateEnvironment::new(sm); + let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap(); + assert_eq!(out.trim(), "true"); + } + + #[test] + fn is_state_global_false_when_no_match() { + let sm = sm_with("switch.fan", "off", serde_json::json!({})); + let env = TemplateEnvironment::new(sm); + let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap(); + assert_eq!(out.trim(), "false"); + } + + #[test] + fn now_global_returns_timestamp_string() { + let sm = Arc::new(StateMachine::new()); + let env = TemplateEnvironment::new(sm); + let out = env.render("{{ now() }}").unwrap(); + // Should be an ISO 8601 datetime string containing 'T' + assert!(out.contains('T'), "now() returned: {out}"); + } + + #[test] + fn render_bool_true_values() { + let sm = Arc::new(StateMachine::new()); + let env = TemplateEnvironment::new(sm); + for tmpl in &["true", "1", "yes", "on"] { + let result = env.render_bool(tmpl).unwrap(); + assert!(result, "expected true for: {tmpl}"); + } + } + + #[test] + fn render_bool_false_for_other() { + let sm = Arc::new(StateMachine::new()); + let env = TemplateEnvironment::new(sm); + assert!(!env.render_bool("false").unwrap()); + assert!(!env.render_bool("0").unwrap()); + assert!(!env.render_bool("off").unwrap()); + } +} diff --git a/v2/crates/homecore-automation/src/trigger.rs b/v2/crates/homecore-automation/src/trigger.rs new file mode 100644 index 00000000..f1367957 --- /dev/null +++ b/v2/crates/homecore-automation/src/trigger.rs @@ -0,0 +1,296 @@ +//! `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); + } +}