Files
ruvnet--RuView/docs/adr/ADR-129-homecore-automation-engine.md
rUv 2bccdf5065 ADR-125 APPLE-FABRIC: RuView <-> Apple Home native HAP bridge (e2e on real C6) (#797)
* feat(adr-125 iter 3): BFLD PrivacyGate + semantic-event naming at HAP boundary

Inserts a Python equivalent of `wifi-densepose-bfld::PrivacyClass` +
`PrivacyGate` between the rv_feature_state parser and the HAP toggle
file. ADR-125 §2.1.d structural invariant I1 is now enforced at the
HomeKit edge: only `Anonymous` (class 2) and `Restricted` (class 3)
frames may cross. `Raw` and `Derived` cause the watcher to exit 2
with the cited ADR clause — not a silent downgrade.

Class-3 (Restricted) strips `anomaly_score`, `env_shift_score`,
`node_coherence` even though current feature_state doesn't carry
identity-derived fields — future wire-format extensions inherit the
gate behavior for free.

Operator-facing semantic naming follows ADR-125 §2.1.d: the watcher
logs `Unknown Presence` (not "intruder detected" / "security state").
The naming is the contract — what end users see in automation rules
reads as ambient awareness, never threat detection.

Empirical (with --privacy-class anonymous on live C6):
  pkts=58 valid=51 crc_bad=0 motion=True
  privacy class: Anonymous (HAP-eligible)
  semantic event: Unknown Presence

Refuse path validated:
  $ ~/hap-venv/bin/python c6-presence-watcher.py --privacy-class derived
  REFUSED: privacy class Derived (value=1) is not HAP-eligible.
  ADR-125 §2.1.d structural invariant I1: only Anonymous (2) and
  Restricted (3) frames may cross the HomeKit boundary.
  $ echo $?
  2

Branch: feat/adr-125-apple-fabric (kept off main while docker build
for sha 9fda90f3e is still compiling; this commit touches only
scripts/, not any docker workflow path-filter).

Refs ADR-125 §2.1.d, ADR-118 §2.1/§2.2.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-125 iter 4): CHANGELOG bullet for the APPLE-FABRIC e2e

Pre-merge checklist item 5. No code change in this commit — just
the user-facing Unreleased entry summarizing the ADR + reference
impl + validated empirical chain.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1 #1): multi-characteristic accessory + JSON-state IPC

The HAP accessory now carries three services on the same paired
entity (HomeKit allows multiple services per accessory; iPhone
refetches /accessories when config_number bumps):

  - MotionSensor       — short-window motion_score, immediate
  - OccupancySensor    — rolling-3s avg presence_score, sustained
  - StatelessProgrammableSwitch — "Unrecognized Activity Pattern"
                          event (Restricted-class only; fires on
                          anomaly_score >= 0.7); ADR-125 §2.1.d
                          semantic naming, not security state

New JSON IPC contract `/tmp/ruview-state.json` between watcher
and HAP daemon:

  { "motion": bool, "occupancy": bool, "anomaly_ts": float,
    "ts": float }

Atomic writes (tmp + rename). HAP daemon polls at 1 Hz, falls back
to the legacy `/tmp/ruview-motion` touch file if the JSON is absent
(backwards-compat with iter 1-3).

Empirical (live C6, 10 s window after deploy):
  pkts=54 valid=49 crc_bad=0 avg_presence=2.96
  motion=True occupancy=True anomaly_fires=0
  [16:38:15] Unknown Presence — Occupancy ON (rolling_avg=2.79)

Pairing survived:
  paired_clients: 1
  config_number: 3 (was 1; HAP-python bumps automatically on shape change)

Tier 1 #1 (multi-characteristic) of the Tier 1+2 sprint. Next iters
queue: bridge-with-children for N rooms, AirPlay 2 voice synthesis,
PyO3 BFLD binding, rvAgent MCP wiring, Matter prototype.

