mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
2bccdf5065
* 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.
213 lines
16 KiB
Markdown
213 lines
16 KiB
Markdown
# 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](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-129 implicit](ADR-129-homecore-automation-engine.md), [ADR-133](ADR-133-homecore-assist-ruflo.md) (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 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 / 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` | `AutomationEntity` struct with same attribute names |
|
||
| `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__.py` — `AutomationEntity`, `AutomationConfig`, trigger/condition/action pipeline
|
||
- `homeassistant/components/script/__init__.py` — `Script`, `ScriptEntity`, run modes, action sequence execution
|
||
- `homeassistant/helpers/template.py` — `Template` 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
|
||
|
||
- [minijinja crates.io](https://crates.io/crates/minijinja) — Jinja2-compatible template engine in Rust
|
||
- [HA Automation Templating docs](https://www.home-assistant.io/docs/automation/templating/) — HA-specific template globals reference
|