docs/adr/ADR-129-homecore-automation-engine.md
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-05-25 |
| Deciders | ruv |
| Codename | HOMECORE-AUTO |
| Relates to | ADR-126 (HOMECORE master), ADR-127 (HOMECORE-CORE), ADR-129 implicit, ADR-133 (HOMECORE-ASSIST) |
| Tracking issue | TBD |
Home Assistant's automation system is defined across three components:
homeassistant/components/automation/__init__.py — the automation manager: loads automation YAML, evaluates trigger platforms, calls the script executor when conditions pass. The core class is AutomationEntity which extends ToggleEntity. Automations are themselves HA entities with state = on/off.
homeassistant/components/script/__init__.py — the script executor: a sequence of actions (service calls, conditions, delays, events, template variables, choose, parallel, repeat, wait_for_trigger). Scripts are entities too (ScriptEntity extends ToggleEntity). The execution engine supports five run modes: single, restart, queued, parallel, ignore_first.
homeassistant/helpers/template.py — HA's Jinja2 customisation layer: wraps the upstream jinja2 Python library with HA-specific globals (states(), is_state(), state_attr(), now(), utcnow(), as_timestamp(), distance(), closest(), etc.), custom filters (regex_match, round, timestamp_local), and a sandboxed Environment that prevents file I/O and dangerous evaluations.
HA's automation YAML supports:
The YAML schema is validated by voluptuous schemas defined in homeassistant/helpers/config_validation.py (~5,000 lines).
HA templates are used not only in automations but in dashboard cards, notification messages, and script variables. The HA frontend sends template strings to the API's POST /api/template endpoint for server-side evaluation. Any HOMECORE instance that claims API compatibility must execute Jinja2-compatible templates or existing automations will break.
Full Jinja2 support in Rust without Python is non-trivial. The approach chosen here uses a WASM-compiled MiniJinja (the minijinja Rust crate compiled with HA-specific extension functions) rather than a full Python Jinja2 re-implementation.
Build the homecore-automation crate with three components:
serde_yaml + custom validator that parses HA's automation and script YAML into typed Rust structs. Validates trigger, condition, and action schemas at load time.tokio::time::sleep. Template evaluation uses MiniJinja. Complex conditions (optional) can route to a ruflo agent (ADR-133).minijinja (crates.io version 2.x) is a production-quality Jinja2 implementation in pure Rust. It is missing 5–10% of Jinja2's surface area (notably: {% block %} / {% extends %} template inheritance, and some Jinja2 Python-specific filters), but covers 100% of HA's automation template usage.
HA-specific globals added on top of MiniJinja:
env.add_global("states", minijinja::Value::from_function(ha_states_global));
env.add_global("is_state", minijinja::Value::from_function(ha_is_state_global));
env.add_global("state_attr", minijinja::Value::from_function(ha_state_attr_global));
env.add_global("now", minijinja::Value::from_function(ha_now_global));
env.add_global("utcnow", minijinja::Value::from_function(ha_utcnow_global));
env.add_global("as_timestamp", minijinja::Value::from_function(ha_as_timestamp_global));
env.add_global("distance", minijinja::Value::from_function(ha_distance_global));
env.add_global("iif", minijinja::Value::from_function(ha_iif_global));
Each global function reads from the HOMECORE state machine (ADR-127) via an Arc<StateMachine> captured at environment construction time. Template evaluation is synchronous (MiniJinja is sync) but runs in a tokio::task::spawn_blocking wrapper to avoid blocking the async executor.
Dashboard card templates submitted via POST /api/template come from user-authored YAML, not first-party code. HA evaluates these in the same Python process, relying on Jinja2's SandboxedEnvironment for safety. HOMECORE uses a WASM-sandboxed MiniJinja evaluator:
homecore-template-eval.wasm) is compiled from the MiniJinja crate with the HA extension globals stubbed to call host functions.This is the same Wasmtime host already used for integration plugins (ADR-128) — no additional WASM runtime dependency.
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
automation/__init__.py AutomationEntity | Automation as a toggle entity (on/off) with triggers/conditions/actions | Automation is a HOMECORE entity with same on/off state semantics | Rust struct AutomationEntity implementing HomeCoreEntity trait | Python class hierarchy, voluptuous schema |
automation/__init__.py TriggerActionConfig | Trigger → condition → action pipeline | Full trigger/condition/action pipeline | Typed Rust enums per trigger platform | Python dict-based config |
automation/trigger.py | Delegates to per-platform trigger modules (homeassistant/components/<platform>/trigger.py) | Same per-platform dispatch | Rust match arm per trigger type | Python dynamic module import |
script/__init__.py Script | Script entity + action sequence executor | Same 22 action types | Rust enum Action with all variants | Python asyncio coroutines |
script/__init__.py run modes | single, restart, queued, parallel, ignore_first | All 5 run modes | Tokio-based concurrency control (semaphore for queued, parallel) | Python asyncio task management |
helpers/template.py Template | Jinja2 evaluation + HA globals | Same HA global function names and signatures | MiniJinja instead of Python Jinja2; WASM sandbox for user templates | Python jinja2 library; voluptuous coercions in templates |
helpers/config_validation.py | cv.template, cv.entity_id, time period validators | Same validation semantics | Rust custom deserializers implementing serde::Deserialize | voluptuous; Python regex |
components/automation/blueprint.py | Blueprint system (reusable automation templates with input variables) | Blueprint YAML schema + variable substitution | Pure Rust YAML substitution | Python Blueprint class hierarchy |
| HA automation surface | HOMECORE equivalent |
|---|---|
automation.trigger (state, time, numeric_state, template, event, ...) | Trigger enum with variants for all 17 HA trigger platforms |
automation.condition (state, numeric_state, time, template, zone, sun, device) | Condition enum with variants for all 7 condition types |
automation.action — call_service, delay, fire_event, choose, if, parallel, repeat, wait_template, stop | Action enum with variants for all 22 action types |
script.run_mode — single, restart, queued, parallel | RunMode enum with 5 variants |
POST /api/template (REST eval of a template string) | Same endpoint in HOMECORE-API (ADR-130); backed by WASM-sandboxed MiniJinja |
| Automation entity: `state = on | off, attributes.last_triggered, attributes.id` |
automation.trigger service (manually trigger an automation) | homecore.automation.trigger service; same service call data schema |
automation.reload service (reload automations.yaml) | homecore.automation.reload service |
automation.toggle service | Standard HomeCoreEntity toggle service |
Blueprint YAML with blueprint: key and input: variables | Blueprint parsed by HOMECORE YAML parser; same substitution semantics |
| HA trigger platform | HOMECORE implementation |
|---|---|
state | Subscribe to state_changed broadcast; match entity_id, from, to, for |
numeric_state | Subscribe to state_changed; parse state as f64; compare against above/below |
time | tokio::time::sleep_until to next occurrence; re-arm after fire |
time_pattern | Cron-style evaluation using cron crate; tokio timer task |
template | Re-evaluate template on every state_changed; fire when template transitions from false to true |
event | Subscribe to named domain event on event bus |
homeassistant (start/stop) | Subscribe to HomeAssistantStart / HomeAssistantStop typed events |
zone | Subscribe to zone.entered / zone.left events from the device tracker integration |
mqtt | Subscribe to MQTT topic via the MQTT plugin (ADR-128); fire event when message arrives |
webhook | HOMECORE-API registers a webhook path; fires event on POST |
calendar | Subscribe to calendar event from calendar integration |
conversation | Subscribe to conversation.user_input event; match intent/sentence |
geo_location | Subscribe to geo_location.entered / geo_location.left |
sun | Compute sunrise/sunset from latitude/longitude in homecore.config; tokio timer |
device | Delegate to integration-specific device trigger via WASM plugin |
persistent_notification | Subscribe to persistent_notification.create event |
tag | Subscribe to tag.scanned event from NFC/QR integration |
Trigger, Condition, Action, RunMode with serde deserialization.automations.yaml from a real HA install with zero errors (test fixture).state, numeric_state, event, homeassistant, time, time_pattern trigger evaluators.ConditionEvaluator for state, numeric_state, time conditions.ActionExecutor for call_service, delay, fire_event, stop action types.POST /api/template endpoint wired to WASM evaluator.template trigger + template condition evaluators.choose, if, parallel, repeat, wait_template, sequence action types.RunMode variants (concurrency control via Tokio semaphore/mutex).automations.yaml from ADR-134 migration fixture loads and runs correctly.condition: ruflo_agent with query: "..." routes to ruflo LLM (ADR-133 §3.3); gated by RUVIEW-POLICY.automation.reload service.| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| MiniJinja gaps — some HA templates use Jinja2 features MiniJinja doesn't support (template inheritance, Python-specific filters) | Medium | Medium | Document the MiniJinja-vs-Jinja2 delta before P3 ships; provide a migration guide for affected templates; defer the 5% of templates that fail to a Python-compat shim (ADR-134) | ADR-134: migration tool must warn on templates that use unsupported Jinja2 features |
Template performance — synchronous MiniJinja in spawn_blocking adds overhead under high automation fan-out | Low | Low | Benchmark at 50 automations each evaluating a template trigger on every state_changed (worst case); if > 2 ms add a template-evaluation cache keyed by (template_hash, relevant_entity_states) | ADR-127: state machine must expose a "relevant states snapshot" API for caching |
ADR-127 state machine API not frozen — trigger evaluators call hass.states.all() and subscribe to broadcasts; if those APIs change, trigger code must update | High (early) | High | ADR-127 must freeze its public API before ADR-129 P2 begins; use a HomeCoreRef trait (version 1.0 stable) | ADR-127 owns this dependency |
Complex action YAML — real-world automations use deeply nested choose/if/parallel blocks; parsing is non-trivial | Medium | Medium | Use a corpus of 500 public HA automations from the HA community (MIT-licensed) as parse-test fixtures in CI | None |
Q1: MiniJinja does not support all Python-specific Jinja2 filters (e.g. map, select, reject with Python lambda arguments). HA's homeassistant/helpers/template.py adds custom equivalents of several of these. How many real-world HA automations use these filters? A corpus analysis of public HA configs on GitHub would answer this before P3 implementation.
Q2: HA's template trigger supports a value_template that can reference trigger.to_state, trigger.from_state, and trigger.for. This requires passing trigger context into the template evaluation scope. Is this context threading straightforward in MiniJinja, or does it require a custom context type?
Q3: The conversation trigger in HA uses the Assist pipeline's intent matching to fire automations based on voice commands. HOMECORE-ASSIST (ADR-133) owns the pipeline. Should the conversation trigger be implemented in ADR-129 (automation engine dependency on ADR-133) or in ADR-133 (assist pipeline fires automation events that ADR-129 listens to)?
Q4: HA blueprints have a community sharing mechanism (blueprint.exchange). Should HOMECORE support importing blueprints from HA's blueprint exchange directly, or only local blueprints?
A focused security review of homecore-automation (the execution/eval surface — triggers → conditions → actions, with templates) was run after the ADR-154–159 sweep, applying the same rigor that the sibling engine/bfld/calibration/vitals/geo reviews used. Two real DoS findings, each pinned by a fails-on-old test; the condition-bypass, fail-closed-parsing, and action-authorization dimensions were probed and found clean.
template: condition / value_template is user automation config, and was rendered with MiniJinja's defaults: no instruction budget, no output cap. A single condition such as {% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %} rendered a 100 MB string over ~11 s on one render call (measured) — a CPU/memory denial of service (the bfld-class "unbounded expansion"; MiniJinja's per-call range() 10k cap does not stop nested loops). Fix: enable MiniJinja's fuel feature and set a per-render budget (set_fuel(Some(1_000_000))) so a nested loop burns one unit per iteration — the attack now fails fast (~90 ms) with "engine ran out of fuel"; plus a 64 KiB source-length cap rejecting pathological sources before compilation. Legitimate HA templates (a few dozen instructions) are unaffected. Pinned by nested_loop_template_is_bounded_not_unbounded_dos, single_huge_repeat_template_is_bounded, oversized_template_source_is_rejected (all fail-on-old: unbounded render / no rejection), and legitimate_template_still_renders_within_fuel (no regression).Action::Delay { seconds } and Action::WaitForTrigger { timeout_seconds } fed the user-supplied float straight into Duration::from_secs_f64, which panics on negative, NaN, infinite, or overflowing inputs — all reachable from a crafted (or typo'd) YAML (delay: {seconds: -1}, .nan, .inf, 1e308). One hostile config aborts the spawned automation run task with a panic (measured: "cannot convert float seconds to Duration: value is negative"). Fix: a safe_duration_from_secs guard that saturates instead of panicking (NaN/±inf/negative → Duration::ZERO, matching HA's lenient "non-positive delay = no delay"; absurdly large → clamped to ~100 years). Pinned by delay_negative_seconds_does_not_panic, delay_nan_seconds_does_not_panic, delay_infinite_seconds_does_not_panic, wait_for_trigger_negative_timeout_does_not_panic, safe_duration_saturates_hostile_values (incl. overflow clamp).Dimensions confirmed clean (with evidence):
Condition::Template whose render errors evaluates to false (condition.rs Err(_) => false), and a Choose branch condition that fails to deserialize is treated as non-matching (the branch is skipped), not silently passing (action.rs ChoiceBranch::matches Err(_) => return false). Both fail closed (do-not-run), confirmed by the existing choose_* tests and template-false-blocks-action behavioral test. No true-by-default-on-parse-error path found.Single/IgnoreFirst re-entrancy guard, Restart cancel-and-replace, Queued FIFO serialization, and max: N semaphore cap (ADR-162; restart_mode_cancels_prior_run, queued_mode_runs_sequentially_not_concurrently, max_two_caps_concurrency_at_two, single_mode_does_not_double_fire_on_rapid_triggers). A self-triggering automation does not livelock the engine — each fire is bounded by its run-mode.states/state_attr/is_state/now globals; no service-call or state-set global is exposed to template scope), so a template cannot escalate into an action. Service authorization itself is enforced at the homecore service-registry boundary (out of this crate's scope); no gap found in what the automation crate enforces.serde_yaml/serde_json deserialization returns structured AutomationError (no unwrap/expect/index reachable from a crafted config in the eval/exec path); the only remaining panic surface was the from_secs_f64 path fixed as HC-SEC-02.Validation: cargo test -p homecore-automation --no-default-features → 54 passed / 0 failed (+14 over baseline). Python deterministic proof unchanged (homecore-automation is off the signal-processing proof path).
homeassistant/components/automation/__init__.py — AutomationEntity, AutomationConfig, trigger/condition/action pipelinehomeassistant/components/script/__init__.py — Script, ScriptEntity, run modes, action sequence executionhomeassistant/helpers/template.py — Template class, TemplateEnvironment, all HA-specific Jinja2 globals and filtershomeassistant/helpers/config_validation.py — voluptuous schema definitions for all automation/script YAML elementshomeassistant/components/automation/blueprint.py — Blueprint input substitutiondocs/adr/ADR-127-homecore-state-machine-rust.md — state machine and event bus that triggers subscribe todocs/adr/ADR-133-homecore-assist-ruflo.md — ruflo agent condition + conversation trigger dependencydocs/adr/ADR-134-homecore-migration-from-python-ha.md — migration tool reads automations.yaml