Back to Ruview

ADR-129: HOMECORE-AUTO — Automation engine, script runner, and template evaluator

docs/adr/ADR-129-homecore-automation-engine.md

209620.8 KB
Original Source

ADR-129: HOMECORE-AUTO — Automation engine, script runner, and template evaluator

FieldValue
StatusProposed
Date2026-05-25
Decidersruv
CodenameHOMECORE-AUTO
Relates toADR-126 (HOMECORE master), ADR-127 (HOMECORE-CORE), ADR-129 implicit, ADR-133 (HOMECORE-ASSIST)
Tracking issueTBD

1. Context

Home Assistant's automation system is defined across three components:

  1. 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.

  2. 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.

  3. 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.

1.1 Scale and surface

HA's automation YAML supports:

  • 17 trigger platforms (state, time, numeric_state, template, event, homeassistant, zone, geo_location, device, calendar, conversation, mqtt, webhook, tag, sun, time_pattern, persistent_notification)
  • 7 condition types (state, numeric_state, time, template, zone, sun, device)
  • 22+ action types (call_service, delay, wait_template, fire_event, device_action, choose, if, parallel, repeat, sequence, stop, set_conversation_response, ...)

The YAML schema is validated by voluptuous schemas defined in homeassistant/helpers/config_validation.py (~5,000 lines).

1.2 Jinja2 is the critical surface

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.


2. Decision

Build the homecore-automation crate with three components:

  1. YAML parser: 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.
  2. Trigger evaluator: a Tokio task per loaded automation that subscribes to the HOMECORE event bus (ADR-127) and evaluates trigger conditions in Rust. When a trigger fires and conditions pass, it enqueues the automation action sequence.
  3. Action executor: a script runner that processes action sequences. Service calls go to the HOMECORE service registry. Delays use tokio::time::sleep. Template evaluation uses MiniJinja. Complex conditions (optional) can route to a ruflo agent (ADR-133).

2.1 Template evaluator: MiniJinja + HA-compatible extension functions

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:

rust
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.

2.2 WASM evaluator for untrusted template strings

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:

  • A single WASM module (homecore-template-eval.wasm) is compiled from the MiniJinja crate with the HA extension globals stubbed to call host functions.
  • Template strings are passed into the WASM module via the HOMECORE plugin ABI (ADR-128 §5.1).
  • The WASM sandbox prevents file I/O, network access, and infinite loops (via Wasmtime fuel metering — 100,000 instructions per template evaluation).
  • Result is returned as a string to the HOMECORE API.

This is the same Wasmtime host already used for integration plugins (ADR-128) — no additional WASM runtime dependency.


3. HA-side reference table

HA module / fileWhat it doesHOMECORE preservesChangesDrops
automation/__init__.py AutomationEntityAutomation as a toggle entity (on/off) with triggers/conditions/actionsAutomation is a HOMECORE entity with same on/off state semanticsRust struct AutomationEntity implementing HomeCoreEntity traitPython class hierarchy, voluptuous schema
automation/__init__.py TriggerActionConfigTrigger → condition → action pipelineFull trigger/condition/action pipelineTyped Rust enums per trigger platformPython dict-based config
automation/trigger.pyDelegates to per-platform trigger modules (homeassistant/components/<platform>/trigger.py)Same per-platform dispatchRust match arm per trigger typePython dynamic module import
script/__init__.py ScriptScript entity + action sequence executorSame 22 action typesRust enum Action with all variantsPython asyncio coroutines
script/__init__.py run modessingle, restart, queued, parallel, ignore_firstAll 5 run modesTokio-based concurrency control (semaphore for queued, parallel)Python asyncio task management
helpers/template.py TemplateJinja2 evaluation + HA globalsSame HA global function names and signaturesMiniJinja instead of Python Jinja2; WASM sandbox for user templatesPython jinja2 library; voluptuous coercions in templates
helpers/config_validation.pycv.template, cv.entity_id, time period validatorsSame validation semanticsRust custom deserializers implementing serde::Deserializevoluptuous; Python regex
components/automation/blueprint.pyBlueprint system (reusable automation templates with input variables)Blueprint YAML schema + variable substitutionPure Rust YAML substitutionPython Blueprint class hierarchy

4. Public API parity table

HA automation surfaceHOMECORE 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, stopAction enum with variants for all 22 action types
script.run_mode — single, restart, queued, parallelRunMode 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 = onoff, 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 serviceStandard HomeCoreEntity toggle service
Blueprint YAML with blueprint: key and input: variablesBlueprint parsed by HOMECORE YAML parser; same substitution semantics

