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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 18:28:41 -04:00
parent a422995817
commit 347aad9bfa
10 changed files with 1401 additions and 0 deletions

View File

@ -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`.

View File

@ -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 <ruv@ruv.net>", "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"] }

View File

@ -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<String>) -> 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<f64>,
},
/// Conditional branching — first matching branch wins.
Choose {
choices: Vec<ChoiceBranch>,
#[serde(default)]
default: Vec<Action>,
},
}
/// A single branch in a `Choose` action.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChoiceBranch {
pub conditions: Vec<serde_yaml::Value>,
pub sequence: Vec<Action>,
}
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<Box<dyn std::future::Future<Output = Result<serde_json::Value, AutomationError>> + 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<Mutex<Vec<serde_json::Value>>> = 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 { .. })));
}
}

View File

@ -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<Automation>` 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<String>,
/// Optional free-text description.
#[serde(default)]
pub description: Option<String>,
/// 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<usize>,
/// One or more trigger definitions. At least one must be present.
pub trigger: Vec<Trigger>,
/// Optional conditions — all must pass before actions run.
#[serde(default)]
pub condition: Vec<Condition>,
/// Action sequence to execute when triggered + conditions pass.
pub action: Vec<Action>,
}
fn default_enabled() -> bool {
true
}
impl Automation {
/// Minimal constructor for tests.
pub fn new(
id: impl Into<String>,
triggers: Vec<Trigger>,
actions: Vec<Action>,
) -> 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);
}
}

View File

@ -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<StateMachine>,
pub template_env: Option<Arc<TemplateEnvironment>>,
}
impl EvalContext {
pub fn new(states: Arc<StateMachine>) -> Self {
Self { states, template_env: None }
}
pub fn with_templates(states: Arc<StateMachine>, env: Arc<TemplateEnvironment>) -> 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<f64>,
#[serde(default)]
below: Option<f64>,
},
/// Jinja2 template evaluates to truthy.
Template {
value_template: String,
},
/// All child conditions must be true (logical AND).
And {
conditions: Vec<Condition>,
},
/// At least one child condition must be true (logical OR).
Or {
conditions: Vec<Condition>,
},
/// Inner condition must be false (logical NOT).
Not {
conditions: Vec<Condition>,
},
}
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<Box<dyn std::future::Future<Output = bool> + 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<f64> = 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<StateMachine> {
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);
}
}

View File

@ -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<Mutex<Vec<Arc<Automation>>>>,
}
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<Mutex<Vec<serde_json::Value>>> {
let log: Arc<Mutex<Vec<serde_json::Value>>> = 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");
}
}

View File

@ -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 },
}

View File

@ -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;

View File

@ -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<StateMachine>` 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<StateMachine>) -> 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<String, AutomationError> {
// 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<bool, AutomationError> {
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<StateMachine> {
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());
}
}

View File

@ -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<EntityId>,
/// New state snapshot (for state / numeric_state triggers).
pub to_state: Option<Arc<State>>,
/// Previous state snapshot (for state / numeric_state triggers).
pub from_state: Option<Arc<State>>,
/// When the trigger fired.
pub fired_at: DateTime<Utc>,
/// Event type (for event triggers).
pub event_type: Option<String>,
}
impl TriggerContext {
pub fn state_changed(
entity_id: EntityId,
from: Option<Arc<State>>,
to: Option<Arc<State>>,
) -> 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<String>) -> 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<String>,
/// Optional: only fire if state transitions to this value.
#[serde(default)]
to: Option<String>,
},
/// 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<f64>,
/// Fire when value drops below this threshold.
#[serde(default)]
below: Option<f64>,
},
/// 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<State> {
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<dyn EvaluateTrigger> = Box::new(Trigger::Event {
event_type: "boot".into(),
});
let ctx = TriggerContext::event("boot");
assert!(trigger.matches(&ctx).await);
}
}