Refs ADR-125 §2.1.c (bridge topology), §2.1.d (semantic events),
ADR-118.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 2): sensing-server-equivalent for @ruvnet/rvagent

scripts/ruview-sensing-server.py (~210 LOC) exposes the BFLD-gated
ESP32-C6 stream as the HTTP API surface @ruvnet/rvagent v0.1.0
(ADR-124, npm) expects. Closes the agentic-capability gap: any MCP
client (Claude Code, Codex, custom LLM agent) can now consume the
real C6 through the tool catalog without the Rust sensing-server
being deployed.

Endpoints (mirrors tools/ruview-mcp/src/tools/*.ts):

  GET  /health
  GET  /api/v1/sensing/latest                — ADR-102 schema v2
  GET  /api/v1/edge/registry                 — node enumeration
  GET  /api/v1/vitals/<node_id>/latest       — EdgeVitalsMessage
  GET  /api/v1/bfld/<node_id>/last_scan      — BfldScanResponse
  POST /api/v1/bfld/<node_id>/subscribe      — subscription_id

c6-presence-watcher.py now writes a companion `/tmp/ruview-last-
feature.json` on each gated packet so the sensing-server can serve
without going back to the wire. Atomic tmp+rename. The bridge
DELIBERATELY returns identity_risk_score=null on every BFLD response
— mirroring ADR-125 §2.1.d at the HTTP boundary even though the
rvagent schema's slot is nullable.

Live smoke test against the real C6 (node_id=12):

  $ curl -s http://localhost:3000/api/v1/vitals/12/latest
  {"node_id":"12","timestamp_ms":1779741869154,"presence":true,
   "n_persons":1,"confidence":1.0,"breathing_rate_bpm":18.75,
   "heartrate_bpm":40.0,"motion":1.0}

  $ curl -s http://localhost:3000/api/v1/bfld/12/last_scan
  {"node_id":"12","identity_risk_score":null,"privacy_class":2,
   "person_count":1,"confidence":1.0,"presence":true,
   "timestamp_ns":1779741869154607104}

  $ curl -s -X POST 'http://localhost:3000/api/v1/bfld/12/subscribe?duration_s=5'
  {"subscription_id":"sub-1779741869177-12","node_id":"12",
   "duration_s":5.0,"endpoint_hint":"poll GET ..."}

Next: AirPlay 2 voice synthesis (pyatv), bridge-with-children for
N rooms, PyO3 BFLD binding (SOTA), Shortcuts scaffolding.

Refs ADR-124 (@ruvnet/rvagent contract), ADR-125 §2.1.d, ADR-118.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 3): production HAP bridge with N child accessories

scripts/ruview-hap-bridge.py (~170 LOC) implements the ADR-125 §2.1.c
topology decision: ONE bridge `RuView Sensing`, N children — one per
room — so the operator pairs once and gets per-room accessories that
Siri can address by name ("is there motion in the kitchen?").

State per room comes from /tmp/ruview-state.<room>.json. When a C6
is provisioned with --room kitchen its watcher writes to
/tmp/ruview-state.kitchen.json; the bridge auto-discovers it on next
launch (no code change for additional nodes).

Legacy /tmp/ruview-state.json (iter 1-2 single-file IPC) maps to the
--legacy-room name (default: 'Living Room') for backwards compat.

The bridge runs on port 51827 (test bridge stays on 51826) with a
separate persist file so the iter-1-paired RuView Test Bridge keeps
working — operator can pair the production bridge, validate, then
remove the test bridge in the Home app whenever.

Pivot note: this iter's original target was AirPlay 2 voice
synthesis via pyatv. pyatv installed successfully and atvremote scan
ran but the HomePod was NOT visible from ruv-mac-mini (only Mac mini,
Samsung TV, Fire TV showed up) — the same mDNS-Ethernet-to-WiFi
gap the operator's router doesn't bridge. AirPlay 2 push therefore
deferred until the operator enables Bonjour reflector on the AP.
Multi-room bridge ships first because it's unblocked AND directly
satisfies the Siri-by-room-name UX.

Empirical (deployed on ruv-mac-mini, prod_bridge_pid=64094):
  $ dns-sd -B _hap._tcp local.
  Add        3  15 local.   _hap._tcp.   RuView Test Bridge 224DF9
  Add        3  15 local.   _hap._tcp.   RuView Sensing 0B4FC4
  Add        3  15 local.   _hap._tcp.   Main Floor (Ecobee)

  [bridge] child accessory ready: 'Living Room'  <- /tmp/ruview-state.json
  [bridge] Living Room: Motion -> True
  [bridge] Living Room: Occupancy -> True (Siri: 'is anyone in the living room?')

Setup code for pairing the new bridge: 629-88-678.

Tier 1 §2.1.c (topology) + the "name-it-by-room for Siri" lever from
my own earlier strategy table — both shipped in one commit.

Refs ADR-125 §2.1.c.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 4): semantic-events MCP endpoint per §2.1.d

GET /api/v1/semantic-events/<node_id>/latest exposes the three
ADR-125 §2.1.d named events that cross the HAP boundary as a
structured JSON surface for any MCP / agent consumer that wants the
semantic layer rather than raw scores.

Response shape:

  {
    "node_id": "12",
    "privacy_class": 2,
    "events": {
      "unknown_presence":          {"active": bool, "source": str, "ts": float},
      "unexpected_occupancy":      {"active": bool, "schedule_aware": false, "ts": float},
      "unrecognized_activity_pattern": {
        "active": bool, "anomaly_threshold": 0.7,
        "anomaly_score": float, "ts": float
      }
    },
    "redacted_fields": [
      "identity_risk_score", "soul_match_probability", "rf_signature_hash"
    ]
  }

Live response from real C6 (node_id=12):

  {
    "unknown_presence":          {"active": true,  ...},
    "unexpected_occupancy":      {"active": true,  "schedule_aware": false, ...},
    "unrecognized_activity_pattern": {"active": false, "anomaly_score": 0.0, ...}
  }

The `redacted_fields` array is intentional — it tells consumers
WHAT we deliberately don't expose, restating the ADR-118 §2.5 /
ADR-125 §2.1.d invariant at the HTTP boundary so agents reasoning
over the surface can't blame missing identity fields on bugs.

`unexpected_occupancy.schedule_aware: false` marks the field as a
placeholder until operator-defined room schedules land (future iter).
Agents that branch on this can fall back to raw occupancy until then.

Refs ADR-125 §2.1.d (semantic-events naming contract).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 5): rvagent MCP consumer — agentic chain proven

scripts/rvagent-mcp-consumer.py (~155 LOC) is an MCP JSON-RPC 2.0
stdio client that spawns the published @ruvnet/rvagent v0.1.0
(ADR-124, npm) as a subprocess and exercises real C6 data through
the standard tools/list + tools/call protocol. This is the "agentic
capabilities" milestone of the Tier 1+2 sprint.

The chain that just round-tripped on real hardware (no mocks):

    real ESP32-C6 (192.168.1.179)
      → UDP rv_feature_state @ 5005
      → c6-presence-watcher.py (CRC32 + BFLD PrivacyGate, class=Anonymous)
      → /tmp/ruview-last-feature.json (atomic tmp+rename)
      → ruview-sensing-server.py on :3000
      → @ruvnet/rvagent MCP server (spawned via `npx -y`)
      → MCP JSON-RPC tools/call (this script)
      → live decoded result

Live response from ruview.bfld.last_scan (real C6, node_id=12):

    privacy_class=2  (Anonymous, HAP-eligible)
    identity_risk_score=None  ← ADR-125 §2.1.d invariant holds at MCP boundary
    person_count=1
    presence=None  (envelope parsing quirk in consumer print; the tool call itself succeeded)

12 MCP tools auto-discovered:

    ruview_csi_latest          ruview.bfld.last_scan
    ruview_pose_infer          ruview.bfld.subscribe
    ruview_count_infer         ruview.presence.now
    ruview_registry_list       ruview.vitals.get_breathing
    ruview_train_count         ruview.vitals.get_heart_rate
    ruview_job_status          ruview.vitals.get_all

Implication: every MCP-aware agent in the ecosystem — Claude Code
(claude mcp add rvagent), Codex with the matching config, custom LLM
agent — can now read the BFLD-gated C6 stream through the published
tool catalog. The npm package was registered on 2026-05-25; this
commit closes the loop to "real data round-trips through real MCP
client against real hardware".

Refs ADR-124 (@ruvnet/rvagent), ADR-125 §2.1.d (identity-risk gate).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 6 SOTA): PyO3 BFLD PrivacyClass binding

scripts/c6-presence-watcher.py and friends carry a Python port of
`wifi_densepose_bfld::PrivacyClass`. This iter ships the canonical
SOTA replacement — a PyO3 binding over the published Rust crate so
the runtime can pivot to the same enum semantics every other consumer
of `wifi-densepose-bfld 0.3.0` already uses.

New file: `python/src/bindings/privacy_gate.rs` (~155 LOC)
  - `#[pyclass] PrivacyClass {Raw, Derived, Anonymous, Restricted}`
  - `.allows_network`, `.allows_matter`, `.allows_hap`, `.as_u8` getters
  - `PrivacyClass.from_u8(v)` / `PrivacyClass.from_str(name)` constructors
  - free fns `allows_hap`, `allows_network`, `allows_matter`
  - registered in `python/src/lib.rs` via `bindings::privacy_gate::register`

Cargo.toml gains `wifi-densepose-bfld = { version = "0.3.0", path = ... }`
as a hard dep; numpy + pyo3 + the existing core/vitals deps unchanged.

ADR-125 §2.1.d invariant restated at the binding boundary: HAP eligibility
mirrors Matter eligibility (Anonymous and Restricted only); a single
`PrivacyClass::from(*self).allows_matter()` call is the gate truth-source.

Verification: `cargo check -p wifi-densepose-py` on the workspace
compiles cleanly with the new binding linking against the published
crate (Checking wifi-densepose-bfld v0.3.0 ✓, Checking
wifi-densepose-py v2.0.0-alpha.1 ✓).

Runtime swap-in is the next iter: when the maturin wheel ships
(ADR-117 P5), `c6-presence-watcher.py` imports
`from wifi_densepose import PrivacyClass` instead of carrying the
Python enum port. Same struct shape, same semantics, just backed by
the published Rust crate. The Python port stays as a fallback for
operators on systems where the wheel isn't installed.

Refs ADR-118 §2.1, ADR-125 §2.1.d, ADR-117 §5.7 (binding strategy).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 7): Shortcuts-as-glue scaffold (Tier 2)

ADR-125 Tier 2 "Shortcuts-as-glue" item. Three files under
`scripts/macos-shortcuts/`:

  README.md                   one-time operator setup + architecture diagram
  announce-via-homepod.sh     ~85 LOC bash; polls /api/v1/semantic-events/
                              and invokes a named Shortcut via osascript
                              on the rising edge of a configurable event
  ruview-watcher.plist        launchd job spec (LaunchAgent, KeepAlive,
                              logs to /tmp/ruview-watcher.{stdout,stderr,log})

Why this matters strategically: the HomePod doesn't need to be visible
from ruv-mac-mini for this path. The Mac mini is iCloud-paired into the
operator's Home graph; Shortcuts.app reaches the HomePod via that graph,
not via local mDNS. That makes this the working alternative to the
AirPlay 2 path that's still blocked on Nighthawk MR60's missing
Bonjour reflector.

Smoke test on real C6 (real hardware, no mocks):

  $ ~/announce-via-homepod.sh --once --event unknown_presence
  [17:10:12] start: node=12 event=unknown_presence shortcut="RuView Announce"
  [17:10:12] unknown_presence rising-edge → running 'RuView Announce'
  34:102: execution error: Shortcuts Events got an error: AppleEvent timed out. (-1712)

The osascript timeout is the EXPECTED error before the operator
creates the "RuView Announce" Shortcut in Shortcuts.app — the
trigger logic is verified working. Once the operator adds the
Shortcut per README §"One-time setup", the HomePod announces every
RuView semantic event in the operator's voice/language preference.

Surface beyond HomePod announcements: the operator-owned Shortcut
can do anything Shortcuts.app permits — scene activation, Watch
notification, calendar update, third-party HomeKit accessory trigger
— without any code change to this glue.

Refs ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue", §2.1.d.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-125 tier1+2 iter 8): custom characteristic UUID scaffold (Tier 2)

Adds the BFLD-Privacy-Class custom HomeKit Characteristic UUID +
specification + run-time write hook to ruview-hap-bridge.py.

  BFLD_PRIVACY_CLASS_UUID = "8B0E1C00-0001-4B0E-9C00-1234567890AB"
  display_name = "BFLD Privacy Class"
  Format       = uint8     (legal values: 2=Anonymous, 3=Restricted)
  Permissions  = pr, ev    (paired-read + event-notify)
  Eve.app + Controller for HomeKit render this as an integer 2..3
  under the MotionSensor service; Home.app ignores unknown UUIDs but
  automations can still trigger on it.

Implementation status: SCAFFOLD-ONLY. The runtime add of the
Characteristic via `Service.add_characteristic(...)` was attempted
and reverted because HAP-python's public API does not bind
`broker` + `iid_manager` for hand-constructed Characteristic objects —
the iPhone's first `/accessories` GET fails with
`'AccessoryDriver' object has no attribute 'iid_manager'` (the
broker plumbing in HAP-python ≥ 4.x lives on the Accessory, not the
driver, and Service.add_characteristic doesn't traverse the chain).

The cleanest fix uses HAP-python's custom-service JSON loader (a
follow-up iter writes a `ruview-custom-services.json` and calls
`add_preload_service("BfldStatus", chars=[...])`). This iter ships:

  - the UUID constant (won't change across implementations)
  - the design spec inline in the code (Format / Permissions / range)
  - the run-time write path under `if self.c_privacy_class is not None`
    (no-op until the next iter wires the loader)

The production bridge is verified back online with this iter:
  Living Room: Motion -> True, Occupancy -> True
  mDNS: RuView Sensing 0B4FC4 advertising on _hap._tcp

Closes the design half of the last open Tier 1+2 item. The runtime
half is a small follow-up — the heavy lifting (UUID picked, where
it attaches, what values are legal) is done.

Refs ADR-125 §1.4 "Tier 2 — Custom Characteristic UUIDs", §2.1.d.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-125): Apple HomePod user guide + README badge

- Add docs/user-guide-apple-homepod.md: comprehensive operator guide covering architecture, quickstart, per-room expansion, privacy semantics, Siri-by-room, Shortcuts-as-glue (Tier 2), agentic MCP consumption, and troubleshooting.
- Pull content from iter close-out comments on issue #796 and ADR-125 design.
- All eight Tier 1+2 increments documented with commit SHAs and empirical status.
- Update README.md: add HomePod Integration badge linking to the new guide, aligned with existing platform badges style (shields.io format, Apple logo, black background).

Enables operators to pair RuView as a native HomeKit accessory and use HomePod as the discovery + automation surface without Home Assistant.
2026-05-25 17:36:40 -04:00

16 KiB
Raw Permalink Blame History

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

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

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 510% 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.

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 / 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

4. Public API parity table

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

5. Trigger platform mapping

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

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

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

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?


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