5. Trigger platform mapping

HA trigger platformHOMECORE implementation
stateSubscribe to state_changed broadcast; match entity_id, from, to, for
numeric_stateSubscribe to state_changed; parse state as f64; compare against above/below
timetokio::time::sleep_until to next occurrence; re-arm after fire
time_patternCron-style evaluation using cron crate; tokio timer task
templateRe-evaluate template on every state_changed; fire when template transitions from false to true
eventSubscribe to named domain event on event bus
homeassistant (start/stop)Subscribe to HomeAssistantStart / HomeAssistantStop typed events
zoneSubscribe to zone.entered / zone.left events from the device tracker integration
mqttSubscribe to MQTT topic via the MQTT plugin (ADR-128); fire event when message arrives
webhookHOMECORE-API registers a webhook path; fires event on POST
calendarSubscribe to calendar event from calendar integration
conversationSubscribe to conversation.user_input event; match intent/sentence
geo_locationSubscribe to geo_location.entered / geo_location.left
sunCompute sunrise/sunset from latitude/longitude in homecore.config; tokio timer
deviceDelegate to integration-specific device trigger via WASM plugin
persistent_notificationSubscribe to persistent_notification.create event
tagSubscribe to tag.scanned event from NFC/QR integration

6. Phased implementation plan

P1 — YAML parser (2 weeks)

  • Define Rust enums for Trigger, Condition, Action, RunMode with serde deserialization.
  • Parse an existing automations.yaml from a real HA install with zero errors (test fixture).
  • Validator: reject unknown trigger platforms with a clear error message.
  • Unit tests: parse 50 automation fixtures covering all 17 trigger types and 22 action types.

P2 — State and event triggers (2 weeks)

  • Implement 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.
  • Integration test: load one automation (state trigger → call_service action); verify fires correctly when state changes.

P3 — Full action set + MiniJinja (3 weeks)

  • MiniJinja + HA extension globals; POST /api/template endpoint wired to WASM evaluator.
  • template trigger + template condition evaluators.
  • choose, if, parallel, repeat, wait_template, sequence action types.
  • All 5 RunMode variants (concurrency control via Tokio semaphore/mutex).
  • Integration test: automations.yaml from ADR-134 migration fixture loads and runs correctly.

P4 — Blueprint system + ruflo agent condition (1 week)

  • Blueprint YAML parser + input variable substitution.
  • Optional ruflo agent condition: condition: ruflo_agent with query: "..." routes to ruflo LLM (ADR-133 §3.3); gated by RUVIEW-POLICY.
  • automation.reload service.
  • Performance benchmark: 100 automations loaded; 100 state changes/s; verify trigger evaluation stays < 5 ms per state change.

7. Risks

RiskLikelihoodSeverityMitigationCross-ADR impact
MiniJinja gaps — some HA templates use Jinja2 features MiniJinja doesn't support (template inheritance, Python-specific filters)MediumMediumDocument 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-outLowLowBenchmark 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 updateHigh (early)HighADR-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-trivialMediumMediumUse a corpus of 500 public HA automations from the HA community (MIT-licensed) as parse-test fixtures in CINone

8. Open questions

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?


8a. Security review (beyond-SOTA sweep, post ADR-154–159)

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.

  • HC-SEC-01 (template-injection / unbounded-expansion DoS, HIGH) — FIXED. A 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).
  • HC-SEC-02 (panic-on-config DoS, MEDIUM) — FIXED. 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 bypass / fail-closed eval — a 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.
  • Re-entrancy / livelock (DoS) — run-mode machinery is bounded and tested: 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.
  • Action authorization — templates are read-only sandboxed (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.
  • Panic-on-config (parse)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).


9. References

HA upstream

  • homeassistant/components/automation/__init__.pyAutomationEntity, AutomationConfig, trigger/condition/action pipeline
  • homeassistant/components/script/__init__.pyScript, ScriptEntity, run modes, action sequence execution
  • homeassistant/helpers/template.pyTemplate class, TemplateEnvironment, all HA-specific Jinja2 globals and filters
  • homeassistant/helpers/config_validation.py — voluptuous schema definitions for all automation/script YAML elements
  • homeassistant/components/automation/blueprint.py — Blueprint input substitution

This repo

  • docs/adr/ADR-127-homecore-state-machine-rust.md — state machine and event bus that triggers subscribe to
  • docs/adr/ADR-133-homecore-assist-ruflo.md — ruflo agent condition + conversation trigger dependency
  • docs/adr/ADR-134-homecore-migration-from-python-ha.md — migration tool reads automations.yaml

External