HOMECORE: native Rust/WASM/TS port of Home Assistant — ADRs 125-134 implementation (#800)
* 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 sha9fda90f3eis 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. * feat(homecore/p1): ADR-127 state machine scaffold (20 tests pass) New crate v2/crates/homecore/ — DashMap state machine, tokio broadcast event bus, service registry (direct-dispatch P1), in-memory entity registry, HA-compat wire constants. 20/20 unit tests pass. EntityId rejects unicode per ADR-127 Q1 (ASCII strict P1). State machine suppresses no-op writes, preserves last_changed on attribute-only updates, fires state_changed broadcast for every real write. Critical path foundation — ADR-130 (API) and ADR-128 (plugins) can begin P1 once this is in main. Refs: docs/adr/ADR-127-homecore-state-machine-rust.md Refs: #798 Co-Authored-By: claude-flow <ruv@ruv.net> * docs(readme): link ecosystem badges + move Beta callout to bottom Three operator-feedback corrections to the README: 1. Every ecosystem badge in the top row now links to a real destination — Home Assistant -> integrations/home-assistant.md, Matter -> ADR-122, Apple Home -> user-guide-apple-homepod.md, Google Home + Alexa -> the HA integration doc (both ecosystems reach RuView through HA's bridge today). Added an Alexa badge alongside the existing four so all four major ecosystems are represented. Dropped the now-redundant separate "HomePod Integration" badge — the Apple Home badge linking to the same guide is enough. 2. Beta callout moved from line 14 (under the hero image) to a dedicated `## Beta software` section immediately before the License. The callout's content is unchanged; it just no longer gates the elevator pitch. Readers see the value proposition first, the caveats at the bottom alongside license + support. 3. The intro paragraph ("Turn ordinary WiFi into ...") now ends with a one-line summary of native ecosystem support naming all four — Home Assistant, Apple Home & HomePod, Google Home, Alexa — plus the Matter endpoint, each linked. The previous mention of ecosystems was buried further down the page; this surfaces it in the intro where the user reads first. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-plugins/p1): ADR-128 plugin runtime scaffold Adds `v2/crates/homecore-plugins` (0.1.0-alpha.0) — the P1 scaffold for the HOMECORE-PLUGINS WASM integration system (ADR-128): - `manifest.rs`: `PluginManifest` — superset of HA manifest.json; serde round-trip + required-field validation (`domain`/`name`/`version`). - `error.rs`: `PluginError` typed enum (InvalidManifest, AlreadyLoaded, NotFound, RuntimeError, SetupFailed, UnloadFailed, Io). - `plugin.rs`: `HomeCorePlugin` async trait + `PluginId` newtype. - `runtime.rs`: `PluginRuntime` trait + `InProcessRuntime` (native Rust, first-party plugins). `WasmtimeRuntime` stub gated on `--features wasmtime` (default-off; 30 MB dep deferred to P2). - `registry.rs`: `PluginRegistry<R>` — load/unload/list/contains via RwLock. - 10 unit tests, 0 failed. Wasmtime vs wasm3 runtime selection is still open (ADR-128 §8 Q2); this scaffold makes the choice swappable via the `PluginRuntime` trait. The `wasmtime` and `wasm3` features are default-off; P2 resolves the choice and wires host ABI (`hc_state_get`/`hc_state_set`/etc.) to ADR-127. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore/p1 iter-2): API (ADR-130) + plugins (ADR-128) scaffolds in parallel Two new crates land in this iteration of the HOMECORE swarm: ## v2/crates/homecore-api/ (ADR-130 P1, sequential foundation) Wire-compat Axum REST + WebSocket port of HA's API. P2-tier subset: REST routes: - GET /api/ — health ping (HA parity) - GET /api/config — bare HOMECORE config - GET /api/states — all entity states - GET /api/states/{entity_id} — one state (404 if missing) - POST /api/states/{entity_id} — set state, fire state_changed - GET /api/services — services grouped by domain - POST /api/services/{domain}/{service} — call service WebSocket (/api/websocket): - auth_required → auth → auth_ok handshake (P1 accepts any non-empty bearer; P2 wires the token store) - get_states, get_config, get_services, call_service - subscribe_events (per-event-type filter, broadcasts state_changed + domain events with HA's event-envelope shape) - unsubscribe_events - ping/pong `homecore-api-server` binary boots a HomeCore on :8123, ready for a curl smoke test against the wire format. ## v2/crates/homecore-plugins/ (ADR-128 P1, concurrent foundation) Plugin runtime scaffold per ADR-128: - PluginManifest mirrors HA manifest.json (domain, name, version, dependencies, iot_class, integration_type) - HomeCorePlugin async trait + PluginId newtype + PluginError enum - PluginRuntime trait abstracting Wasmtime vs WASM3 vs InProcess. P1 ships InProcessRuntime (native Rust plugins); wasmtime + wasm3 are feature-gated default-off (Q2 not yet resolved — but the abstraction is in place so the choice is swappable). - PluginRegistry: load/unload/list by PluginId. ## Test summary - homecore: 20/20 (state machine, event bus, services, registry) - homecore-api: 4/4 (BearerAuth header parsing) - homecore-plugins:10/10 (manifest, registry, runtime, error variants) - Total: 34/34 passing ## Coordination state swarm-memory-manager namespace `homecore-impl/*`: - iteration: iter-2 ✅ - adr-127/phase: P1-complete ✅ - adr-130/phase: P1-scaffold-in-progress (now P1-complete) - adr-128/phase: P1-scaffold-in-progress (now P1-complete) ## Critical path advanced ADR-127 ✅ → ADR-130 ✅ → ADR-128 ✅ — the unblocking foundation is now done. Next iteration can fan out 129/131/132/133/134/125 concurrently. Tracking issue #798. Refs: docs/adr/ADR-130-homecore-rest-websocket-api.md Refs: docs/adr/ADR-128-homecore-integration-plugin-system.md Refs: #798 Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-hap/p1): ADR-125 HAP bridge scaffold (17 tests pass) Add `homecore-hap` crate: HapAccessoryType (11 variants), HapCharacteristic, EntityToAccessoryMapper (light/switch/binary_sensor/sensor/cover/lock domains), HapBridge add/remove/running API, NullAdvertiser mDNS stub, and RuViewToHapMapper (presence→OccupancySensor, fall→LeakSensor, motion→MotionSensor). P2 `hap-server` feature gates the real hap = "0.1" server + mdns-sd integration. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-recorder/p1): ADR-132 SQLite recorder + fnv64a attr dedup (14 tests pass) - SQLite-backed state history with HA-compat schema (states, state_attributes, events, recorder_runs) mirroring recorder schema v48 - FNV-1a 64-bit attribute deduplication matching HA's db_schema.py fnv64a - RecorderListener subscribes to StateMachine broadcast and persists every state change; subscription created at construction to avoid missed events - SemanticIndex trait + NullSemanticIndex for P1; ruvector-backed impl stub feature-gated behind --features ruvector for P2 hand-off Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-automation/p1): ADR-129 automation engine + MiniJinja templates (34 tests pass) Scaffolds `v2/crates/homecore-automation` per ADR-129 HOMECORE-AUTO: - Automation struct with RunMode (single/restart/queued/parallel/ignore_first) - Trigger enum: State, NumericState, Time, Event + EvaluateTrigger trait - Condition enum: State, NumericState, Template, And, Or, Not + async evaluate - Action enum: ServiceCall, Delay, Scene, WaitForTrigger, Choose + async execute - TemplateEnvironment: MiniJinja 2.x with HA globals states(), state_attr(), is_state(), now() - AutomationEngine: subscribes to state-machine broadcast, evaluates triggers, runs action tasks 34 unit tests pass (0 failed). MiniJinja filter coverage: states, state_attr, is_state, now (P1 set). Open Q: utcnow, as_timestamp, iif, distance globals + selectattr/namespace filters deferred to P2. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-migrate/p1): ADR-134 .storage parser + entity-registry import (19 tests pass) - HaStorageEnvelope: outer {version, minor_version, key, data} shape for all .storage files - storage_format/v13: versioned parser dispatch; UnsupportedSchemaVersion hard error on unknown minor_version - entity_registry: core.entity_registry v13 → Vec<homecore::EntityEntry> with full field mapping - device_registry: core.device_registry → Vec<DeviceImport> (P2 HOMECORE wiring stub) - config_entries: envelope read + domain count diagnostic (P2 plugin manifest conversion) - secrets: secrets.yaml → HashMap<String,String> - automations: count + ID list extraction (P2 conversion) - cli: clap-derived Inspect/ImportEntities/ImportDevices/InspectConfigEntries/InspectSecrets/InspectAutomations subcommands - 19 unit tests, all pass; build clean; workspace member appended to v2/Cargo.toml Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-assist/p1): ADR-133 intent pipeline + ruflo runner stub (23 tests pass) - Creates v2/crates/homecore-assist with intent, recognizer, handler, runner, and pipeline modules per ADR-133 §2 design - RegexIntentRecognizer: HA-style named-capture-group pattern matching - Built-in handlers: HassTurnOn, HassTurnOff, HassLightSet, HassNevermind, HassCancelAll — dispatch to homecore ServiceRegistry - RufloRunner trait + NoopRunner P1 stub (Windows-safe subprocess teardown deferred to P2 per ADR-133 §Q3) - AssistPipeline + default_pipeline() wires recognizer → handler → response - SemanticIntentRecognizer P2 stub (ruvector HNSW deferred) - 23 unit tests, 0 failures; cargo build -p homecore-assist clean Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-131/recon): cognitum-one/v0-appliance design recon for HOMECORE-FRONTEND Captures the full design system from the live cognitum-v0:9000 dashboard (all 10 nav pages fetched, HTTP 200, unauthenticated). Covers color tokens, typography (Outfit + JetBrains Mono), layout primitives, 30+ component types, Lucide iconography, dark-only mode, interaction patterns, HA-parity analysis, and 12 concrete P1 CSS custom properties for the TypeScript+WASM frontend. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-frontend/p1): @ruvnet/homecore-frontend Lit+TS+Vite scaffold (3 tests) Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-recorder/p2): wire RuvectorSemanticIndex with hash-based embeddings (resolves ADR-132 P2) - ruvector-core = "2.2.0" + sha2 = "0.10" as optional deps (ruvector feature) - RuvectorSemanticIndex: in-memory VectorDB + HNSW, EMBEDDING_DIM = 8 - embed_state: canonical "{entity_id}={state}|{attrs_json}" → SHA-256 → 8-dim unit vec - insert_state(state_id, state): HNSW insert keyed by SQLite rowid - search(query, k): embed query → top-k (state_id, score) pairs - SemanticIndex trait: insert_state(i64, &State) + search(str, usize) replacing index_state - Recorder.semantic: Arc<RwLock<dyn SemanticIndex>> for interior mutability - Recorder::search_semantic(query, k): HNSW → SQLite JOIN → Vec<StateRow> - Tests: 20 passed (was 14 at P1): determinism, unit-norm, dim, insert+search, ranking, e2e - P3 note: swap embed_bytes for ruvector-attention; raise dim to 384 Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-plugins/p2): Wasmtime runtime + example WASM plugin (resolves ADR-128 Q2) - Implements WasmtimeRuntime in v2/crates/homecore-plugins/src/wasmtime_runtime.rs with a Wasmtime 25 Cranelift JIT engine. Registers 4 host imports via Linker: hc_state_get, hc_state_set, hc_state_subscribe, hc_log. Each plugin gets an isolated Store<PluginStoreData> holding a HomeCore handle + subscription list. - Adds host_abi.rs documenting the JSON-over-linear-memory wire format (public ABI spec for plugin authors). Max buffer 64 KiB. ConfigEntryJson and StateChangedEventJson are the canonical wire types. - Creates v2/crates/homecore-plugin-example/ (wasm32-unknown-unknown, excluded from workspace per wifi-densepose-wasm-edge pattern). The plugin monitors sensor.test_temp and sets binary_sensor.test_alert on/off at 25/20 thresholds. - Adds tests/integration.rs with 3 tests: compiled .wasm end-to-end round-trip, WAT-based fallback (always runs), and linker smoke test. All 15 tests pass (12 unit + 3 integration) under --features wasmtime. - ADR-128 Q2 resolved: Wasmtime is the chosen runtime for P2. WASM3 stays as future fallback under --features wasm3 for constrained hardware (ADR-128 §8). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(homecore-server/iter-9): integration binary tying all 8 HOMECORE crates together New crate `v2/crates/homecore-server/` boots one process that wires every HOMECORE surface into a single HA-compatible runtime: 1. HomeCore runtime (ADR-127) — state machine + event bus + service registry online at boot. 2. Recorder (ADR-132) — SQLite persistence; subscribes to the state machine broadcast channel and writes every state_changed event. Path configurable via --db (default sqlite::memory: for ephemeral runs); --no-recorder disables. ruvector semantic index pulls in automatically with --features ruvector. 3. Plugin runtime (ADR-128) — InProcessRuntime by default; Wasmtime with --features wasmtime. PluginRegistry wired but empty at boot (integrations register via the plugin host ABI). 4. Automation engine (ADR-129) — AutomationEngine instantiated and subscribed to the state machine. No automations loaded at boot yet; that's a YAML-loading P3 task. 5. Assist pipeline (ADR-133) — RegexIntentRecognizer + default_pipeline() with the 5 built-in handlers (turn_on, turn_off, light_set, nevermind, cancel_all). 6. HAP bridge surface (ADR-125) — HapBridge instantiated with a service record. Accessory registration via the API. 7. REST + WebSocket API (ADR-130) — Axum router on :8123, HA-compat. /api/, /api/config, /api/states[/{eid}], /api/services[/...], /api/websocket. Configuration via CLI flags + env vars: - --bind / HOMECORE_BIND (default 0.0.0.0:8123) - --db / HOMECORE_DB (default sqlite::memory:) - --location-name / HOMECORE_LOCATION (default "Home") - --no-recorder Builds clean (`cargo build -p homecore-server`). Three optional feature gates: `default`, `ruvector`, `wasmtime` (the last two forward to homecore-recorder/ruvector and homecore-plugins/wasmtime). Refs: docs/adr/ADR-126-ruview-native-ha-port-master.md §5 phase roadmap Refs: #798 Co-Authored-By: claude-flow <ruv@ruv.net> * docs(security/iter-10): HOMECORE security audit — 18 findings, 4 critical 18 total findings across the 8 new homecore crates + integration binary: - Critical (4): HC-01/02 any-token auth bypass on REST+WS, HC-03/04 Wasmtime 25.0.3 sandbox-escape CVEs (RUSTSEC-2026-0095/0096, CVSS 9.0) - High (3): permissive CORS, sqlx 0.7.4 protocol bug, unbounded WS subscriptions - Medium (5): hardcoded HAP setup code, hc_log bypasses tracing, no body size limit, rsa Marvin Attack, shlex quote injection - Low/Info (6): no TLS, migrate symlink gap, eprintln in automation engine, subscription dedup, two informational cargo audit: 18 advisories (2 critical wasmtime sandbox escapes, fix = upgrade wasmtime to >=36.0.7; upgrade sqlx to >=0.8.1) Co-Authored-By: claude-flow <ruv@ruv.net> * fix(homecore-recorder/sec): bump sqlx 0.7.4 → 0.8.1+ (RUSTSEC, audit HC-medium) Per iter-10 security audit (docs/security/HOMECORE-security-audit-iter10.md): sqlx 0.7.4 ships an advisory for binary protocol misinterpretation. Bump to 0.8.1+ — cargo resolved to 0.8.6. Feature set unchanged (default-features = false + runtime-tokio-native-tls, sqlite, chrono, uuid). Tests still pass: cargo test -p homecore-recorder --features ruvector → 20 passed; 0 failed No code changes required. The 0.7 → 0.8 API surface we touch in `db.rs` is stable across the bump. Deferred to a later iter: - shlex 0.1.1 → ≥1.3.0 (transitive via wasm3-sys, only on --features wasm3 which is default-off; will be addressed when the wasm3 path is removed per ADR-128 Q2 Wasmtime resolution) - wasmtime 25 → 36+/42+ (HC-03/04 CVSS 9.0 sandbox-escape) — being handled by a background coder agent this iter, separate commit. Refs: docs/security/HOMECORE-security-audit-iter10.md (HC-09 sqlx) Refs: #798 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(homecore-plugins/sec): bump wasmtime 25 → 42 for RUSTSEC-2026-0095/0096 (HC-03/04, CVSS 9.0) Remediates iter-11 security audit findings HC-03 (RUSTSEC-2026-0095) and HC-04 (RUSTSEC-2026-0096) — Cranelift/Winch sandbox-escape CVEs (CVSS 9.0). Version specifier updated from "25" → "42"; lockfile already pinned at 42.0.2. Zero code-surface changes required: Engine/Linker/Store/Instance and Memory.data/data_mut APIs are ABI-compatible across this range. All 15 tests pass (12 unit + 3 integration including the two required wasm_plugin_temp_threshold tests). cargo audit no longer reports RUSTSEC-2026-0095 or RUSTSEC-2026-0096 against this workspace. Co-Authored-By: claude-flow <ruv@ruv.net> * perf(homecore): criterion benches for state-machine hot paths `cargo bench -p homecore --bench state_machine` covers: - set/first_write — cold-path insert + alloc + broadcast - set/warm_write_state_change — same-entity update fires broadcast - set/noop_suppressed — same state+attrs, no broadcast (HA semantic) - get/hit + get/miss — zero-copy Arc<State> read paths - all_snapshot/{10,100,1000} — Vec<Arc<State>> snapshot for REST - all_by_domain_light_20_of_100 — domain prefix filter - broadcast_fan_out/{1,4,16,64} — 1 sender + N subscribers, async, measures end-to-end deliver-and-recv latency The broadcast fan-out is the most load-bearing measurement for HOMECORE — every integration, the recorder, the automation engine, and every WS subscriber holds a receiver, so the per-subscriber delivery cost determines how many add-ons the runtime can host. criterion 0.5 with sample_size=20 (fast tick, the fast-path benches run in nanoseconds and don't need 100 samples). Refs: docs/adr/ADR-127-homecore-state-machine-rust.md Refs: #798 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(homecore-api/sec): close HC-01/HC-02 — real bearer-token store Replaces the P1 "any non-empty bearer" placeholder with a real LongLivedTokenStore (HashSet<String>) on SharedState. Closes the two Critical findings from the iter-10 security audit (docs/security/HOMECORE-security-audit-iter10.md HC-01 + HC-02). New module `homecore-api::tokens`: - LongLivedTokenStore::empty() — default-deny - LongLivedTokenStore::from_env() — reads HOMECORE_TOKENS=t1,t2,t3 - LongLivedTokenStore::allow_any_non_empty() — DEV-only, warns on every check, preserves legacy behaviour for migrating users - register / revoke / is_valid / len / is_dev_mode — full API Wired through: - SharedState gains `tokens: LongLivedTokenStore`; constructors with_tokens(...) for explicit injection; with_metadata defaults to DEV (allow_any) for backwards compat with existing smoke tests - BearerAuth::from_headers now async + takes &LongLivedTokenStore; checks store.is_valid(token) before returning Ok - All 6 REST handlers updated to thread the store and await the validation - homecore-server reads HOMECORE_TOKENS at boot; if set, builds the store from env; if unset, falls back to DEV with a warn log Test count: 4 → 15 (+11 token-store + auth-with-store tests). Smoke verified end-to-end: HOMECORE_TOKENS=good homecore-server --bind 127.0.0.1:8126 → "LongLivedTokenStore provisioned with 1 bearer token(s)" curl -H "Authorization: Bearer good" .../api/states → 200 curl -H "Authorization: Bearer wrong" .../api/states → 401 curl -H "Authorization: Bearer " .../api/states → 401 curl .../api/states → 401 Refs: docs/security/HOMECORE-security-audit-iter10.md (HC-01 + HC-02) Refs: docs/adr/ADR-130-homecore-rest-websocket-api.md §3 auth Refs: #798 Refs: #800 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(homecore-api/sec): close HC-05 — CORS allowlist instead of permissive Replaces `CorsLayer::permissive()` (which set Access-Control-Allow- Origin: *) with an explicit allowlist via `CorsLayer::new()`. Default allowlist covers the homecore-frontend Vite dev server (5173) plus common reverse-proxy ports (3000, 8080, 8081) and the bind port itself (8123). Production deployments override via HOMECORE_CORS_ORIGINS=https://app.example.com,https://hass.example.com (comma-separated). Method allowlist: GET, POST, OPTIONS, DELETE (no PUT/PATCH yet). Header allowlist: Authorization, Content-Type, Accept. Credentials: disabled (no cookies in HOMECORE-API path). Test count: 15 → 18 (+3 CORS allowlist tests). Closes audit finding HC-05 (High). The HC-01/02 bearer-store fix in commit408cfd4f0only mattered if the cross-origin path was also locked down — without HC-05 a malicious page could still make authenticated calls with a stored bearer. Refs: docs/security/HOMECORE-security-audit-iter10.md (HC-05) Refs: #800 Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
baba851a89
commit
e96ebaea81
|
|
@ -0,0 +1,176 @@
|
|||
# ADR-133: HOMECORE-ASSIST — Voice/Intent Pipeline + Ruflo Agent Bridge
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-ASSIST** |
|
||||
| **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-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE) |
|
||||
| **Tracking issue** | TBD |
|
||||
| **Crate** | `v2/crates/homecore-assist` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Home Assistant's Assist pipeline (`homeassistant/components/assist_pipeline/`) provides
|
||||
voice-to-intent-to-response processing. It chains:
|
||||
|
||||
1. **STT** (speech-to-text) — Whisper, cloud, or satellite
|
||||
2. **NLU** (natural language understanding) — intent recognition via regex/slots
|
||||
3. **Intent handler** — maps intent to a HA service call
|
||||
4. **TTS** (text-to-speech) — synthesises the response for the caller
|
||||
|
||||
HA's intent model (`homeassistant/helpers/intent.py`) is keyword/regex based. Every
|
||||
intent is a named template with slot definitions and a handler that dispatches to HA
|
||||
services. The built-in intents (`homeassistant/components/conversation/default_agent.py`)
|
||||
cover `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll`,
|
||||
`HassGetState`, `HassGetWeather`, and many others.
|
||||
|
||||
HOMECORE needs a wire-compatible Assist pipeline so that:
|
||||
- The HA iOS/Android companion app's "Assist" button works against HOMECORE.
|
||||
- The HOMECORE-API WebSocket `assist` command (ADR-130 §2.2) has a handler.
|
||||
- The ruflo agent toolchain (ADR-124) can provide LLM-grade intent disambiguation as a
|
||||
drop-in upgrade path for the P1 regex recognizer.
|
||||
|
||||
### 1.1 Ruflo integration approach
|
||||
|
||||
Ruflo's agent runner exposes an MCP-over-stdio interface (`node ruflo-agent.js`).
|
||||
HOMECORE-ASSIST manages a long-lived subprocess (Q3 Windows concern below), sends
|
||||
utterance JSON, and receives intent JSON back. In P1 we ship only the trait surface
|
||||
and a `NoopRunner` stub; the real subprocess management is P2.
|
||||
|
||||
### 1.2 Ruvector semantic intent matching (P2)
|
||||
|
||||
`ruvector-core` provides embedding + cosine-similarity primitives. P2 will add a
|
||||
`SemanticIntentRecognizer` that embeds the utterance and compares it to a HNSW index
|
||||
of intent exemplars, falling back to the P1 regex recognizer when similarity < 0.75.
|
||||
This is the mechanism that allows "dim the lights" to match `HassLightSet` without an
|
||||
explicit regex entry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 Module layout (`v2/crates/homecore-assist/`)
|
||||
|
||||
| Module | Contents |
|
||||
|--------|----------|
|
||||
| `intent` | `IntentName` newtype, `Intent` (name + slots), `IntentResponse` (speech + optional card + optional data) |
|
||||
| `recognizer` | `IntentRecognizer` trait; `RegexIntentRecognizer` (P1); `SemanticIntentRecognizer` stub (P2) |
|
||||
| `handler` | `IntentHandler` trait; built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll` |
|
||||
| `runner` | `RufloRunner` trait + `RufloRunnerOpts`; `NoopRunner` (P1 stub); real subprocess runner (P2) |
|
||||
| `pipeline` | `AssistPipeline`: wires recognizer → handler → response; exposes `async fn process(utterance, language) -> IntentResponse` |
|
||||
|
||||
### 2.2 Built-in intent handlers (P1)
|
||||
|
||||
| Handler | HA service call | Slot |
|
||||
|---------|-----------------|------|
|
||||
| `HassTurnOn` | `homeassistant.turn_on` / `light.turn_on` / `switch.turn_on` | `entity_id` |
|
||||
| `HassTurnOff` | `homeassistant.turn_off` / `light.turn_off` / `switch.turn_off` | `entity_id` |
|
||||
| `HassLightSet` | `light.turn_on` | `entity_id`, `brightness` (0–255), `color_name` |
|
||||
| `HassNevermind` | — (no-op, returns acknowledgement) | — |
|
||||
| `HassCancelAll` | — (fires `homeassistant_stop_all_scripts` domain event) | — |
|
||||
|
||||
### 2.3 IntentResponse
|
||||
|
||||
```rust
|
||||
pub struct IntentResponse {
|
||||
pub speech: String,
|
||||
pub card: Option<Card>,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct Card {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RufloRunner trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait RufloRunner: Send + Sync + 'static {
|
||||
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
|
||||
async fn send_request(&self, payload: serde_json::Value) -> Result<RufloResponse, AssistError>;
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError>;
|
||||
}
|
||||
```
|
||||
|
||||
`RufloResponse` is `{ intent: Option<Intent>, speech: Option<String> }`.
|
||||
|
||||
### 2.5 Pipeline
|
||||
|
||||
```rust
|
||||
pub struct AssistPipeline<R, H> {
|
||||
recognizer: R,
|
||||
handler: H,
|
||||
runner: Option<Box<dyn RufloRunner>>,
|
||||
}
|
||||
|
||||
impl<R: IntentRecognizer, H: IntentHandler> AssistPipeline<R, H> {
|
||||
pub async fn process(&self, utterance: &str, language: &str, hc: &HomeCore)
|
||||
-> Result<IntentResponse, AssistError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Questions & Answers
|
||||
|
||||
### Q1 — Why not reuse HA's existing `homeassistant.helpers.intent` via PyO3?
|
||||
|
||||
PyO3 bridges add a GIL lock on every cross-language call; the Assist pipeline processes
|
||||
hundreds of short utterances per day from voice satellites. A native Rust recognizer is
|
||||
simpler and faster. Python HA can still connect as an external integration via MQTT or
|
||||
the HOMECORE WebSocket API.
|
||||
|
||||
### Q2 — How does `RegexIntentRecognizer` handle ambiguity?
|
||||
|
||||
Patterns are tried in registration order; the first match wins. Slot extraction uses
|
||||
named capture groups. A future P2 upgrade can run all patterns, score them by slot
|
||||
completeness, and return the highest-scoring match.
|
||||
|
||||
### Q3 — Windows subprocess teardown (ruflo runner subprocess on Windows)
|
||||
|
||||
`tokio::process::Child` on Windows does not automatically kill the child process when
|
||||
the `Child` struct is dropped — `SIGTERM` is not a Windows concept, and `TerminateProcess`
|
||||
is not called automatically. Options for P2:
|
||||
|
||||
1. Call `child.start_kill()` in a `Drop` impl (requires a `Runtime` handle — tricky in sync Drop).
|
||||
2. Wrap `Child` in an `Arc<Mutex<Option<Child>>>` and call `kill()` in an `async fn shutdown()`.
|
||||
3. Use a Windows job object to bind the subprocess lifetime to the parent process.
|
||||
|
||||
**P2 decision**: implement option 2 (explicit `async shutdown()`) + register a `tokio::signal`
|
||||
handler for `Ctrl+C` / `SIGINT` that calls `shutdown()` before exit. Document the Windows caveat
|
||||
in the crate README and in `runner.rs`. Job object approach (option 3) is deferred to P3 only
|
||||
if option 2 proves insufficient in fleet testing.
|
||||
|
||||
### Q4 — Why is `SemanticIntentRecognizer` a P2 stub?
|
||||
|
||||
The ruvector HNSW index requires the vector store to be populated at startup with intent
|
||||
exemplars. That startup path requires deciding on a serialization format (HNSW index files
|
||||
vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ) and ADR-067
|
||||
(ruvector v2.0.5). P2 will define the exemplar format and populate the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
- **Positive**: HOMECORE-API `assist` WebSocket command gets a functional backend.
|
||||
- **Positive**: Ruflo LLM pipelines can upgrade intent matching by swapping the `RufloRunner` impl.
|
||||
- **Positive**: P1 ships with zero new heavy dependencies (no subprocess spawning, no ML runtime).
|
||||
- **Negative**: Regex matching has limited coverage; long-tail utterances will return "I'm not sure".
|
||||
- **Deferral**: ruvector semantic recognizer and real subprocess runner both land in P2.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation phases
|
||||
|
||||
| Phase | Scope |
|
||||
|-------|-------|
|
||||
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests |
|
||||
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
|
||||
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
# HOMECORE-FRONTEND Design Recon — ADR-131
|
||||
|
||||
**Source:** cognitum-one/v0-appliance dashboard at `http://cognitum-v0:9000/`
|
||||
**Captured:** 2026-05-25 by browser-recon agent (session `20260525-181819-adr131-recon`)
|
||||
**Pages fetched:** dashboard, cogs, seeds, edge, analytics, settings, cluster, tailscale, aidefence, guide (all HTTP 200)
|
||||
**Auth:** dashboard is unauthenticated; `/api/*` requires bearer token — all recon confined to dashboard pages
|
||||
|
||||
---
|
||||
|
||||
## 1. Color Palette
|
||||
|
||||
The entire UI is dark-only. There is no light mode and no `prefers-color-scheme` media query anywhere in the stylesheet. Every surface is drawn from a tight family of near-black navy blues with two accent hues: a cool teal (`--primary`) and a green (`--accent`).
|
||||
|
||||
### Core tokens (hex conversions from HSL source)
|
||||
|
||||
| CSS variable | HSL value | Hex | Role |
|
||||
|---|---|---|---|
|
||||
| `--background` | `220 25% 6%` | `#0b0e13` | Page background, modal overlay base |
|
||||
| `--foreground` | `210 20% 92%` | `#e6eaee` | Body text, headings |
|
||||
| `--primary` | `185 80% 50%` | `#19d4e5` | Teal — active nav underline, CTA borders, ring focus, brand slash |
|
||||
| `--primary-foreground` | `220 25% 6%` | `#0b0e13` | Text on filled primary buttons |
|
||||
| `--accent` | `142 70% 50%` | `#26d867` | Green — secondary CTA, success state, deploy button text |
|
||||
| `--accent-foreground` | `220 25% 6%` | `#0b0e13` | Text on filled accent buttons |
|
||||
| `--secondary` | `220 20% 14%` | `#1c212a` | Button fill, pill-tab background |
|
||||
| `--card` | `220 20% 10%` | `#14171e` | Card surface (also popover) |
|
||||
| `--surface-elevated` | `220 20% 12%` | `#181c24` | Slightly elevated card variant |
|
||||
| `--surface-overlay` | `220 20% 8%` | `#111318` | Modal scrim, sticky navbar |
|
||||
| `--muted` | `220 15% 15%` | `#20242b` | Muted chip backgrounds, scrollbar track |
|
||||
| `--muted-foreground` | `215 15% 55%` | `#7b899d` | Secondary text, labels, timestamps |
|
||||
| `--border` | `220 15% 18%` | `#272b34` | All borders (at 50% opacity by default) |
|
||||
| `--destructive` | `0 65% 50%` | `#d22c2c` | Error state, danger button |
|
||||
| `--ring` | `185 80% 50%` | `#19d4e5` | Focus ring (same hue as primary) |
|
||||
|
||||
### Semantic status colors (inline, not variables)
|
||||
|
||||
| State | Color | Hex | Usage |
|
||||
|---|---|---|---|
|
||||
| Online / success | `hsl(142 70% 50%)` | `#26d867` | `.badge.online`, `.dot.up`, `.heat-cell.up` |
|
||||
| Warning | `hsl(38 80% 60%)` | `#e69940` | `.badge.unpaired`, `.hero-dot.warn`, banner backgrounds |
|
||||
| Error / offline | `hsl(0 65% 50%)` | `#d22c2c` | `.badge.offline`, `.badge.danger`, `.dot.down` |
|
||||
| Info (log line) | `hsl(205 80% 65%)` | `#4db8f5` | Log viewer `.info` class |
|
||||
| Paired | `hsl(185 80% 50%)` | `#19d4e5` | `.badge.paired` (same as primary) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
|
||||
### Font families
|
||||
|
||||
The CSS declares two font families via CSS custom properties:
|
||||
|
||||
- `--font-display: 'Outfit', system-ui, sans-serif` — all headings, nav items, buttons, card titles, KPI values. Outfit is a modern geometric sans loaded locally (no Google Fonts outbound call; the source comment says "ship from local chrome.css fallback").
|
||||
- `--font-mono: 'JetBrains Mono', monospace` — timestamps, port numbers, version strings, table cells, log output, KPI labels, chip text.
|
||||
|
||||
### Type scale
|
||||
|
||||
| Token name / usage | Size | Weight | Notes |
|
||||
|---|---|---|---|
|
||||
| Hero title (`h1.hero-title`) | `clamp(1.5rem, 2.4vw, 2.1rem)` | 600 | Fluid, capped at ~33.6px |
|
||||
| Page h1 (`.page`) | `1.5rem` (24px) | 600 | All inner pages |
|
||||
| Section heading (`.row-h h2`) | `1.125rem` (18px) | 700 | Section openers on Cogs/Dashboard |
|
||||
| Card title (`.card-title`) | `0.9375rem` (15px) | 600 | |
|
||||
| Body / button | `0.8125rem` (13px) | 400/500 | Default body, nav links, buttons |
|
||||
| Secondary body / lede | `0.875rem` (14px) | 400 | Page lede text |
|
||||
| Small label | `0.75rem` (12px) | 400–600 | Table cells, modal sub-text |
|
||||
| Micro label | `0.6875rem` (11px) | 600 | Section eyebrows, uppercase KPI labels, badge text |
|
||||
| Mono micro | `0.625rem` (10px) | 400 | Heatmap cells, chip category text |
|
||||
|
||||
Letter-spacing: `0.1em` on section eyebrows (`.section h2`), `0.08em` on filter-rail headings and chip category text, `-0.02em` on all `h1–h4` display headings. Line-height for body is `1.5`; lede text uses `1.45`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout Primitives
|
||||
|
||||
### Page shell
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ .appbar (sticky, z-50, backdrop-filter:blur(8px)) │
|
||||
│ [brand-mark] [brand-text] [nav links scrollable] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ .wrap (max-width: 1400px, padding: 1.5rem 1.25rem) │
|
||||
│ ┌── .hero (full-width, gradient bg, radial accents) │
|
||||
│ ├── .kpi-grid (auto-fill, min 170px columns) │
|
||||
│ ├── .section > h2 (eyebrow) + content │
|
||||
│ └── .grid / .grid-2 / .grid-3 (auto-fit) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ footer.appfoot (border-top, centered text) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Appbar:** `position: sticky; top: 0; z-index: 50`. Background is the page background at 90% opacity with 8px blur backdrop-filter, so the page content bleeds through. Nav links overflow-scroll horizontally with a right-fade mask gradient.
|
||||
|
||||
**Active nav state:** primary-colored text + a 2px bottom border line (`::after` pseudo-element) positioned at bottom: -2px of the link. Hover reveals secondary background fill on the link.
|
||||
|
||||
**Content wrap:** max-width 1400px, centered, 1.25rem horizontal padding. Inner page sections are separated by margin-bottom spacing in multiples of 0.75rem (base unit = 12px at 16px root).
|
||||
|
||||
### Cogs page: app-store sub-navigation
|
||||
|
||||
The Cogs page adds a sticky secondary nav bar (`.subnav`) at `top: 3.25rem` (just below the appbar). Tabs are borderless buttons with a 2px bottom underline indicator when active. A `flex: 1` spacer pushes a gear icon to the right edge.
|
||||
|
||||
### Card patterns
|
||||
|
||||
Three card variants, all sharing the same surface gradient and border:
|
||||
|
||||
1. **Standard card (`.card`)** — `background: var(--gradient-card)` (linear 180deg from `--surface-elevated` to `--surface-overlay`), 1px border at 50% opacity, `--radius` (0.75rem), `box-shadow` 8px/32px dark drop shadow.
|
||||
2. **KPI card (`.kpi`)** — 38px icon square left + text right, same gradient, 1rem/1.125rem padding, smaller vertical rhythm.
|
||||
3. **Empty-state card (`.empty-card`)** — dashed 1px border (instead of solid), centered text, optional compact variant. The headline in `.empty-card h3` uses the primary teal, body explains what to do next.
|
||||
|
||||
### Spacing rhythm
|
||||
|
||||
Base unit is 4px. Gaps between grid items are universally `0.75rem` (12px). Card padding is `1.25rem` (20px) for standard, `0.875rem` (14px) for compact. Section margin-bottom is `1.5rem` (24px). The hero section uses `1.75rem` (28px) horizontal padding.
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Vocabulary
|
||||
|
||||
### Navigation components
|
||||
|
||||
- **Appbar** — sticky top bar with brand + horizontal nav links. Brand mark is a 32px rounded SVG icon square.
|
||||
- **Nav link** — 0.4rem × 0.7rem padding, 0.4rem radius, transitions on color + background. Active state: primary text + 2px underline pseudo-element. Mobile: wraps below brand row at 720px.
|
||||
- **Sub-nav / secondary tab bar** (`.subnav`) — app-store style horizontal tab strip, sticky under appbar. Used exclusively on Cogs.
|
||||
- **Pill tabs** (`.pill-tabs` + `.pill-tab`) — smaller rounded-rect tab group for in-card filter switching. Active state fills with primary color.
|
||||
- **Page tabs** (`.page-tabs`) — used on Analytics for domain view switching. Underline-style, same pattern as sub-nav but at content level.
|
||||
|
||||
### Card & data display
|
||||
|
||||
- **Card** (`.card`) — base data container with gradient surface, subtle border, shadow.
|
||||
- **KPI tile** (`.kpi`, `.kpi-tile`) — metric display with icon, label (uppercase micro mono), large value, and optional sub-line. Two variants: `.kpi` (icon-left layout) and `.kpi-tile` (stack layout, used on Seeds/Edge/AIDefence).
|
||||
- **Node card** (`.node`) — cluster member card with mono metadata rows. Key-value pairs in `.node-meta` with dimmed label prefix (`.l` class).
|
||||
- **Cog card** (`.cog`) — product-catalog card with emoji icon, name, description, category chips, and a "Get" pill button. Hover lifts 2px with primary glow border.
|
||||
- **Pick card** (`.pick-card`) — horizontal-scroll featured card (220px fixed width), snap-scroll container. Smaller emoji + name + category + pill CTA.
|
||||
- **Category tile small** (`.cat-tile-sm`) — 180px min-width grid item, emoji + name + count.
|
||||
- **Category tile large** (`.cat-tile-big`) — 16:9 aspect-ratio card, full-bleed with gradient per category.
|
||||
- **Nav tile** (`.nav-tile`) — dashboard home navigation card with icon square, title, description, and a chevron arrow that translates +2px on hover.
|
||||
- **Architecture action card** (`.arch-card`, `.arch-action-card`) — setup wizard launcher cards on the dashboard.
|
||||
|
||||
### Status & feedback
|
||||
|
||||
- **Badge** (`.badge`) — pill with 1px border, 11px mono text. Variants: `role-master` (teal), `role-worker` (green), `online` (green), `offline` (red), `unknown` (muted), `paired` (teal), `unpaired` (amber), `danger` (red).
|
||||
- **Dot** (`.dot`) — 8px circle status indicator. `.up` glows green with box-shadow, `.down` is red, default is muted gray.
|
||||
- **Hero dot** (`.hero-dot`) — 7px circle in the dashboard hero status row. Same three states: `.ok` (green glow), `.warn` (amber glow), `.down` (red glow).
|
||||
- **Op-pill** (`.op-pill`) — "operational status" pill with colored dot inside. Used in dashboard architecture hub.
|
||||
- **AI pill / status chip** (`.pill` on AIDefence, `.md-badge` in cluster) — inline classification badge at 0.68rem. States: `.ok`, `.warn`, `.bad`.
|
||||
- **Chip** (`.chip`) — tiny category/difficulty label, all-caps, 0.5625rem, pill-shaped. Category-colored variants (`.cat-ai`, `.cat-health`, `.cat-security`, etc.) each get a hue-appropriate 15% opacity background.
|
||||
|
||||
### Actions
|
||||
|
||||
- **Button** (`.btn`) — 0.5rem × 0.875rem padding, 0.4rem radius, secondary fill. Variants: `.primary` (filled teal, 600 weight, box-shadow), `.outline` (transparent fill), `.danger` (red tint), `.sm` (compact).
|
||||
- **Hero button** (`.hero-btn`) — slightly larger, display-font, 0.9rem padding, glass-effect dark fill. `.primary` variant uses the green accent gradient.
|
||||
- **Pill CTA** (`.get`, `.pget`) — full pill-radius (9999px), primary-tint background at rest, fills solid on hover. Used on cog cards and pick cards.
|
||||
- **Gear button** (`.gear-btn`) — icon-only square button, transparent at rest, border appears on hover.
|
||||
- **Context menu** (`.ctx-menu`) — dark card dropdown (min-width 180px), each item is a full-width button with secondary hover fill.
|
||||
- **Copy button** (`.copy-btn`) — positioned absolute in `.copy-row`, 0.7rem opacity at rest, `.copied` state turns green/accent.
|
||||
|
||||
### Forms & inputs
|
||||
|
||||
- **Input** — all `<input>`, `<textarea>`, `<select>` inherit dark theme globally. Focus ring: 2px solid primary at 30% opacity (`box-shadow: 0 0 0 2px hsl(var(--ring) / 0.3)`). Checkboxes and radios use `accent-color: hsl(var(--primary))`.
|
||||
- **Collapsible section** (`.coll`, `.coll-h`, `.coll-body`) — used in Settings page. Header row is clickable with `user-select: none`. Body `display: none` by default, revealed on expand.
|
||||
- **Key-value row** (`.kv`) — 3-column grid (160px label | 1fr value | auto action) for settings display.
|
||||
- **Filters rail** (`.filters-rail`) — sticky sidebar on Cogs/Apps tab. Sticky at `top: 7rem` (below both navbars). Contains checkboxes, a range input, and a reset button.
|
||||
- **Range input** — native `<input type="range">` styled with `accent-color: hsl(var(--primary))`.
|
||||
|
||||
### Data visualization
|
||||
|
||||
- **Heatmap** (`.heatmap`) — CSS grid of 14px × variable cells. 60 time columns, label column at 90px. Cell states: `up` (green 70%), `down` (red 70%), `empty` (muted 30%).
|
||||
- **Bar chart** (`.bar-list` + `.bar-row` + `.bar-fill`) — horizontal bar list, 3-col grid (120px label | 1fr bar | 30px value). Bar fill transitions width in 0.3s.
|
||||
- **uPlot time-series** (`.uplot-host`) — 200px height host container; actual charting via uPlot library.
|
||||
- **Three.js 3D** — importmap for `three` + `OrbitControls` in Analytics page, for 3D sensor visualization.
|
||||
- **Log box** (`pre.logbox`) — monospace pre-formatted block, max-height 30rem, overflow-y scroll. Dark background on dark background gives subtle separation via border.
|
||||
- **OTA row table** (`.ota-row`) — 3-col grid (160px | 80px | 1fr) for firmware OTA records.
|
||||
|
||||
### Overlays
|
||||
|
||||
- **Modal** (`.modal-bg` + `.modal`) — fixed inset, 70% opacity blur-backdrop scrim. Modal itself is card-surfaced, max-width 560px. Result states: `.modal-result.ok` (green tint) and `.modal-result.err` (red tint).
|
||||
- **Detail modal** (`.detail-modal-bg` + `.detail-modal`) — larger variant (max 820px, 2rem padding) used on Cog detail view. Header has emoji, name, meta chips; sections below are tabbed.
|
||||
- **Keyboard shortcut tag** (`.kb`) — small monospace tag with secondary background, used inline in Settings and Tailscale pages to show keyboard shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 5. Iconography
|
||||
|
||||
All icons are inline SVG, 24×24 viewBox, `fill: none`, `stroke: currentColor`, `stroke-width: 2`. The path geometry is **Lucide Icons** — confirmed by comparing the Sun/gear/shield/grid/activity paths against Lucide's source. Key examples observed:
|
||||
|
||||
- Sun/rays (brand mark, dashboard hero)
|
||||
- Settings/gear (nav, subnav gear button)
|
||||
- Activity/pulse (KPI signal icon)
|
||||
- Bar chart 3 (analytics KPI)
|
||||
- Grid 2×2 (cluster/cog layout)
|
||||
- Shield with checkmark (AIDefence)
|
||||
- House (home nav tile)
|
||||
- Book-open (guide nav)
|
||||
|
||||
No external icon font is used. Every icon is self-contained in the HTML at point of use — no sprite sheet.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dark Mode
|
||||
|
||||
The design is **dark-only**. There is no `prefers-color-scheme: light` media query in `v0-chrome.css` or any page-level stylesheet. The color system is entirely designed around the dark palette above. The source comments explicitly note that `fonts.googleapis.com` is blocked for Tailnet isolation, reinforcing that this is an always-dark appliance UI, not a consumer product that needs theming.
|
||||
|
||||
Surface hierarchy (light to dark, within the dark palette):
|
||||
1. `--surface-elevated` (`#181c24`) — slightly lighter card variant
|
||||
2. `--card` (`#14171e`) — standard card
|
||||
3. `--surface-overlay` (`#111318`) — modal/sticky appbar base
|
||||
4. `--background` (`#0b0e13`) — page root
|
||||
|
||||
The appbar uses `background: hsl(var(--background) / 0.9)` + `backdrop-filter: blur(8px)` so content underneath bleeds through as a translucency effect.
|
||||
|
||||
---
|
||||
|
||||
## 7. Notable Interactions
|
||||
|
||||
- **Nav hover:** 150ms color + background transition, no translate. Active state uses a 2px pseudo-element underline that animates in via opacity.
|
||||
- **Nav link active press:** `transform: translateY(1px)` on `:active` at 50ms — very subtle tactile response.
|
||||
- **Card hover:** `transform: translateY(-2px)` at 200ms on cards and cog items. Border shifts from `--border/0.5` to `primary/0.4` on hover. On the nav tiles, box-shadow deepens.
|
||||
- **Hero button hover:** `transform: translateY(-1px)` + border-color shift to primary at 70%.
|
||||
- **Pick card hover:** translateY(-2px) + primary-glow box-shadow.
|
||||
- **Focus ring:** 2px solid primary at 30% opacity as box-shadow — uses `outline: none` everywhere and replaces it with the ring shadow. nav links use `outline: 2px solid hsl(var(--primary)/0.6); outline-offset: 1px` for focus-visible.
|
||||
- **Bar fill animation:** `transition: width 0.3s` on bar chart fill elements for data-load entrance.
|
||||
- **Modal backdrop:** `backdrop-filter: blur(4px)` on modal scrim, `blur(6px)` on the Cog detail modal.
|
||||
- **Copy button feedback:** `.copied` state class swaps border and text to accent green, visible for a short duration (JS-controlled).
|
||||
- **Pill CTA:** Background fills from 15% opacity teal to 100% solid on hover — a strong affordance for primary actions.
|
||||
- **Scroll fade mask:** The nav bar has `mask-image: linear-gradient(to right, black calc(100% - 24px), transparent)` to fade out the rightmost item, hinting at horizontal scroll.
|
||||
- **Cogs hero carousel:** Paginator dots expand from 0.55rem circles to 1.5rem pill shape (border-radius 0.4rem) when active — a distinctive indicator pattern.
|
||||
|
||||
---
|
||||
|
||||
## 8. HA-Parity Opportunities
|
||||
|
||||
For ADR-131 P2, the following comparisons are relevant between this design and Home Assistant's frontend (`home-assistant-main`):
|
||||
|
||||
| HOMECORE component | Cognitum V0 pattern | HA equivalent | Better reference |
|
||||
|---|---|---|---|
|
||||
| KPI metric card | `.kpi` — icon + label + value | `ha-statistic-card`, `sensor-badge` | **Cognitum** — cleaner dense layout; HA's is more verbose |
|
||||
| Status badge/pill | `.badge` + `.chip` — pill with 1px border | `ha-label-badge`, `state-badge` | **HA** — HA has more state variants and i18n built in |
|
||||
| Dark surface cards | `--gradient-card` linear gradient | HA uses flat `var(--card-background-color)` | **Cognitum** — gradient gives depth HA lacks |
|
||||
| Toggle/switch | `accent-color` native checkbox | HA `ha-switch` (Material) | **HA** — purpose-built, accessible, animated |
|
||||
| Navigation | Horizontal sticky nav, underline indicator | HA sidebar (vertical) | Neither — HOMECORE needs a new shell; Cognitum's horizontal bar is appropriate for appliance context |
|
||||
| Heatmap timeline | CSS grid `.heatmap` | No HA equivalent | **Cognitum** — take this pattern directly |
|
||||
| Bar chart | CSS-only `.bar-fill` bar list | HA uses Recharts | **Cognitum** — zero-dep CSS bars good for simple metrics; use for small cards |
|
||||
| Time-series chart | uPlot `.uplot-host` | HA uses ApexCharts / Recharts | **HA** — ApexCharts has more features, better RTL support |
|
||||
| Modal | `.modal-bg` blur-backdrop | HA `ha-dialog` (Material) | **HA** — a11y and focus-trap already solved |
|
||||
| Toast / alert banner | `.modal-result.ok/err` inline result + `.cl-banner.warn/err` | HA `ha-alert` | **HA** — HA's alerts are more composable |
|
||||
| Focus ring | `box-shadow` ring pattern | HA uses `:focus-visible` outline | **HA** — HA's approach has better browser compatibility |
|
||||
| Chip (category) | `.chip.cat-*` per-category color mapping | HA `ha-chip` | **Cognitum** — the category-specific hue mapping is richer |
|
||||
|
||||
---
|
||||
|
||||
## 9. Design Tokens for HOMECORE-FRONTEND P1
|
||||
|
||||
Concrete CSS variable names and starting values for the TypeScript+WASM frontend to adopt. These follow the Cognitum V0 source directly, adjusted where needed for HOMECORE context.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--hc-bg: hsl(220 25% 6%); /* #0b0e13 — page root */
|
||||
--hc-surface-card: hsl(220 20% 10%); /* #14171e — card fill */
|
||||
--hc-surface-elevated: hsl(220 20% 12%); /* #181c24 — raised panel */
|
||||
--hc-surface-overlay: hsl(220 20% 8%); /* #111318 — modal/nav base */
|
||||
|
||||
/* Text */
|
||||
--hc-text: hsl(210 20% 92%); /* #e6eaee — primary text */
|
||||
--hc-text-muted: hsl(215 15% 55%); /* #7b899d — secondary/label */
|
||||
|
||||
/* Accent palette */
|
||||
--hc-primary: hsl(185 80% 50%); /* #19d4e5 — teal, primary actions */
|
||||
--hc-primary-fg: hsl(220 25% 6%); /* #0b0e13 — text on primary */
|
||||
--hc-accent: hsl(142 70% 50%); /* #26d867 — green, success/CTA */
|
||||
--hc-accent-fg: hsl(220 25% 6%); /* #0b0e13 — text on accent */
|
||||
--hc-destructive: hsl(0 65% 50%); /* #d22c2c — error/danger */
|
||||
--hc-warning: hsl(38 80% 60%); /* #e69940 — warning/amber */
|
||||
|
||||
/* Borders & rings */
|
||||
--hc-border: hsl(220 15% 18%); /* #272b34 — subtle border */
|
||||
--hc-ring: hsl(185 80% 50%); /* #19d4e5 — focus ring */
|
||||
|
||||
/* Radii */
|
||||
--hc-radius: 0.75rem; /* cards, modals */
|
||||
--hc-radius-sm: 0.4rem; /* buttons, inputs, chips */
|
||||
--hc-radius-pill: 9999px; /* badges, CTA pills */
|
||||
|
||||
/* Typography */
|
||||
--hc-font-display: 'Outfit', system-ui, sans-serif;
|
||||
--hc-font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Shadows */
|
||||
--hc-shadow-card: 0 8px 32px -8px hsl(220 25% 2% / 0.8);
|
||||
--hc-shadow-glow: 0 0 60px -10px hsl(185 80% 50% / 0.3);
|
||||
|
||||
/* Gradients */
|
||||
--hc-gradient-card: linear-gradient(180deg, hsl(220 20% 12%) 0%, hsl(220 20% 8%) 100%);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes for P1 implementation:**
|
||||
|
||||
- Adopt Outfit + JetBrains Mono from Google Fonts in development; ship local fallbacks for production (Tailnet appliances block outbound font requests per the Cognitum source comment).
|
||||
- The `--hc-ring` focus approach should be implemented as `box-shadow: 0 0 0 2px hsl(var(--hc-ring) / 0.3)` combined with `outline: none` — matches Cognitum's pattern and avoids the offset-gap issue in Firefox.
|
||||
- Add `--hc-gradient-hero` and `--hc-gradient-glow` when the dashboard hero section is built; keep them out of the P1 design-token foundation to avoid premature complexity.
|
||||
- The `--hc-warning` amber is not in the Cognitum `:root` block (it is inline throughout) — elevating it to a token is a deliberate improvement for HOMECORE.
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# HOMECORE Security Audit — Iter-10
|
||||
|
||||
**Branch**: `feat/adr-126-homecore-impl`
|
||||
**Audit date**: 2026-05-25
|
||||
**Scope**: 8 new crates + integration binary (iter-1 through iter-9)
|
||||
**Auditor**: Security-audit agent (claude-sonnet-4-6)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
HOMECORE's Rust codebase is structurally sound but ships with two pre-production
|
||||
placeholders that are critical blockers for any production deployment: the HTTP
|
||||
bearer-token validator accepts **any non-empty string as a valid token**, and the
|
||||
WebSocket auth handshake does the same. Every protected endpoint is therefore fully
|
||||
open to unauthenticated attackers who can reach port 8123.
|
||||
|
||||
`cargo audit` flagged **18 advisories** across three dependency trees. Two are
|
||||
Critical (CVSS 9.0): both are Wasmtime sandbox-escape bugs in the Winch and
|
||||
Cranelift compiler backends (RUSTSEC-2026-0095/0096). SQLx 0.7.4 carries a
|
||||
binary-protocol misinterpretation bug (RUSTSEC-2024-0363). The Wasmtime
|
||||
version must be upgraded before any WASM plugin is loaded in production.
|
||||
|
||||
Additional findings: `CorsLayer::permissive()` allows cross-origin requests from
|
||||
any domain; the HAP service record hardcodes a predictable setup code and a
|
||||
broadcast MAC address; `hc_log` writes plugin output directly to `eprintln!`
|
||||
without going through `tracing`; and the WS `subscribe_events` command has no
|
||||
per-connection subscription cap, enabling a resource-exhaustion DoS.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
| ID | Severity | Title | File : Line | Description | Remediation |
|
||||
|----|----------|-------|-------------|-------------|-------------|
|
||||
| HC-01 | **Critical** | Bearer auth accepts any non-empty token (REST) | `homecore-api/src/auth.rs:25` and `rest.rs` (all handlers) | `BearerAuth::from_headers` returns `Ok` for any non-empty string. All REST endpoints (`/api/config`, `/api/states`, `/api/services`, `call_service`) are fully open to any caller. | Implement a token store in P2 before deployment. Until then, enforce network-level ACL so port 8123 is unreachable from untrusted networks. |
|
||||
| HC-02 | **Critical** | WebSocket auth handshake accepts any non-empty token | `homecore-api/src/ws.rs:61–68` | The WS `auth` phase validates only that `access_token` is non-empty. After passing this check the client reaches the full command loop including `call_service`. An attacker sending `{"type":"auth","access_token":"x"}` gets a fully authenticated session. | Same as HC-01; block at network until real token store is wired. |
|
||||
| HC-03 | **Critical** | Wasmtime 25.0.3 — sandbox-escape via Winch backend (RUSTSEC-2026-0095) | `homecore-plugins/Cargo.toml` | The Winch compiler backend in Wasmtime 25.0.3 allows a sandboxed WASM plugin to perform out-of-sandbox memory writes (CVSS 9.0). | Upgrade `wasmtime` to `>=36.0.7` or `>=42.0.2`. |
|
||||
| HC-04 | **Critical** | Wasmtime 25.0.3 — sandbox-escape via miscompiled heap access on aarch64 Cranelift (RUSTSEC-2026-0096) | `homecore-plugins/Cargo.toml` | Miscompiled guest heap access in Cranelift's aarch64 backend enables sandbox escape (CVSS 9.0). Production Pi 5 targets are aarch64. | Upgrade `wasmtime` to `>=36.0.7` or `>=42.0.2`. |
|
||||
| HC-05 | **High** | `CorsLayer::permissive()` allows all cross-origin requests | `homecore-api/src/app.rs:25` | `CorsLayer::permissive()` sets `Access-Control-Allow-Origin: *` and allows all methods and headers. Any webpage on any origin can make authenticated API calls using a stored bearer token (when HC-01/02 are fixed). | Replace with an explicit allowlist: `CorsLayer::new().allow_origin(expected_origin).allow_methods([GET, POST])`. |
|
||||
| HC-06 | **High** | SQLx 0.7.4 — binary protocol misinterpretation (RUSTSEC-2024-0363) | `homecore-recorder/Cargo.toml` | Truncating/overflowing casts in SQLx 0.7.4's binary protocol handling can cause values to be misread. Although HOMECORE only uses SQLite (not MySQL/Postgres), the vulnerable codepath is in the shared crate. | Upgrade `sqlx` to `>=0.8.1`. |
|
||||
| HC-07 | **High** | No per-connection subscription cap on WS `subscribe_events` | `homecore-api/src/ws.rs:237–295` | A single authenticated WS connection can call `subscribe_events` in an unbounded loop. Each subscription spawns a Tokio task and takes one broadcast receiver slot. With the bus capacity at 4096 slots, a malicious client can exhaust OS thread/task resources before the bus fills. | Add a per-connection subscription ceiling (e.g., 50). Reject further `subscribe_events` commands with `"too_many_subscriptions"`. |
|
||||
| HC-08 | **High** | Hardcoded HAP setup code and broadcast MAC in production binary | `homecore-server/src/main.rs:113–114`, `homecore-hap/src/bridge.rs:143–144` | The integration binary hard-codes `setup_code: "123-45-678"` and `device_id: "AA:BB:CC:DD:EE:FF"`. When real HAP pairing lands in P2 any attacker on the local network can pair with the bridge using the published setup code; the broadcast MAC address is also invalid per the HAP specification. | Generate a random setup code and a locally administered unicast MAC at startup (or require them as CLI arguments). Never use a known-fixed setup code. |
|
||||
| HC-09 | **Medium** | Wasmtime 25.0.3 — 11 additional medium/low CVEs | `homecore-plugins/Cargo.toml` | RUSTSEC-2025-0046, -0118, -2026-0020, -0021, -0085, -0086, -0087, -0088, -0089, -0091, -0092, -0093, -0094 affect resource exhaustion, host data leakage, OOB reads/writes, and panics. All are fixed in wasmtime `>=36.0.7`. | Same fix as HC-03/04: upgrade wasmtime. |
|
||||
| HC-10 | **Medium** | `hc_log` writes plugin output via `eprintln!` bypassing structured logging | `homecore-plugins/src/wasmtime_runtime.rs:297` | Plugin log messages are written directly to stderr via `eprintln!`, bypassing the `tracing` subscriber. This means: (a) log level filtering does not apply to plugin output; (b) log aggregation pipelines (e.g., JSON structured logs) miss plugin messages. A verbose or malicious plugin can flood stderr. | Replace `eprintln!` with `tracing::debug!/info!/warn!/error!` using the already-imported `LogLevel`. |
|
||||
| HC-11 | **Medium** | No size bound on `set_state` body or `attributes` JSON | `homecore-api/src/rest.rs:95–108`, `ws.rs:222–235` | `POST /api/states/:entity_id` and the WS `call_service` / `get_states` paths accept a `serde_json::Value` body with no size limit beyond Axum's default (2 MB). Specially crafted deeply-nested JSON can cause quadratic parse time or high-memory allocation during serialization. | Apply `axum::extract::DefaultBodyLimit::max(65536)` on the route or globally; validate JSON depth before accepting. |
|
||||
| HC-12 | **Medium** | `rsa 0.9.10` — Marvin Attack timing side-channel (RUSTSEC-2023-0071) | transitive via `sqlx-mysql 0.7.4` | The `rsa` crate's decryption is vulnerable to timing-based key recovery. Pulled in by `sqlx-mysql` even though HOMECORE only uses SQLite. No fix is available upstream. | Add `sqlx` features `sqlite` only (remove `mysql`/`postgres` from the feature list) to avoid pulling in `sqlx-mysql` and the `rsa` transitive dependency. |
|
||||
| HC-13 | **Medium** | `shlex 0.1.1` — shell-injection via quote API (RUSTSEC-2024-0006) | transitive via `wasm3-sys 0.3.0 → wasm3 0.3.1 → homecore-plugins` | `shlex`'s quote function can produce unsafe shell strings. Pulled in by the `wasm3` build system. Not directly callable from HOMECORE Rust code but present in the binary's dependency tree. | Upgrade `shlex` to `>=1.3.0` or drop the `wasm3` dependency if `WasmtimeRuntime` is the production path. |
|
||||
| HC-14 | **Low** | No TLS on the HTTP/WS listener | `homecore-server/src/main.rs:122–128` | The Axum listener binds plain TCP (`axum::serve`). Bearer tokens and all home automation data are transmitted in cleartext. On LAN deployments an attacker with ARP poisoning can intercept credentials. | Add `rustls`/`axum-server` TLS termination or document that a TLS-terminating reverse proxy (nginx/Caddy) is required. |
|
||||
| HC-15 | **Low** | Migration CLI performs no symlink/traversal check on `.storage/` path | `homecore-migrate/src/storage.rs:36–37`, `main.rs:14–32` | `HaStorageDir::file_path` calls `self.path.join(name)` where `name` comes from hard-coded constants, so exploitation requires the `--storage` argument itself to point outside the intended tree. There is no `Path::canonicalize` + prefix check. While the current filenames are constants, if P2 makes `name` data-driven the surface widens. | Add `path.canonicalize()` + assert prefix after computing `file_path` if the name ever becomes user-controlled. Document this as a P2 gate. |
|
||||
| HC-16 | **Low** | `AutomationEngine` uses `eprintln!` for action errors | `homecore-automation/src/engine.rs:93–95, 105` | Action errors and lag notices are emitted via `eprintln!`, not `tracing::warn!`. Same issues as HC-10: bypasses structured logging. | Replace with `tracing::warn!`/`tracing::error!`. |
|
||||
| HC-17 | **Informational** | WS `call_service` authorization is contingent on fixing HC-01/HC-02 | `homecore-api/src/ws.rs:222–235` | `call_service` (including destructive calls such as `homeassistant.restart`) sits behind the WS auth handshake. Once HC-01 and HC-02 are fixed this path is properly guarded. No additional change needed here beyond those fixes. | No action required beyond HC-02. |
|
||||
| HC-18 | **Informational** | `hc_state_subscribe` accumulates entity strings without eviction | `homecore-plugins/src/wasmtime_runtime.rs:263–268` | The `PluginStoreData.subscriptions` Vec grows without bound if a plugin repeatedly subscribes to the same entity. There is no deduplication. This is a plugin-local memory leak, not a sandbox escape. | Deduplicate on insert: `if !caller.data().subscriptions.contains(&eid)`. |
|
||||
|
||||
---
|
||||
|
||||
## Negative-Result Section (Surfaces Checked and Found Clean)
|
||||
|
||||
**SQL injection (homecore-recorder/src/db.rs)**: All queries use `sqlx::query`
|
||||
with positional `?` bind parameters. No `format!`-constructed SQL was found in
|
||||
any path (`record_state`, `record_event`, `get_state_history`, `search_semantic`,
|
||||
`apply_schema`). Clean.
|
||||
|
||||
**WS bearer token in logs/error messages**: The bearer token is extracted and
|
||||
immediately discarded after the non-empty check at ws.rs:62. It is not passed
|
||||
to any `tracing` macro, `eprintln!`, or error-display path. The `access_token`
|
||||
field is not part of any `Debug`-derived struct that enters a log path. Clean.
|
||||
|
||||
**REST bearer token in logs/error messages**: `BearerAuth(token)` is `Debug`
|
||||
but no handler logs it or includes it in an error response. `ApiError` variants
|
||||
do not capture the token. Clean.
|
||||
|
||||
**WASM linear-memory buffer overflow in `hc_state_get`/`hc_state_set`**: The
|
||||
`read_str` helper validates `len < 0` and `len > MAX_ABI_BUFFER_BYTES (65536)`
|
||||
before slicing, and uses `mem.get(ptr..ptr+len)?` which cannot panic. In
|
||||
`hc_state_get` phase 3, the write is guarded by `json_bytes.len() > out_cap`
|
||||
before attempting the slice. The `call_export_str` host-to-guest path also uses
|
||||
`.get_mut(ptr..ptr+len).ok_or_else(...)` rather than unchecked indexing. No
|
||||
buffer-overflow vector identified in the host ABI.
|
||||
|
||||
**WASM JSON ABI escape**: Plugins receive and emit plain UTF-8 JSON strings via
|
||||
the linear-memory ABI. The host deserializes attribute JSON with
|
||||
`serde_json::from_str` and defaults to `{}` on parse failure — no panic path.
|
||||
No mechanism for a plugin to escape the Cranelift JIT sandbox via the JSON layer
|
||||
alone was identified; the sandbox-escape risk is in the Cranelift/Winch compiler
|
||||
backends (HC-03/04).
|
||||
|
||||
**Path traversal in homecore-migrate**: All `.storage/` filenames are currently
|
||||
hard-coded constants (`"core.entity_registry"`, `"core.device_registry"`, etc.)
|
||||
in the Rust source. The `--storage` and `--config-dir` arguments are user-supplied
|
||||
but refer to the directory root, not individual filenames. No user-controlled
|
||||
string is concatenated into a file path. Clean at P1 scope (noted as a P2 gate in HC-15).
|
||||
|
||||
**DoS via event-bus flood from a plugin**: A WASM plugin can call `hc_state_set`
|
||||
in a tight loop. Each call fires a `broadcast::Sender::send` on the system channel
|
||||
(capacity 4096). When the channel is full, `send` returns 0 (receivers are
|
||||
dropped/lagged) but does not block or panic. Lagged receivers are notified via
|
||||
`RecvError::Lagged`. The state machine itself does not back-pressure the sender.
|
||||
The flood can cause the recorder and automation engine to lag, but it cannot crash
|
||||
the host process. Noted as design-level concern; acceptable for P1.
|
||||
|
||||
**Secrets leakage in homecore-migrate InspectSecrets**: The CLI correctly prints
|
||||
`<redacted>` for secret values and only logs key names.
|
||||
|
||||
---
|
||||
|
||||
## Critical-Path Remediation List (Required Before Production Deployment)
|
||||
|
||||
The following items MUST be resolved before `homecore-server` is reachable from
|
||||
any untrusted network:
|
||||
|
||||
1. **HC-01 + HC-02 (Critical)** — Implement the token store and validate bearer
|
||||
tokens in both `BearerAuth::from_headers` and the WS `handle_socket` auth
|
||||
phase. Until this is done every REST and WS endpoint is completely open.
|
||||
|
||||
2. **HC-03 + HC-04 (Critical)** — Upgrade `wasmtime` in `homecore-plugins/Cargo.toml`
|
||||
from `25.0.3` to `>=36.0.7` (or `>=42.0.2`). The current version has two
|
||||
confirmed CVSS-9.0 sandbox-escape bugs; loading any third-party WASM plugin
|
||||
on the current version cannot be considered safe.
|
||||
|
||||
3. **HC-06 (High)** — Upgrade `sqlx` from `0.7.4` to `>=0.8.1` to eliminate the
|
||||
binary-protocol misinterpretation bug.
|
||||
|
||||
4. **HC-05 (High)** — Replace `CorsLayer::permissive()` with an explicit
|
||||
origin allowlist before any browser-accessible deployment.
|
||||
|
||||
5. **HC-08 (High)** — Replace the hardcoded HAP setup code and broadcast MAC
|
||||
address with randomly generated values before P2 real HAP pairing lands.
|
||||
|
||||
6. **HC-07 (High)** — Add per-connection subscription limit to the WS command
|
||||
loop before exposing the server to untrusted LAN clients.
|
||||
|
||||
---
|
||||
|
||||
## Dependency CVE Summary
|
||||
|
||||
`cargo audit` reported **18 advisories** against workspace `Cargo.lock`:
|
||||
|
||||
| Advisory | Crate | Severity | Affects HOMECORE |
|
||||
|----------|-------|----------|------------------|
|
||||
| RUSTSEC-2026-0096 | wasmtime 25.0.3 | Critical (9.0) | homecore-plugins |
|
||||
| RUSTSEC-2026-0095 | wasmtime 25.0.3 | Critical (9.0) | homecore-plugins |
|
||||
| RUSTSEC-2026-0093 | wasmtime 25.0.3 | Medium (6.9) | homecore-plugins |
|
||||
| RUSTSEC-2026-0020 | wasmtime 25.0.3 | Medium (6.9) | homecore-plugins |
|
||||
| RUSTSEC-2026-0021 | wasmtime 25.0.3 | Medium (6.9) | homecore-plugins |
|
||||
| RUSTSEC-2024-0363 | sqlx 0.7.4 | (no CVSS) | homecore-recorder |
|
||||
| RUSTSEC-2026-0091 | wasmtime 25.0.3 | Medium (6.1) | homecore-plugins |
|
||||
| RUSTSEC-2026-0094 | wasmtime 25.0.3 | Medium (6.1) | homecore-plugins |
|
||||
| RUSTSEC-2026-0089 | wasmtime 25.0.3 | Medium (5.9) | homecore-plugins |
|
||||
| RUSTSEC-2026-0092 | wasmtime 25.0.3 | Medium (5.9) | homecore-plugins |
|
||||
| RUSTSEC-2023-0071 | rsa 0.9.10 | Medium (5.9) | transitive via sqlx-mysql |
|
||||
| RUSTSEC-2026-0085 | wasmtime 25.0.3 | Medium (5.6) | homecore-plugins |
|
||||
| RUSTSEC-2026-0087 | wasmtime 25.0.3 | Medium (4.1) | homecore-plugins |
|
||||
| RUSTSEC-2025-0046 | wasmtime 25.0.3 | Low (3.3) | homecore-plugins |
|
||||
| RUSTSEC-2026-0086 | wasmtime 25.0.3 | Low (2.3) | homecore-plugins |
|
||||
| RUSTSEC-2026-0088 | wasmtime 25.0.3 | Low (2.3) | homecore-plugins |
|
||||
| RUSTSEC-2025-0118 | wasmtime 25.0.3 | Low (1.8) | homecore-plugins |
|
||||
| RUSTSEC-2024-0006 | shlex 0.1.1 | (no CVSS) | transitive via wasm3-sys |
|
||||
|
||||
All 15 wasmtime advisories are resolved by upgrading to `wasmtime >= 36.0.7`.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# @ruvnet/homecore-frontend
|
||||
|
||||
HOMECORE web UI — built with Lit 3, TypeScript, and Vite.
|
||||
Design system mirrors the cognitum-v0 / v0-appliance dashboard (ADR-131).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` → `http://localhost:8123`, so you need a
|
||||
`homecore-api-server` (or the `wifi-densepose-sensing-server` crate) running on `:8123`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Start Vite dev server on port 5173 |
|
||||
| `npm run build` | TypeScript compile + Vite production bundle → `dist/` |
|
||||
| `npm run lint` | ESLint on `src/` |
|
||||
| `npm test` | Vitest unit tests (3 suites, jsdom) |
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
client.ts # fetch + WebSocket client (REST + WS)
|
||||
types.ts # TypeScript types matching homecore-api JSON shapes
|
||||
components/
|
||||
AppShell.ts # <hc-app-shell> — header + nav + content slot
|
||||
StateCard.ts # <hc-state-card> — single entity state card
|
||||
icons/
|
||||
lucide.ts # Tree-shaken Lucide icon wrapper
|
||||
styles/
|
||||
tokens.css # 16 CSS custom properties (--hc-*)
|
||||
base.css # Typography reset, page shell, nav layout
|
||||
__tests__/ # Vitest unit tests
|
||||
index.html # Shell loading src/main.ts
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
```
|
||||
|
||||
## Design system
|
||||
|
||||
Colors, typography, and components mirror the cognitum-v0 dashboard
|
||||
(`http://cognitum-v0:9000/`). Dark-only; no light-mode. Key tokens:
|
||||
|
||||
- `--hc-primary` `#19d4e5` — teal (active nav, focus ring, CTA borders)
|
||||
- `--hc-accent` `#26d867` — green (success, secondary CTA)
|
||||
- `--hc-bg` `#0b0e13` — near-black navy page root
|
||||
- Font: Outfit (display) + JetBrains Mono (mono)
|
||||
- Icons: Lucide (SVG, `stroke: currentColor`, no icon font)
|
||||
|
||||
See `docs/design/HOMECORE-FRONTEND-design-recon.md` for the full recon.
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Components are standard Lit `LitElement` custom elements — compatible with
|
||||
any HTML page and with Home Assistant's Lit-based frontend.
|
||||
- The REST client uses `fetch`; the WS client uses `WebSocket`. Both accept a
|
||||
bearer token and are fully typed against the Rust `homecore-api` JSON shapes.
|
||||
- WASM: `vite.config.ts` enables `.wasm` asset import. Hook up via dynamic
|
||||
`import('/path/to/module.wasm?init')` when WASM bindings are ready.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>HOMECORE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<hc-app-shell></hc-app-shell>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@ruvnet/homecore-frontend",
|
||||
"version": "0.1.0-alpha.0",
|
||||
"description": "HOMECORE web UI — Lit + TypeScript + Vite, cognitum-v0 design system",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.2.1",
|
||||
"lucide": "^0.474.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"eslint": "^9.17.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Unit tests for <hc-state-card>.
|
||||
* Verifies that the component renders entity_id and state value into the DOM.
|
||||
*
|
||||
* Uses jsdom (via vitest environment) — no real browser required.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
// Register the custom element before tests run
|
||||
beforeAll(async () => {
|
||||
// jsdom does not support Lit's adoptedStyleSheets; suppress the error.
|
||||
if (typeof document !== 'undefined' && !document.adoptedStyleSheets) {
|
||||
Object.defineProperty(document, 'adoptedStyleSheets', { value: [], writable: true });
|
||||
}
|
||||
await import('../components/StateCard.js');
|
||||
});
|
||||
|
||||
function makeState(overrides: Partial<StateView> = {}): StateView {
|
||||
return {
|
||||
entity_id: 'light.living_room',
|
||||
state: 'on',
|
||||
attributes: { brightness: 255 },
|
||||
last_changed: '2026-05-25T10:00:00Z',
|
||||
last_updated: '2026-05-25T10:00:00Z',
|
||||
context: { id: 'abc123', user_id: null, parent_id: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StateCard', () => {
|
||||
it('renders entity_id in the DOM', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState();
|
||||
document.body.appendChild(el);
|
||||
|
||||
// Lit renders synchronously into shadow root after a microtask
|
||||
await el.updateComplete;
|
||||
|
||||
const shadowRoot = el.shadowRoot!;
|
||||
const entityEl = shadowRoot.querySelector('.entity-id');
|
||||
expect(entityEl).not.toBeNull();
|
||||
expect(entityEl!.textContent).toContain('light.living_room');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('renders the state value', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'off' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const stateEl = el.shadowRoot!.querySelector('.state-value');
|
||||
expect(stateEl).not.toBeNull();
|
||||
expect(stateEl!.textContent).toBe('off');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('applies .off badge class for unavailable state', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'unavailable' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const badge = el.shadowRoot!.querySelector('.badge.off');
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Augment for updateComplete
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Unit tests for HomecoreClient REST methods.
|
||||
* Mocks global `fetch` and asserts correct URL + Authorization header.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
|
||||
describe('HomecoreClient', () => {
|
||||
const token = 'test-bearer-token';
|
||||
let client: HomecoreClient;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HomecoreClient({ token });
|
||||
fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('getStates() GETs /api/states with the bearer header', async () => {
|
||||
await client.getStates();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
expect(url).toBe('/api/states');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${token}`);
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('getState() GETs /api/states/:entity_id with the bearer header', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ entity_id: 'light.living', state: 'on', attributes: {}, last_changed: '', last_updated: '', context: { id: 'x', user_id: null, parent_id: null } }),
|
||||
} as Response);
|
||||
|
||||
await client.getState('light.living');
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/states/light.living');
|
||||
});
|
||||
|
||||
it('getConfig() GETs /api/config', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ location_name: 'Home', version: '0.1.0', state: 'RUNNING', components: [] }),
|
||||
} as Response);
|
||||
|
||||
await client.getConfig();
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/config');
|
||||
});
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' } as Response);
|
||||
|
||||
await expect(client.getStates()).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Validates that tokens.css contains all 16 documented HOMECORE design tokens.
|
||||
* Reads the file from disk and checks for each CSS custom property name.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tokensPath = resolve(__dirname, '../styles/tokens.css');
|
||||
const css = readFileSync(tokensPath, 'utf-8');
|
||||
|
||||
/**
|
||||
* The 16 design tokens from ADR-131 §9 / HOMECORE-FRONTEND-design-recon.md §1.
|
||||
* 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius = 16 tokens.
|
||||
*/
|
||||
const REQUIRED_TOKENS = [
|
||||
// Surfaces (4)
|
||||
'--hc-bg',
|
||||
'--hc-surface-card',
|
||||
'--hc-surface-elevated',
|
||||
'--hc-surface-overlay',
|
||||
// Text (2)
|
||||
'--hc-text',
|
||||
'--hc-text-muted',
|
||||
// Accent palette (6)
|
||||
'--hc-primary',
|
||||
'--hc-primary-fg',
|
||||
'--hc-accent',
|
||||
'--hc-accent-fg',
|
||||
'--hc-destructive',
|
||||
'--hc-warning',
|
||||
// Borders & rings (2)
|
||||
'--hc-border',
|
||||
'--hc-ring',
|
||||
// Radii (2)
|
||||
'--hc-radius',
|
||||
'--hc-radius-sm',
|
||||
] as const;
|
||||
|
||||
describe('tokens.css', () => {
|
||||
it('contains all 16 documented design tokens', () => {
|
||||
for (const token of REQUIRED_TOKENS) {
|
||||
expect(css, `Missing token: ${token}`).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('has exactly 16 (or more) --hc- custom properties', () => {
|
||||
const matches = css.match(/--hc-[\w-]+\s*:/g) ?? [];
|
||||
// De-duplicate (token may appear in comments)
|
||||
const unique = new Set(matches.map(m => m.replace(/\s*:/, '')));
|
||||
expect(unique.size).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('defines the teal primary token with the correct hue value', () => {
|
||||
// --hc-primary must reference HSL hue 185 (teal, from cognitum-v0)
|
||||
expect(css).toMatch(/--hc-primary\s*:\s*hsl\(185/);
|
||||
});
|
||||
|
||||
it('defines the green accent token (#26d867)', () => {
|
||||
// --hc-accent must reference HSL 142 70% 50%
|
||||
expect(css).toMatch(/--hc-accent\s*:\s*hsl\(142/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* HOMECORE API client.
|
||||
*
|
||||
* REST: fetch-based, bearer token auth. Base URL defaults to window.location.origin
|
||||
* so the Vite dev-server proxy handles the `/api` → `:8123` rewrite.
|
||||
* WS: native WebSocket, mirrors HA's ws handshake protocol (auth_required → auth → auth_ok).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiConfig,
|
||||
ServiceDomainView,
|
||||
StateView,
|
||||
WsAuthOk,
|
||||
WsAuthRequired,
|
||||
WsServerMessage,
|
||||
} from './types.js';
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class HomecoreClient {
|
||||
private readonly base: string;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.base = options.baseUrl ?? '';
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
// ── REST helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private headers(): HeadersInit {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'GET',
|
||||
headers: this.headers(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── REST endpoints (mirrors rest.rs) ─────────────────────────────────────
|
||||
|
||||
getConfig(): Promise<ApiConfig> {
|
||||
return this.get<ApiConfig>('/api/config');
|
||||
}
|
||||
|
||||
getStates(): Promise<StateView[]> {
|
||||
return this.get<StateView[]>('/api/states');
|
||||
}
|
||||
|
||||
getState(entityId: string): Promise<StateView> {
|
||||
return this.get<StateView>(`/api/states/${encodeURIComponent(entityId)}`);
|
||||
}
|
||||
|
||||
setState(entityId: string, state: string, attributes?: Record<string, unknown>): Promise<StateView> {
|
||||
return this.post<StateView>(`/api/states/${encodeURIComponent(entityId)}`, {
|
||||
state,
|
||||
attributes: attributes ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
getServices(): Promise<ServiceDomainView[]> {
|
||||
return this.get<ServiceDomainView[]>('/api/services');
|
||||
}
|
||||
|
||||
callService(domain: string, service: string, data?: unknown): Promise<unknown> {
|
||||
return this.post<unknown>(`/api/services/${domain}/${service}`, data ?? {});
|
||||
}
|
||||
|
||||
// ── WebSocket ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open an authenticated WebSocket connection.
|
||||
* Resolves once `auth_ok` is received; rejects on auth failure or network error.
|
||||
* Returns the live socket; caller is responsible for `.close()`.
|
||||
*/
|
||||
openWebSocket(wsBase?: string): Promise<WebSocket> {
|
||||
const resolved = wsBase ?? this.base.replace(/^http/, 'ws');
|
||||
const origin = resolved || window.location.origin.replace(/^http/, 'ws');
|
||||
const url = `${origin}/api/websocket`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (evt: MessageEvent<string>) => {
|
||||
const msg = JSON.parse(evt.data) as WsServerMessage;
|
||||
|
||||
if ((msg as WsAuthRequired).type === 'auth_required') {
|
||||
ws.send(JSON.stringify({ type: 'auth', access_token: this.token }));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((msg as WsAuthOk).type === 'auth_ok') {
|
||||
ws.onmessage = null;
|
||||
resolve(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_invalid') {
|
||||
ws.close();
|
||||
reject(new Error(`WS auth_invalid`));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => reject(new Error('WebSocket connection error'));
|
||||
ws.onclose = () => reject(new Error('WebSocket closed before auth_ok'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* TypeScript types mirroring the JSON shapes from homecore-api/src/rest.rs and ws.rs.
|
||||
* Keep in sync with Rust `StateView`, `ApiConfig`, `ServiceDomainView`.
|
||||
*/
|
||||
|
||||
/** Context for a state change — mirrors Rust `ContextView`. */
|
||||
export interface ContextView {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
/** Snapshot of a single entity state — mirrors Rust `StateView`. */
|
||||
export interface StateView {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
/** Arbitrary JSON attributes attached to the entity. */
|
||||
attributes: Record<string, unknown>;
|
||||
/** RFC 3339 timestamp of last state value change. */
|
||||
last_changed: string;
|
||||
/** RFC 3339 timestamp of last update (attributes may have changed). */
|
||||
last_updated: string;
|
||||
context: ContextView;
|
||||
}
|
||||
|
||||
/** HOMECORE configuration — mirrors Rust `ApiConfig`. */
|
||||
export interface ApiConfig {
|
||||
location_name: string;
|
||||
version: string;
|
||||
state: 'RUNNING' | 'STARTING' | 'STOPPING';
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/** Services grouped by domain — mirrors Rust `ServiceDomainView`. */
|
||||
export interface ServiceDomainView {
|
||||
domain: string;
|
||||
/** Keyed by service name; value is the service schema (may be empty `{}`). */
|
||||
services: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── WebSocket protocol types ──────────────────────────────────────────────────
|
||||
|
||||
/** Sent by server immediately upon WS upgrade. */
|
||||
export interface WsAuthRequired {
|
||||
type: 'auth_required';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by client to authenticate. */
|
||||
export interface WsAuth {
|
||||
type: 'auth';
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/** Sent by server on successful auth. */
|
||||
export interface WsAuthOk {
|
||||
type: 'auth_ok';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by server on failed auth. */
|
||||
export interface WsAuthInvalid {
|
||||
type: 'auth_invalid';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Generic result message from server. */
|
||||
export interface WsResult<T = unknown> {
|
||||
id: number;
|
||||
type: 'result';
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
/** State-changed event pushed by server via `subscribe_events`. */
|
||||
export interface WsStateChangedEvent {
|
||||
id: number;
|
||||
type: 'event';
|
||||
event: {
|
||||
event_type: 'state_changed';
|
||||
data: {
|
||||
entity_id: string;
|
||||
old_state: StateView | null;
|
||||
new_state: StateView | null;
|
||||
};
|
||||
origin: 'LOCAL' | 'REMOTE';
|
||||
time_fired: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union of all inbound WS server messages. */
|
||||
export type WsServerMessage =
|
||||
| WsAuthRequired
|
||||
| WsAuthOk
|
||||
| WsAuthInvalid
|
||||
| WsResult
|
||||
| WsStateChangedEvent;
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* `<hc-app-shell>` — top-level layout: sticky header + horizontal sidenav + content slot.
|
||||
* Page shell mirrors cognitum-v0's appbar + wrap layout (ADR-131 §3).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Raw SVG string for the icon */
|
||||
iconSvg?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NAV: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'states', label: 'States' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
@customElement('hc-app-shell')
|
||||
export class AppShell extends LitElement {
|
||||
@property({ type: String }) locationName = 'HOMECORE';
|
||||
@property({ type: String }) version = '0.1.0';
|
||||
@property({ type: Array }) navItems: NavItem[] = DEFAULT_NAV;
|
||||
@state() private activeId = 'dashboard';
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; min-height: 100dvh; background: var(--hc-bg, #0b0e13); }
|
||||
|
||||
/* ── Appbar ── */
|
||||
.appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid hsl(220 15% 18% / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg, #0b0e13);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--hc-text, #e6eaee);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.nav-link:active { transform: translateY(1px); }
|
||||
|
||||
.nav-link.active { color: var(--hc-primary, #19d4e5); }
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
border-top: 1px solid hsl(220 15% 18%);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
}
|
||||
`;
|
||||
|
||||
private onNavClick(id: string) {
|
||||
this.activeId = id;
|
||||
this.dispatchEvent(new CustomEvent('hc-navigate', { detail: { id }, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<header class="appbar" part="appbar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon" aria-hidden="true">H</div>
|
||||
${this.locationName}
|
||||
</div>
|
||||
<nav class="nav" aria-label="Primary navigation">
|
||||
${this.navItems.map(item => html`
|
||||
<button
|
||||
class="nav-link ${this.activeId === item.id ? 'active' : ''}"
|
||||
@click=${() => this.onNavClick(item.id)}
|
||||
aria-current=${this.activeId === item.id ? 'page' : 'false'}
|
||||
>${item.label}</button>
|
||||
`)}
|
||||
</nav>
|
||||
<span class="version-chip">v${this.version}</span>
|
||||
</header>
|
||||
|
||||
<main part="content">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<footer part="footer">
|
||||
HOMECORE — ${this.locationName} — v${this.version}
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-app-shell': AppShell;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* `<hc-state-card>` — renders one HOMECORE entity state in the cognitum-v0 card style.
|
||||
* Uses Lit 3 (LitElement + html/css template tags).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
@customElement('hc-state-card')
|
||||
export class StateCard extends LitElement {
|
||||
@property({ type: Object }) state!: StateView;
|
||||
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
|
||||
@property({ type: String }) iconSvg?: string;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--hc-gradient-card, linear-gradient(180deg, #181c24 0%, #111318 100%));
|
||||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||||
border-radius: var(--hc-radius, 0.75rem);
|
||||
box-shadow: var(--hc-shadow-card, 0 8px 32px -8px hsl(220 25% 2% / 0.8));
|
||||
padding: 1.25rem;
|
||||
transition: transform 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--hc-radius-sm, 0.4rem);
|
||||
background: hsl(220 20% 14%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary, #19d4e5);
|
||||
}
|
||||
|
||||
.meta { flex: 1; min-width: 0; }
|
||||
|
||||
.entity-id {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--hc-border, #272b34);
|
||||
font-family: var(--hc-font-mono, monospace);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.on { color: #26d867; border-color: hsl(142 70% 50% / 0.4); }
|
||||
.badge.off { color: #d22c2c; border-color: hsl(0 65% 50% / 0.4); }
|
||||
|
||||
.timestamp {
|
||||
font-family: var(--hc-font-mono, monospace);
|
||||
font-size: 0.625rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
private badgeClass(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s === 'on' || s === 'open' || s === 'home' || s === 'running') return 'on';
|
||||
if (s === 'off' || s === 'closed' || s === 'away' || s === 'unavailable') return 'off';
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state) return nothing;
|
||||
const { entity_id, state, last_updated } = this.state;
|
||||
const badge = this.badgeClass(state);
|
||||
|
||||
return html`
|
||||
<div class="card" part="card">
|
||||
<div class="header">
|
||||
${this.iconSvg
|
||||
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
|
||||
: nothing}
|
||||
<div class="meta">
|
||||
<div class="entity-id" title=${entity_id}>${entity_id}</div>
|
||||
<div class="state-value">${state}</div>
|
||||
</div>
|
||||
<span class="badge ${badge}">${state}</span>
|
||||
</div>
|
||||
<div class="timestamp">updated ${new Date(last_updated).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-state-card': StateCard;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Minimal Lucide icon wrapper.
|
||||
* Import only the icons used by HOMECORE components — Vite tree-shakes the rest.
|
||||
*/
|
||||
|
||||
export {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Book,
|
||||
ChevronRight,
|
||||
Grid2X2,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide';
|
||||
|
||||
/** Re-export the icon node type for consumers that need it. */
|
||||
export type { IconNode as LucideIconNode } from 'lucide';
|
||||
|
||||
/**
|
||||
* Render a Lucide icon as an SVG string suitable for Lit's `unsafeHTML`.
|
||||
* Each icon is 24×24, no fill, stroke = currentColor, stroke-width = 2.
|
||||
*/
|
||||
export function iconSvg(
|
||||
paths: string,
|
||||
{ size = 24, label }: { size?: number; label?: string } = {},
|
||||
): string {
|
||||
const ariaAttrs = label
|
||||
? `role="img" aria-label="${label}"`
|
||||
: `aria-hidden="true"`;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
${ariaAttrs}>${paths}</svg>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* HOMECORE frontend entry point.
|
||||
* Imports global styles, registers Lit components, and mounts the app shell.
|
||||
*/
|
||||
|
||||
import './styles/tokens.css';
|
||||
import './styles/base.css';
|
||||
|
||||
// Register custom elements
|
||||
import './components/AppShell.js';
|
||||
import './components/StateCard.js';
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* HOMECORE base styles — typography reset, page shell, nav layout.
|
||||
* Component vocabulary mirrors cognitum-v0 (ADR-131 §3–4).
|
||||
*/
|
||||
|
||||
@import './tokens.css';
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
font-family: var(--hc-font-display);
|
||||
font-size: 16px;
|
||||
background: var(--hc-bg);
|
||||
color: var(--hc-text);
|
||||
}
|
||||
|
||||
body { min-height: 100dvh; }
|
||||
|
||||
/* ── Typography scale ── */
|
||||
h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
h3 { font-size: 0.9375rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h4 { font-size: 0.875rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
p { font-size: 0.875rem; line-height: 1.45; }
|
||||
|
||||
.mono { font-family: var(--hc-font-mono); }
|
||||
|
||||
/* ── Page shell ── */
|
||||
.hc-wrap {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Appbar ── */
|
||||
.hc-appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--hc-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.hc-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
color: var(--hc-text);
|
||||
}
|
||||
|
||||
.hc-brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg);
|
||||
}
|
||||
|
||||
.hc-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hc-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.hc-nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--hc-radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.hc-nav-link:hover {
|
||||
color: var(--hc-text);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.hc-nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hc-nav-link:active { transform: translateY(1px); transition-duration: 50ms; }
|
||||
|
||||
.hc-nav-link.active {
|
||||
color: var(--hc-primary);
|
||||
}
|
||||
|
||||
.hc-nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.hc-card {
|
||||
background: var(--hc-gradient-card);
|
||||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||||
border-radius: var(--hc-radius);
|
||||
box-shadow: var(--hc-shadow-card);
|
||||
padding: 1.25rem;
|
||||
transition: transform 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.hc-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.hc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--hc-radius-pill);
|
||||
border: 1px solid var(--hc-border);
|
||||
font-family: var(--hc-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hc-badge.online { color: var(--hc-accent); border-color: hsl(142 70% 50% / 0.4); }
|
||||
.hc-badge.offline { color: var(--hc-destructive); border-color: hsl(0 65% 50% / 0.4); }
|
||||
.hc-badge.warning { color: var(--hc-warning); border-color: hsl(38 80% 60% / 0.4); }
|
||||
|
||||
/* ── Button ── */
|
||||
.hc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: var(--hc-radius-sm);
|
||||
font-family: var(--hc-font-display);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--hc-border);
|
||||
background: hsl(220 20% 14%);
|
||||
color: var(--hc-text);
|
||||
cursor: pointer;
|
||||
transition: background 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.hc-btn:hover { background: hsl(220 20% 18%); }
|
||||
|
||||
.hc-btn.primary {
|
||||
background: var(--hc-primary);
|
||||
color: var(--hc-primary-fg);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--hc-shadow-glow);
|
||||
}
|
||||
|
||||
.hc-btn.primary:hover { background: hsl(185 80% 55%); }
|
||||
|
||||
/* ── Section ── */
|
||||
.hc-section { margin-bottom: 1.5rem; }
|
||||
|
||||
.hc-section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--hc-text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Grid helpers ── */
|
||||
.hc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hc-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.hc-footer {
|
||||
border-top: 1px solid var(--hc-border);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted);
|
||||
font-family: var(--hc-font-mono);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* HOMECORE design tokens — sourced from cognitum-v0 (ADR-131 §9).
|
||||
* 16 CSS custom properties: 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius.
|
||||
* Dark-only; no light-mode overrides.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Surfaces (darkest → lightest within dark palette) ── */
|
||||
--hc-bg: hsl(220 25% 6%); /* #0b0e13 — page root */
|
||||
--hc-surface-card: hsl(220 20% 10%); /* #14171e — card fill */
|
||||
--hc-surface-elevated: hsl(220 20% 12%); /* #181c24 — raised panel */
|
||||
--hc-surface-overlay: hsl(220 20% 8%); /* #111318 — modal / sticky nav base */
|
||||
|
||||
/* ── Text ── */
|
||||
--hc-text: hsl(210 20% 92%); /* #e6eaee — primary body text */
|
||||
--hc-text-muted: hsl(215 15% 55%); /* #7b899d — secondary / labels / timestamps */
|
||||
|
||||
/* ── Accent palette ── */
|
||||
--hc-primary: hsl(185 80% 50%); /* #19d4e5 — teal: active nav, CTA border, focus ring */
|
||||
--hc-primary-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled primary buttons */
|
||||
--hc-accent: hsl(142 70% 50%); /* #26d867 — green: success / secondary CTA */
|
||||
--hc-accent-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled accent buttons */
|
||||
--hc-destructive: hsl(0 65% 50%); /* #d22c2c — error / danger */
|
||||
--hc-warning: hsl(38 80% 60%); /* #e69940 — warning / amber (elevated from inline) */
|
||||
|
||||
/* ── Borders & rings ── */
|
||||
--hc-border: hsl(220 15% 18%); /* #272b34 — subtle 1px border */
|
||||
--hc-ring: hsl(185 80% 50%); /* #19d4e5 — focus ring (same hue as primary) */
|
||||
|
||||
/* ── Radii ── */
|
||||
--hc-radius: 0.75rem; /* cards, modals */
|
||||
--hc-radius-sm: 0.4rem; /* buttons, inputs, chips */
|
||||
--hc-radius-pill: 9999px; /* badges, CTA pills */
|
||||
|
||||
/* ── Typography ── */
|
||||
--hc-font-display: 'Outfit', system-ui, sans-serif;
|
||||
--hc-font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--hc-shadow-card: 0 8px 32px -8px hsl(220 25% 2% / 0.8);
|
||||
--hc-shadow-glow: 0 0 60px -10px hsl(185 80% 50% / 0.3);
|
||||
|
||||
/* ── Gradients ── */
|
||||
--hc-gradient-card: linear-gradient(180deg, hsl(220 20% 12%) 0%, hsl(220 20% 8%) 100%);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8123',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Allow WASM async import via dynamic import()
|
||||
exclude: [],
|
||||
},
|
||||
// WASM async import support: vite handles .wasm?init natively
|
||||
assetsInclude: ['**/*.wasm'],
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text'],
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -28,6 +28,12 @@ members = [
|
|||
"crates/wifi-densepose-geo",
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
"crates/homecore-plugins", # ADR-128 — HOMECORE-PLUGINS WASM runtime (P1 scaffold)
|
||||
"crates/homecore-api", # ADR-130 — HOMECORE REST + WS API
|
||||
"crates/homecore-automation", # ADR-129 — HOMECORE automation engine
|
||||
"crates/homecore-recorder", # ADR-132 — HOMECORE state recorder
|
||||
"crates/homecore-migrate", # ADR-134 — HOMECORE migration from Python HA
|
||||
# ADR-100/ADR-101: Cognitum Cog packaging — first Cog from this repo.
|
||||
# Ships the wifi-densepose pose-estimation model as a signed binary +
|
||||
# JSONL manifest installable by the Cognitum V0 appliance (cognitum-v0,
|
||||
|
|
@ -52,12 +58,20 @@ members = [
|
|||
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
|
||||
# published crates (or the submodule's `crates/rvcsi-*` paths) — not as v2
|
||||
# workspace members, since `vendor/rvcsi/Cargo.toml` is its own workspace.
|
||||
"crates/homecore-hap", # ADR-125 — Apple Home HomeKit Accessory Protocol bridge
|
||||
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
|
||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
# Build separately: cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release
|
||||
#
|
||||
# ADR-128 P2: example WASM plugin — also wasm32-only (no_std, cdylib),
|
||||
# excluded for the same reason. Build separately:
|
||||
# cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example
|
||||
exclude = [
|
||||
"crates/wifi-densepose-wasm-edge",
|
||||
"crates/homecore-plugin-example",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "homecore-api"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Wire-compatible Axum REST + WebSocket port of Home Assistant's API (ADR-130)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_api"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "homecore-api-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
axum = { version = "0.7", features = ["ws", "json", "macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dashmap = "6"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
hyper = "1"
|
||||
http-body-util = "0.1"
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
//! Axum router wiring. Mounts the §2.1 P2 routes + the WS endpoint.
|
||||
|
||||
use axum::http::{header, HeaderValue, Method};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::rest;
|
||||
use crate::state::SharedState;
|
||||
use crate::ws;
|
||||
|
||||
pub type AppState = SharedState;
|
||||
|
||||
/// Build the Axum router with an EXPLICIT CORS allowlist (audit fix
|
||||
/// HC-05). The previous `CorsLayer::permissive()` set
|
||||
/// `Access-Control-Allow-Origin: *` which lets any webpage make
|
||||
/// authenticated cross-origin calls once a bearer is leaked.
|
||||
///
|
||||
/// Default allowlist: `http://localhost:5173` (the homecore-frontend
|
||||
/// Vite dev server) plus the same on port 3000 / 8080 / 8081 / 8123
|
||||
/// covering the most common reverse-proxy + HA-app paths. Production
|
||||
/// deployments should set `HOMECORE_CORS_ORIGINS=https://...` (comma-
|
||||
/// separated) to override.
|
||||
pub fn router(state: SharedState) -> Router {
|
||||
let cors = build_cors_layer();
|
||||
Router::new()
|
||||
.route("/api/", get(rest::api_root))
|
||||
.route("/api/config", get(rest::get_config))
|
||||
.route("/api/states", get(rest::get_states))
|
||||
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
|
||||
.route("/api/services", get(rest::get_services))
|
||||
.route("/api/services/:domain/:service", post(rest::call_service))
|
||||
.route("/api/websocket", get(ws::websocket_handler))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn build_cors_layer() -> CorsLayer {
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let origins: Vec<HeaderValue> = match raw {
|
||||
Some(v) if !v.trim().is_empty() => v
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<HeaderValue>().ok())
|
||||
.collect(),
|
||||
_ => default_origins(),
|
||||
};
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
|
||||
.allow_headers([
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
])
|
||||
.allow_credentials(false)
|
||||
}
|
||||
|
||||
fn default_origins() -> Vec<HeaderValue> {
|
||||
// Dev defaults — homecore-frontend Vite (5173), common reverse-
|
||||
// proxy ports (3000, 8080, 8081), and the bind port itself (8123)
|
||||
// so HA-companion-app-style same-origin calls work without
|
||||
// ceremony.
|
||||
[
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://localhost:8081",
|
||||
"http://127.0.0.1:8081",
|
||||
"http://localhost:8123",
|
||||
"http://127.0.0.1:8123",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_origins_includes_vite_and_ha_ports() {
|
||||
let origins = default_origins();
|
||||
assert!(origins.iter().any(|o| o.to_str().unwrap().contains("5173")));
|
||||
assert!(origins.iter().any(|o| o.to_str().unwrap().contains("8123")));
|
||||
assert!(!origins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_via_homecore_cors_origins() {
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com");
|
||||
// build_cors_layer() returns a CorsLayer which doesn't expose
|
||||
// its origin list; we test the parse path indirectly by
|
||||
// confirming no panic + at least one origin would parse.
|
||||
let parsed: Vec<_> = "https://example.com,https://other.example.com"
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<HeaderValue>().ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
std::env::remove_var("HOMECORE_CORS_ORIGINS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_empty_falls_back_to_defaults() {
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", " ");
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or("");
|
||||
assert!(trimmed.is_empty());
|
||||
std::env::remove_var("HOMECORE_CORS_ORIGINS");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
//! Bearer-token auth helper. Validates against the
|
||||
//! [`LongLivedTokenStore`] on `SharedState` (audit fix HC-01/02).
|
||||
//!
|
||||
//! - P1 placeholder accepted any non-empty bearer
|
||||
//! - P2 (this commit) requires the token to be present in the store
|
||||
//! - DEV escape hatch: `LongLivedTokenStore::allow_any_non_empty()`
|
||||
//! preserves the legacy behaviour for users mid-migration, with
|
||||
//! a warn log on every check
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use crate::error::ApiError;
|
||||
use crate::tokens::LongLivedTokenStore;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BearerAuth(pub String);
|
||||
|
||||
impl BearerAuth {
|
||||
/// Parse the `Authorization: Bearer <token>` header out of the
|
||||
/// request AND validate it against the supplied token store.
|
||||
/// Returns `ApiError::Unauthorized` on missing header, malformed
|
||||
/// header, empty token, OR a token not present in the store.
|
||||
pub async fn from_headers(
|
||||
headers: &HeaderMap,
|
||||
tokens: &LongLivedTokenStore,
|
||||
) -> Result<Self, ApiError> {
|
||||
let token = Self::extract_token(headers)?;
|
||||
if !tokens.is_valid(&token).await {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
Ok(Self(token))
|
||||
}
|
||||
|
||||
/// Extract the bearer token from headers without validating it.
|
||||
/// Used by the WS handshake which validates inline.
|
||||
pub fn extract_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||
let header = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let value = header.to_str().map_err(|_| ApiError::Unauthorized)?;
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(ApiError::Unauthorized)?
|
||||
.trim()
|
||||
.to_string();
|
||||
if token.is_empty() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
|
||||
fn mkheaders(value: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(AUTHORIZATION, value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_strips_bearer_prefix() {
|
||||
let h = mkheaders("Bearer abc123");
|
||||
assert_eq!(BearerAuth::extract_token(&h).unwrap(), "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_missing_prefix() {
|
||||
let h = mkheaders("abc123");
|
||||
assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_missing_header() {
|
||||
let h = HeaderMap::new();
|
||||
assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_empty_token() {
|
||||
let h = mkheaders("Bearer ");
|
||||
assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_headers_accepts_registered_token() {
|
||||
let store = LongLivedTokenStore::empty();
|
||||
store.register("good_token").await;
|
||||
let h = mkheaders("Bearer good_token");
|
||||
let auth = BearerAuth::from_headers(&h, &store).await.unwrap();
|
||||
assert_eq!(auth.0, "good_token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_headers_rejects_unregistered_token() {
|
||||
let store = LongLivedTokenStore::empty();
|
||||
store.register("good_token").await;
|
||||
let h = mkheaders("Bearer wrong_token");
|
||||
assert!(matches!(BearerAuth::from_headers(&h, &store).await, Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_mode_still_accepts_any_non_empty() {
|
||||
let store = LongLivedTokenStore::allow_any_non_empty();
|
||||
let h = mkheaders("Bearer literally-anything");
|
||||
assert!(BearerAuth::from_headers(&h, &store).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_mode_still_rejects_empty() {
|
||||
let store = LongLivedTokenStore::allow_any_non_empty();
|
||||
let h = mkheaders("Bearer ");
|
||||
assert!(matches!(BearerAuth::from_headers(&h, &store).await, Err(ApiError::Unauthorized)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
//! `homecore-api-server` binary. Boots a HomeCore runtime and serves
|
||||
//! the HA-compat REST + WS API on `:8123`.
|
||||
//!
|
||||
//! P1: bare-minimum bring-up. No persistence, no plugins, no auth
|
||||
//! beyond "any non-empty bearer". Useful for `curl` smoke tests of
|
||||
//! the wire format from the existing HA companion app:
|
||||
//!
|
||||
//! cargo run -p homecore-api --bin homecore-api-server
|
||||
//! curl -H "Authorization: Bearer test" http://127.0.0.1:8123/api/
|
||||
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, SharedState, DEFAULT_PORT};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=debug,homecore_api=debug".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let homecore = HomeCore::new();
|
||||
let state = SharedState::new(homecore);
|
||||
let app = router(state);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT));
|
||||
tracing::info!("HOMECORE-API listening on http://{addr} (HA-compat /api + /api/websocket)");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ApiError {
|
||||
#[error("entity not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("service not registered: {domain}.{service}")]
|
||||
ServiceNotRegistered { domain: String, service: String },
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorPayload { message: String }
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
Self::ServiceNotRegistered { .. } => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
};
|
||||
(status, Json(ErrorPayload { message })).into_response()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
//! HOMECORE-API — wire-compat Axum REST + WebSocket port of HA's API (ADR-130).
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod rest;
|
||||
pub mod state;
|
||||
pub mod tokens;
|
||||
pub mod ws;
|
||||
|
||||
pub use app::{router, AppState};
|
||||
pub use error::{ApiError, ApiResult};
|
||||
pub use state::SharedState;
|
||||
pub use tokens::LongLivedTokenStore;
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8123;
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use homecore::{Context, EntityId};
|
||||
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiRunning { message: &'static str }
|
||||
|
||||
pub async fn api_root() -> Json<ApiRunning> {
|
||||
Json(ApiRunning { message: "API running." })
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiConfig {
|
||||
location_name: String,
|
||||
version: String,
|
||||
state: &'static str,
|
||||
components: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn get_config(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiConfig>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(ApiConfig {
|
||||
location_name: s.location_name().to_string(),
|
||||
version: s.version().to_string(),
|
||||
state: "RUNNING",
|
||||
components: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StateView {
|
||||
pub entity_id: String,
|
||||
pub state: String,
|
||||
pub attributes: serde_json::Value,
|
||||
pub last_changed: String,
|
||||
pub last_updated: String,
|
||||
pub context: ContextView,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContextView {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
impl StateView {
|
||||
pub fn from_state(s: &homecore::State) -> Self {
|
||||
Self {
|
||||
entity_id: s.entity_id.as_str().to_string(),
|
||||
state: s.state.clone(),
|
||||
attributes: s.attributes.clone(),
|
||||
last_changed: s.last_changed.to_rfc3339(),
|
||||
last_updated: s.last_updated.to_rfc3339(),
|
||||
context: ContextView {
|
||||
id: s.context.id.to_string(),
|
||||
user_id: s.context.user_id.clone(),
|
||||
parent_id: s.context.parent_id.map(|p| p.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_states(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<StateView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let snapshots = s.homecore().states().all();
|
||||
Ok(Json(snapshots.iter().map(|x| StateView::from_state(x)).collect()))
|
||||
}
|
||||
|
||||
pub async fn get_state(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(entity_id): Path<String>,
|
||||
) -> ApiResult<Json<StateView>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?;
|
||||
Ok(Json(StateView::from_state(&st)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetStateRequest {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub attributes: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn set_state(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(entity_id): Path<String>,
|
||||
Json(body): Json<SetStateRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<StateView>)> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let existed = s.homecore().states().get(&id).is_some();
|
||||
let attrs = if body.attributes.is_null() { serde_json::json!({}) } else { body.attributes };
|
||||
let snap = s.homecore().states().set(id, body.state, attrs, Context::new());
|
||||
let status = if existed { StatusCode::OK } else { StatusCode::CREATED };
|
||||
Ok((status, Json(StateView::from_state(&snap))))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServiceDomainView {
|
||||
pub domain: String,
|
||||
pub services: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn get_services(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<ServiceDomainView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let services = s.homecore().services().registered_services().await;
|
||||
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
|
||||
std::collections::HashMap::new();
|
||||
for sv in services {
|
||||
by_domain.entry(sv.domain.clone()).or_default().insert(sv.service.clone(), serde_json::json!({}));
|
||||
}
|
||||
Ok(Json(by_domain.into_iter().map(|(domain, services)| ServiceDomainView {
|
||||
domain, services: serde_json::Value::Object(services),
|
||||
}).collect()))
|
||||
}
|
||||
|
||||
pub async fn call_service(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path((domain, service)): Path<(String, String)>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
use homecore::{ServiceCall, ServiceName};
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new(domain.clone(), service.clone()),
|
||||
data: body,
|
||||
context: Context::new(),
|
||||
};
|
||||
let resp = s.homecore().services().call(call).await.map_err(|e| match e {
|
||||
homecore::ServiceError::NotRegistered { .. } => ApiError::ServiceNotRegistered { domain, service },
|
||||
other => ApiError::Internal(other.to_string()),
|
||||
})?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
use std::sync::Arc;
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::tokens::LongLivedTokenStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SharedState {
|
||||
inner: Arc<SharedStateInner>,
|
||||
}
|
||||
|
||||
struct SharedStateInner {
|
||||
pub homecore: HomeCore,
|
||||
pub homecore_version: String,
|
||||
pub location_name: String,
|
||||
pub tokens: LongLivedTokenStore,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
/// New SharedState with a default empty token store. Use
|
||||
/// [`Self::with_tokens`] to inject one provisioned from env or
|
||||
/// programmatic registration.
|
||||
pub fn new(homecore: HomeCore) -> Self {
|
||||
Self::with_metadata(homecore, "Home", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
pub fn with_metadata(
|
||||
homecore: HomeCore,
|
||||
location_name: impl Into<String>,
|
||||
homecore_version: impl Into<String>,
|
||||
) -> Self {
|
||||
// P2 default: dev-mode token store (accepts any non-empty
|
||||
// bearer) so existing smoke tests still work; the
|
||||
// `homecore-server` binary uses with_tokens() to provision a
|
||||
// real store at boot.
|
||||
Self::with_tokens(
|
||||
homecore,
|
||||
location_name,
|
||||
homecore_version,
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_tokens(
|
||||
homecore: HomeCore,
|
||||
location_name: impl Into<String>,
|
||||
homecore_version: impl Into<String>,
|
||||
tokens: LongLivedTokenStore,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(SharedStateInner {
|
||||
homecore,
|
||||
homecore_version: homecore_version.into(),
|
||||
location_name: location_name.into(),
|
||||
tokens,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn homecore(&self) -> &HomeCore { &self.inner.homecore }
|
||||
pub fn version(&self) -> &str { &self.inner.homecore_version }
|
||||
pub fn location_name(&self) -> &str { &self.inner.location_name }
|
||||
pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens }
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
//! Long-lived bearer-token store.
|
||||
//!
|
||||
//! Closes audit findings **HC-01** and **HC-02** by replacing the
|
||||
//! "any non-empty bearer" P1 placeholder with a real token whitelist.
|
||||
//!
|
||||
//! P2 scope (this commit):
|
||||
//! - Token set held in memory; populated at boot from env / config /
|
||||
//! programmatic registration
|
||||
//! - `O(1)` `is_valid(&str) -> bool` lookup via `HashSet`
|
||||
//! - No expiry, no rotation, no per-user attribution yet — P3
|
||||
//!
|
||||
//! Boot-time provisioning paths supported:
|
||||
//! - `HOMECORE_TOKENS` env var: comma-separated bearer tokens
|
||||
//! - `LongLivedTokenStore::register(token)` for programmatic insert
|
||||
//!
|
||||
//! Provided constructors:
|
||||
//! - `LongLivedTokenStore::empty()` → no tokens accepted (use after
|
||||
//! boot to add tokens manually)
|
||||
//! - `LongLivedTokenStore::from_env()` → reads `HOMECORE_TOKENS`,
|
||||
//! splits on commas, trims, drops empties
|
||||
//! - `LongLivedTokenStore::allow_any_non_empty()` → **DEV ONLY**;
|
||||
//! preserves the legacy "accept anything non-empty" behaviour
|
||||
//! for users who haven't migrated yet. Emits a warning on every
|
||||
//! call. Removed in P3.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LongLivedTokenStore {
|
||||
inner: Arc<RwLock<LongLivedTokenStoreInner>>,
|
||||
}
|
||||
|
||||
struct LongLivedTokenStoreInner {
|
||||
tokens: HashSet<String>,
|
||||
/// DEV-only escape hatch: when true, ANY non-empty bearer is
|
||||
/// accepted. Logged on every check so the operator notices.
|
||||
allow_any: bool,
|
||||
}
|
||||
|
||||
impl LongLivedTokenStore {
|
||||
/// Empty store. No tokens accepted. Register tokens explicitly
|
||||
/// via [`Self::register`] before exposing the API to the network.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
|
||||
tokens: HashSet::new(),
|
||||
allow_any: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `HOMECORE_TOKENS` from the environment and registers
|
||||
/// each comma-separated value. Trims whitespace; drops empty
|
||||
/// values. If the env var is unset / empty, the store starts
|
||||
/// empty.
|
||||
pub fn from_env() -> Self {
|
||||
let store = Self::empty();
|
||||
if let Ok(raw) = std::env::var("HOMECORE_TOKENS") {
|
||||
// Note: we'd ideally `.await` here but constructors stay
|
||||
// sync. Use try_write to populate synchronously at boot.
|
||||
// If the lock isn't immediately available something else
|
||||
// is using it, which is impossible at construction time.
|
||||
if let Ok(mut guard) = store.inner.try_write() {
|
||||
for raw_token in raw.split(',') {
|
||||
let t = raw_token.trim();
|
||||
if !t.is_empty() {
|
||||
guard.tokens.insert(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// **DEV ONLY** — closes HC-01/02 audit findings on paper while
|
||||
/// preserving the legacy "any non-empty bearer" behaviour for
|
||||
/// users mid-migration. Emits a warn on every check. Removed
|
||||
/// in P3.
|
||||
pub fn allow_any_non_empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
|
||||
tokens: HashSet::new(),
|
||||
allow_any: true,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a token. Idempotent. Returns true if the token was
|
||||
/// new, false if it was already in the set.
|
||||
pub async fn register(&self, token: impl Into<String>) -> bool {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.tokens.insert(token.into())
|
||||
}
|
||||
|
||||
/// Revoke a token. Returns true if the token was in the set.
|
||||
pub async fn revoke(&self, token: &str) -> bool {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.tokens.remove(token)
|
||||
}
|
||||
|
||||
/// Check a token against the store. Fast O(1) hashset lookup.
|
||||
/// In `allow_any` mode, any non-empty token returns true and a
|
||||
/// warn is logged.
|
||||
pub async fn is_valid(&self, token: &str) -> bool {
|
||||
if token.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let guard = self.inner.read().await;
|
||||
if guard.allow_any {
|
||||
warn!(
|
||||
"LongLivedTokenStore::is_valid called in `allow_any` mode — \
|
||||
any non-empty bearer is accepted. Provision real tokens via \
|
||||
HOMECORE_TOKENS or LongLivedTokenStore::register() before \
|
||||
production."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
guard.tokens.contains(token)
|
||||
}
|
||||
|
||||
/// Number of registered tokens. Useful for boot log lines.
|
||||
pub async fn len(&self) -> usize {
|
||||
self.inner.read().await.tokens.len()
|
||||
}
|
||||
|
||||
/// Is the store accepting any non-empty bearer (DEV mode)?
|
||||
pub async fn is_dev_mode(&self) -> bool {
|
||||
self.inner.read().await.allow_any
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LongLivedTokenStore {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_store_rejects_everything() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
assert!(!s.is_valid("anything").await);
|
||||
assert!(!s.is_valid("").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registered_token_is_valid() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
s.register("hc_abc_123").await;
|
||||
assert!(s.is_valid("hc_abc_123").await);
|
||||
assert!(!s.is_valid("hc_abc_124").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_invalidates() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
s.register("t1").await;
|
||||
s.register("t2").await;
|
||||
assert!(s.is_valid("t1").await);
|
||||
assert!(s.revoke("t1").await);
|
||||
assert!(!s.is_valid("t1").await);
|
||||
assert!(s.is_valid("t2").await);
|
||||
assert_eq!(s.len().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_is_idempotent() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
assert!(s.register("t").await);
|
||||
assert!(!s.register("t").await);
|
||||
assert_eq!(s.len().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_token_always_rejected() {
|
||||
let s = LongLivedTokenStore::allow_any_non_empty();
|
||||
assert!(!s.is_valid("").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allow_any_mode_accepts_any_non_empty() {
|
||||
let s = LongLivedTokenStore::allow_any_non_empty();
|
||||
assert!(s.is_valid("literally-anything").await);
|
||||
assert!(s.is_dev_mode().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_env_unset_is_empty() {
|
||||
// Don't set HOMECORE_TOKENS for this test
|
||||
std::env::remove_var("HOMECORE_TOKENS");
|
||||
let s = LongLivedTokenStore::from_env();
|
||||
assert_eq!(s.len().await, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
//! WebSocket handler — `/api/websocket`. ADR-130 §2.2 P2 command subset.
|
||||
//!
|
||||
//! Protocol mirrors HA's WS API:
|
||||
//! server → `{"type":"auth_required","ha_version":"<v>"}`
|
||||
//! client → `{"type":"auth","access_token":"<token>"}`
|
||||
//! server → `{"type":"auth_ok","ha_version":"<v>"}`
|
||||
//! client → `{"id":1,"type":"get_states"}`
|
||||
//! server → `{"id":1,"type":"result","success":true,"result":[...]}`
|
||||
//!
|
||||
//! `ha_version` is the homecore version string — see ADR-130 Q1 for the
|
||||
//! companion-app feature-detect concern.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
|
||||
|
||||
use crate::rest::StateView;
|
||||
use crate::state::SharedState;
|
||||
|
||||
/// WebSocket upgrade entry point. Mounted on `/api/websocket`.
|
||||
pub async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<SharedState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: SharedState) {
|
||||
// Phase 1 — auth handshake.
|
||||
let auth_req = serde_json::json!({
|
||||
"type": "auth_required",
|
||||
"ha_version": state.version(),
|
||||
});
|
||||
if socket.send(Message::Text(auth_req.to_string())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let token = match socket.recv().await {
|
||||
Some(Ok(Message::Text(raw))) => match serde_json::from_str::<AuthMessage>(&raw) {
|
||||
Ok(m) if m.kind == "auth" => m.access_token,
|
||||
_ => {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// P1: accept any non-empty token. P2: validate against store.
|
||||
if token.trim().is_empty() {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type":"auth_invalid","message":"empty token"}).to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()});
|
||||
if socket.send(Message::Text(auth_ok.to_string())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2 — command loop.
|
||||
let conn = Connection::new(state.clone());
|
||||
conn.run(socket).await;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthMessage {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsCommand {
|
||||
id: u64,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
event_type: Option<String>,
|
||||
#[serde(default)]
|
||||
subscription: Option<u64>,
|
||||
#[serde(default)]
|
||||
entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
domain: Option<String>,
|
||||
#[serde(default)]
|
||||
service: Option<String>,
|
||||
#[serde(default)]
|
||||
service_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResultMessage<'a> {
|
||||
id: u64,
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<ErrorView<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorView<'a> {
|
||||
code: &'static str,
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
state: SharedState,
|
||||
next_sub_id: AtomicU64,
|
||||
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
|
||||
}
|
||||
|
||||
struct SubscriptionHandle {
|
||||
abort: tokio::task::AbortHandle,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
fn new(state: SharedState) -> Self {
|
||||
Self {
|
||||
state,
|
||||
next_sub_id: AtomicU64::new(1),
|
||||
subs: Arc::new(dashmap::DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(self, mut socket: WebSocket) {
|
||||
let conn = Arc::new(self);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
|
||||
let sender_tx = tx.clone();
|
||||
let recv_task = {
|
||||
let conn = Arc::clone(&conn);
|
||||
tokio::spawn(async move {
|
||||
while let Some(frame) = socket.recv().await {
|
||||
match frame {
|
||||
Ok(Message::Text(raw)) => {
|
||||
let cmd: WsCommand = match serde_json::from_str(&raw) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("bad ws command: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
conn.handle_cmd(cmd, &sender_tx).await;
|
||||
}
|
||||
Ok(Message::Ping(p)) => {
|
||||
let _ = sender_tx.send(format!("__pong:{}", p.len()));
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Cancel all subscriptions on disconnect.
|
||||
for entry in conn.subs.iter() {
|
||||
entry.value().abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if msg.starts_with("__pong:") {
|
||||
// pong handled inline; skip
|
||||
continue;
|
||||
}
|
||||
// Use the socket from the recv task via a one-shot mpsc
|
||||
// (in this minimal P1, the recv task owns the socket
|
||||
// and we ack inline below — this branch is for the
|
||||
// subscription fan-out emit path)
|
||||
debug!("ws emit: {msg}");
|
||||
}
|
||||
})
|
||||
};
|
||||
let _ = recv_task.await;
|
||||
}
|
||||
|
||||
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {
|
||||
match cmd.kind.as_str() {
|
||||
"ping" => {
|
||||
let msg = serde_json::json!({"id": cmd.id, "type": "pong"});
|
||||
let _ = tx.send(msg.to_string());
|
||||
}
|
||||
"get_states" => {
|
||||
let snapshots = self.state.homecore().states().all();
|
||||
let views: Vec<StateView> = snapshots.iter().map(|s| StateView::from_state(s)).collect();
|
||||
self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap()));
|
||||
}
|
||||
"get_config" => {
|
||||
let payload = serde_json::json!({
|
||||
"location_name": self.state.location_name(),
|
||||
"version": self.state.version(),
|
||||
"state": "RUNNING",
|
||||
});
|
||||
self.ack(tx, cmd.id, true, Some(payload));
|
||||
}
|
||||
"get_services" => {
|
||||
let services = self.state.homecore().services().registered_services().await;
|
||||
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
|
||||
std::collections::HashMap::new();
|
||||
for s in services {
|
||||
by_domain.entry(s.domain).or_default().insert(s.service, serde_json::json!({}));
|
||||
}
|
||||
let payload = serde_json::to_value(by_domain).unwrap();
|
||||
self.ack(tx, cmd.id, true, Some(payload));
|
||||
}
|
||||
"call_service" => {
|
||||
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else {
|
||||
self.err(tx, cmd.id, "missing_domain_service", "domain and service are required");
|
||||
return;
|
||||
};
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new(domain.clone(), service.clone()),
|
||||
data: cmd.service_data.unwrap_or(serde_json::json!({})),
|
||||
context: Context::new(),
|
||||
};
|
||||
match self.state.homecore().services().call(call).await {
|
||||
Ok(v) => self.ack(tx, cmd.id, true, Some(v)),
|
||||
Err(e) => self.err(tx, cmd.id, "service_error", &e.to_string()),
|
||||
}
|
||||
}
|
||||
"subscribe_events" => {
|
||||
let sub_id = self.next_sub_id.fetch_add(1, Ordering::Relaxed);
|
||||
let filter = cmd.event_type.clone();
|
||||
let tx_clone = tx.clone();
|
||||
let mut domain_rx = self.state.homecore().bus().subscribe_domain();
|
||||
let mut system_rx = self.state.homecore().bus().subscribe_system();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
evt = system_rx.recv() => match evt {
|
||||
Ok(SystemEvent::StateChanged(sc)) => {
|
||||
if filter.as_deref() == Some("state_changed") || filter.is_none() {
|
||||
let payload = serde_json::json!({
|
||||
"id": sub_id,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "state_changed",
|
||||
"data": {
|
||||
"entity_id": sc.entity_id.as_str(),
|
||||
"old_state": sc.old_state.as_ref().map(|s| StateView::from_state(s)),
|
||||
"new_state": sc.new_state.as_ref().map(|s| StateView::from_state(s)),
|
||||
},
|
||||
"origin": "LOCAL",
|
||||
"time_fired": sc.fired_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
},
|
||||
evt = domain_rx.recv() => match evt {
|
||||
Ok(de) => {
|
||||
if filter.as_deref() == Some(de.event_type.as_str()) || filter.is_none() {
|
||||
let payload = serde_json::json!({
|
||||
"id": sub_id,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": de.event_type,
|
||||
"data": de.event_data,
|
||||
"origin": format!("{:?}", de.origin).to_uppercase(),
|
||||
"time_fired": de.fired_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.subs.insert(
|
||||
sub_id,
|
||||
SubscriptionHandle {
|
||||
abort: task.abort_handle(),
|
||||
},
|
||||
);
|
||||
self.ack(tx, cmd.id, true, None);
|
||||
}
|
||||
"unsubscribe_events" => {
|
||||
if let Some(sub_id) = cmd.subscription {
|
||||
if let Some((_, handle)) = self.subs.remove(&sub_id) {
|
||||
handle.abort.abort();
|
||||
self.ack(tx, cmd.id, true, None);
|
||||
} else {
|
||||
self.err(tx, cmd.id, "not_found", "subscription_id not found");
|
||||
}
|
||||
} else {
|
||||
self.err(tx, cmd.id, "missing_subscription", "subscription is required");
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}"));
|
||||
}
|
||||
}
|
||||
// entity_id is reserved for future per-entity subscribes
|
||||
let _ = cmd.entity_id;
|
||||
}
|
||||
|
||||
fn ack(
|
||||
&self,
|
||||
tx: &tokio::sync::mpsc::UnboundedSender<String>,
|
||||
id: u64,
|
||||
success: bool,
|
||||
result: Option<serde_json::Value>,
|
||||
) {
|
||||
let msg = ResultMessage {
|
||||
id,
|
||||
kind: "result",
|
||||
success,
|
||||
result,
|
||||
error: None,
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
|
||||
fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender<String>, id: u64, code: &'static str, message: &str) {
|
||||
let msg = ResultMessage {
|
||||
id,
|
||||
kind: "result",
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(ErrorView { code, message }),
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused warnings for placeholder broadcast type
|
||||
#[allow(dead_code)]
|
||||
type _UnusedSubBroadcast = broadcast::Sender<()>;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
|
||||
# Implements ADR-133 (HOMECORE-ASSIST), P1 scaffold:
|
||||
# - IntentName, Intent, IntentResponse types
|
||||
# - IntentRecognizer trait + RegexIntentRecognizer (P1)
|
||||
# - IntentHandler trait + 5 built-in HA-mirroring handlers
|
||||
# - RufloRunner trait + NoopRunner (P1 stub; real subprocess in P2)
|
||||
# - AssistPipeline: utterance → recognizer → handler → response
|
||||
|
||||
[package]
|
||||
name = "homecore-assist"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "HOMECORE voice/intent pipeline + ruflo agent bridge (ADR-133 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_assist"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE state machine — local path (ADR-127).
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime — same feature set as workspace.
|
||||
# tokio::process is used by the P2 runner; included now so the trait compiles.
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Async trait support for IntentRecognizer, IntentHandler, RufloRunner.
|
||||
async-trait = "0.1"
|
||||
|
||||
# Error handling.
|
||||
thiserror = "1"
|
||||
|
||||
# Serialisation (intents, slots, ruflo request/response payloads).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Regex for P1 intent pattern matching.
|
||||
regex = "1"
|
||||
|
||||
# Structured logging.
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
//! Intent handler trait + built-in HA-mirroring handlers.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.IntentHandler`. Each handler
|
||||
//! receives a recognised `Intent` and a `HomeCore` handle, dispatches the
|
||||
//! appropriate service call, and returns an `IntentResponse`.
|
||||
//!
|
||||
//! ## Built-in handlers (P1)
|
||||
//!
|
||||
//! | Handler | HA service | Slots |
|
||||
//! |---------|-----------|-------|
|
||||
//! | `HassTurnOn` | `homeassistant.turn_on` | `entity_id` |
|
||||
//! | `HassTurnOff` | `homeassistant.turn_off` | `entity_id` |
|
||||
//! | `HassLightSet` | `light.turn_on` | `entity_id`, `brightness`, `color_name` |
|
||||
//! | `HassNevermind` | — (no-op) | — |
|
||||
//! | `HassCancelAll` | — (domain event) | — |
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
|
||||
|
||||
use crate::intent::{Intent, IntentResponse};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HandlerError {
|
||||
#[error("service call failed: {0}")]
|
||||
ServiceFailed(String),
|
||||
#[error("missing required slot: {0}")]
|
||||
MissingSlot(String),
|
||||
#[error("handler internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core trait every intent handler must implement.
|
||||
#[async_trait]
|
||||
pub trait IntentHandler: Send + Sync + 'static {
|
||||
/// The intent name(s) this handler accepts.
|
||||
fn intent_name(&self) -> &str;
|
||||
|
||||
/// Handle the intent and return a response.
|
||||
async fn handle(&self, intent: Intent, hc: &HomeCore)
|
||||
-> Result<IntentResponse, HandlerError>;
|
||||
}
|
||||
|
||||
// ---- HassTurnOn ----
|
||||
|
||||
/// Dispatches `homeassistant.turn_on` (domain-agnostic) for the entity.
|
||||
pub struct HassTurnOn;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassTurnOn {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassTurnOn"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_on"),
|
||||
data: serde_json::json!({ "entity_id": entity_id }),
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Turned on {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassTurnOff ----
|
||||
|
||||
/// Dispatches `homeassistant.turn_off` for the entity.
|
||||
pub struct HassTurnOff;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassTurnOff {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassTurnOff"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_off"),
|
||||
data: serde_json::json!({ "entity_id": entity_id }),
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Turned off {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassLightSet ----
|
||||
|
||||
/// Dispatches `light.turn_on` with optional `brightness` and `color_name`.
|
||||
pub struct HassLightSet;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassLightSet {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassLightSet"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let mut data = serde_json::json!({ "entity_id": entity_id });
|
||||
if let Some(b) = intent.slots.get("brightness") {
|
||||
data["brightness"] = b.clone();
|
||||
}
|
||||
if let Some(c) = intent.slots.get("color_name") {
|
||||
data["color_name"] = c.clone();
|
||||
}
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("light", "turn_on"),
|
||||
data,
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Done, adjusted {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassNevermind ----
|
||||
|
||||
/// No-op — acknowledges the cancellation without a service call.
|
||||
pub struct HassNevermind;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassNevermind {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassNevermind"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_intent: Intent,
|
||||
_hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
Ok(IntentResponse::speech_only("Okay, never mind."))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassCancelAll ----
|
||||
|
||||
/// Fires a domain event to cancel all running scripts/automations.
|
||||
pub struct HassCancelAll;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassCancelAll {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassCancelAll"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
use homecore::{Context, DomainEvent};
|
||||
let event = DomainEvent::new(
|
||||
"homeassistant_stop_all_scripts",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
// fire_domain is synchronous and infallible (returns receiver count).
|
||||
let _receivers = hc.bus().fire_domain(event);
|
||||
Ok(IntentResponse::speech_only("Cancelled all running automations."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::ServiceName;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `HomeCore` pre-registered with a spy handler for the given
|
||||
/// service. Returns `(HomeCore, Arc<AtomicBool>)` so tests can assert
|
||||
/// the handler was called.
|
||||
async fn hc_with_spy(domain: &str, service: &str) -> (HomeCore, std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
let hc = HomeCore::new();
|
||||
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let called2 = called.clone();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |_call| {
|
||||
let c = called2.clone();
|
||||
async move {
|
||||
c.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
(hc, called)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_on_dispatches_service() {
|
||||
let (hc, called) = hc_with_spy("homeassistant", "turn_on").await;
|
||||
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
|
||||
let resp = HassTurnOn.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_off_dispatches_service() {
|
||||
let (hc, called) = hc_with_spy("homeassistant", "turn_off").await;
|
||||
let intent = Intent::with_entity("HassTurnOff", "switch.fan", "en");
|
||||
let resp = HassTurnOff.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("switch.fan"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn light_set_dispatches_light_turn_on() {
|
||||
let (hc, called) = hc_with_spy("light", "turn_on").await;
|
||||
let mut intent = Intent::with_entity("HassLightSet", "light.living", "en");
|
||||
intent
|
||||
.slots
|
||||
.insert("brightness".into(), serde_json::json!(128));
|
||||
let resp = HassLightSet.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("light.living"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nevermind_returns_ok_response() {
|
||||
let hc = HomeCore::new();
|
||||
let intent = Intent {
|
||||
name: crate::intent::IntentName::new("HassNevermind"),
|
||||
slots: Default::default(),
|
||||
language: "en".into(),
|
||||
};
|
||||
let resp = HassNevermind.handle(intent, &hc).await.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("never mind")
|
||||
|| resp.speech.to_lowercase().contains("nevermind")
|
||||
|| resp.speech.to_lowercase().contains("okay"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_all_fires_domain_event() {
|
||||
let hc = HomeCore::new();
|
||||
// Subscribe before firing so the sender has a live receiver.
|
||||
let mut rx = hc.bus().subscribe_domain();
|
||||
let intent = Intent {
|
||||
name: crate::intent::IntentName::new("HassCancelAll"),
|
||||
slots: Default::default(),
|
||||
language: "en".into(),
|
||||
};
|
||||
let resp = HassCancelAll.handle(intent, &hc).await.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("cancel"));
|
||||
// Domain event should have been broadcast.
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(event.event_type, "homeassistant_stop_all_scripts");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
//! Intent types for the HOMECORE-ASSIST pipeline.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.Intent` and
|
||||
//! `homeassistant.helpers.intent.IntentResponse`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Newtype wrapping the intent name string (e.g. `"HassTurnOn"`).
|
||||
///
|
||||
/// Kept as a newtype rather than a raw `String` so that call sites can
|
||||
/// pattern-match on well-known constant values without stringly-typed bugs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct IntentName(pub String);
|
||||
|
||||
impl IntentName {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IntentName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A recognised user intent with extracted slot values.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.Intent`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Intent {
|
||||
/// The intent name, e.g. `HassTurnOn`.
|
||||
pub name: IntentName,
|
||||
/// Extracted slots as a JSON-value map. Keys are slot names
|
||||
/// (e.g. `"entity_id"`, `"brightness"`); values are typed by the
|
||||
/// recognizer.
|
||||
pub slots: HashMap<String, serde_json::Value>,
|
||||
/// BCP-47 language tag of the utterance (e.g. `"en"`, `"en-US"`).
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// Convenience constructor for single-slot intents.
|
||||
pub fn with_entity(name: impl Into<String>, entity_id: impl Into<String>, lang: &str) -> Self {
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
"entity_id".into(),
|
||||
serde_json::Value::String(entity_id.into()),
|
||||
);
|
||||
Self {
|
||||
name: IntentName::new(name),
|
||||
slots,
|
||||
language: lang.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the `entity_id` slot as a `&str`, if present.
|
||||
pub fn entity_id(&self) -> Option<&str> {
|
||||
self.slots.get("entity_id").and_then(|v| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional card displayed in the HA frontend alongside the speech response.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.IntentResponseType.ACTION_DONE`
|
||||
/// card payload.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// The full response produced by an intent handler.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.IntentResponse`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct IntentResponse {
|
||||
/// Spoken text to synthesise (TTS) or display.
|
||||
pub speech: String,
|
||||
/// Optional rich card for dashboard display.
|
||||
pub card: Option<Card>,
|
||||
/// Optional structured data for programmatic callers.
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl IntentResponse {
|
||||
/// Quick constructor for a plain speech-only response.
|
||||
pub fn speech_only(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
speech: text.into(),
|
||||
card: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default "not understood" response, mirroring HA's fallback text.
|
||||
pub fn not_understood() -> Self {
|
||||
Self::speech_only("I'm not sure how to help with that.")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intent_name_display() {
|
||||
let n = IntentName::new("HassTurnOn");
|
||||
assert_eq!(format!("{n}"), "HassTurnOn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intent_with_entity_sets_slot() {
|
||||
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
|
||||
assert_eq!(intent.entity_id(), Some("light.kitchen"));
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_understood_response_text() {
|
||||
let r = IntentResponse::not_understood();
|
||||
assert!(r.speech.contains("not sure"));
|
||||
assert!(r.card.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
//! HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
|
||||
//!
|
||||
//! Implements [ADR-133](../../../docs/adr/ADR-133-homecore-assist-ruflo.md):
|
||||
//! the Assist pipeline that takes a voice utterance through intent
|
||||
//! recognition, intent handling, and response synthesis.
|
||||
//!
|
||||
//! ## Module layout (P1 scaffold)
|
||||
//!
|
||||
//! - [`intent`] — `IntentName`, `Intent`, `IntentResponse`, `Card`
|
||||
//! - [`recognizer`] — `IntentRecognizer` trait + `RegexIntentRecognizer` (P1)
|
||||
//! - [`handler`] — `IntentHandler` trait + 5 built-in HA-mirroring handlers
|
||||
//! - [`runner`] — `RufloRunner` trait + `NoopRunner` (P1 stub)
|
||||
//! - [`pipeline`] — `AssistPipeline`: wires recognizer → handler → response
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! - Regex-based intent recognition (HA classic intent matching).
|
||||
//! - Built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`,
|
||||
//! `HassNevermind`, `HassCancelAll`.
|
||||
//! - `RufloRunner` trait surface only; `NoopRunner` stub for P1.
|
||||
//!
|
||||
//! ## What's NOT here yet (deferred to P2+)
|
||||
//!
|
||||
//! - Real `tokio::process::Child` subprocess runner for `node ruflo-agent.js`
|
||||
//! (Windows-safe teardown per ADR-133 §Q3 lands in P2).
|
||||
//! - `SemanticIntentRecognizer` using ruvector HNSW embeddings (P2).
|
||||
//! - STT/TTS bridge and satellite protocol (P3).
|
||||
|
||||
pub mod intent;
|
||||
pub mod recognizer;
|
||||
pub mod handler;
|
||||
pub mod runner;
|
||||
pub mod pipeline;
|
||||
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
|
||||
pub use handler::{
|
||||
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
IntentHandler,
|
||||
};
|
||||
pub use runner::{AssistError, NoopRunner, RufloResponse, RufloRunner, RufloRunnerOpts};
|
||||
pub use pipeline::AssistPipeline;
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
//! AssistPipeline — wires recognizer → handler → response.
|
||||
//!
|
||||
//! The pipeline is the public entry point for the HOMECORE-ASSIST subsystem.
|
||||
//! The HOMECORE-API WebSocket `assist` command will call
|
||||
//! `pipeline.process(utterance, language, &hc).await`.
|
||||
//!
|
||||
//! ## Processing flow
|
||||
//!
|
||||
//! 1. Call `recognizer.recognize(utterance, language)`.
|
||||
//! 2. If no intent matched → return `IntentResponse::not_understood()`.
|
||||
//! 3. Look up the handler by intent name.
|
||||
//! 4. Call `handler.handle(intent, hc)`.
|
||||
//! 5. Return the `IntentResponse`.
|
||||
//!
|
||||
//! The `RufloRunner` is reserved for a P2 LLM disambiguation pass that
|
||||
//! fires between steps 1 and 2 when the regex recognizer returns `None`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::HomeCore;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::handler::IntentHandler;
|
||||
use crate::intent::IntentResponse;
|
||||
use crate::recognizer::IntentRecognizer;
|
||||
use crate::runner::AssistError;
|
||||
|
||||
/// Boxed type alias so the pipeline can hold heterogeneous handlers.
|
||||
type BoxedHandler = Arc<dyn IntentHandler>;
|
||||
|
||||
/// The main Assist pipeline.
|
||||
///
|
||||
/// Construct with `AssistPipeline::new(recognizer)`, register handlers
|
||||
/// with `register_handler`, then call `process`.
|
||||
pub struct AssistPipeline<R: IntentRecognizer> {
|
||||
recognizer: R,
|
||||
handlers: HashMap<String, BoxedHandler>,
|
||||
}
|
||||
|
||||
impl<R: IntentRecognizer> AssistPipeline<R> {
|
||||
/// Create a new pipeline with the given recognizer and no handlers.
|
||||
pub fn new(recognizer: R) -> Self {
|
||||
Self {
|
||||
recognizer,
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an intent handler. If a handler for the same intent name
|
||||
/// was already registered, it is replaced.
|
||||
pub fn register_handler<H: IntentHandler>(&mut self, handler: H) {
|
||||
self.handlers
|
||||
.insert(handler.intent_name().to_owned(), Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Process an utterance through the full pipeline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AssistError` only for unexpected internal failures.
|
||||
/// Unknown intents and unrecognised utterances are returned as
|
||||
/// `IntentResponse::not_understood()` — not as errors — so the caller
|
||||
/// (WebSocket handler) can always synthesise a speech reply.
|
||||
pub async fn process(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, AssistError> {
|
||||
debug!(%utterance, %language, "AssistPipeline: processing utterance");
|
||||
|
||||
let intent = match self.recognizer.recognize(utterance, language).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => {
|
||||
debug!("no intent recognised — returning not_understood");
|
||||
return Ok(IntentResponse::not_understood());
|
||||
}
|
||||
Err(e) => return Err(AssistError::Recognizer(e)),
|
||||
};
|
||||
|
||||
let name = intent.name.as_str().to_owned();
|
||||
let handler = self.handlers.get(&name).cloned();
|
||||
|
||||
match handler {
|
||||
Some(h) => h
|
||||
.handle(intent, hc)
|
||||
.await
|
||||
.map_err(AssistError::Handler),
|
||||
None => {
|
||||
debug!(%name, "no handler registered for intent");
|
||||
Ok(IntentResponse::not_understood())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: count of registered handlers.
|
||||
pub fn handler_count(&self) -> usize {
|
||||
self.handlers.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder that pre-wires the standard set of built-in HA intent handlers.
|
||||
///
|
||||
/// Use this when you want all 5 P1 built-ins registered without listing
|
||||
/// them individually.
|
||||
pub fn default_pipeline(
|
||||
recognizer: impl IntentRecognizer,
|
||||
) -> AssistPipeline<impl IntentRecognizer> {
|
||||
use crate::handler::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
|
||||
let mut pipeline = AssistPipeline::new(recognizer);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
pipeline.register_handler(HassTurnOff);
|
||||
pipeline.register_handler(HassLightSet);
|
||||
pipeline.register_handler(HassNevermind);
|
||||
pipeline.register_handler(HassCancelAll);
|
||||
pipeline
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::{HomeCore, ServiceName};
|
||||
|
||||
use crate::handler::{HassTurnOff, HassTurnOn};
|
||||
use crate::recognizer::RegexIntentRecognizer;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn build_test_pipeline() -> (AssistPipeline<RegexIntentRecognizer>, HomeCore) {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register(
|
||||
"HassTurnOff",
|
||||
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register("HassNevermind", r"never ?mind|cancel that", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut pipeline = AssistPipeline::new(r);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
pipeline.register_handler(HassTurnOff);
|
||||
pipeline.register_handler(crate::handler::HassNevermind);
|
||||
|
||||
let hc = HomeCore::new();
|
||||
// Register spy handlers so service calls don't return NotRegistered.
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_off"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
(pipeline, hc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_turn_on_end_to_end() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("turn on light.kitchen", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_turn_off_end_to_end() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("turn off switch.fan", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("off") || resp.speech.contains("switch.fan"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_unknown_utterance_returns_not_understood() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("what is the weather like", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_recognized_but_no_handler_returns_not_understood() {
|
||||
// Register a pattern but NOT its handler.
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("HassGetState", r"what is (?P<entity_id>\S+)", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
let pipeline = AssistPipeline::new(r);
|
||||
let hc = HomeCore::new();
|
||||
let resp = pipeline
|
||||
.process("what is light.kitchen", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pipeline_registers_five_handlers() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
let pipeline = default_pipeline(r);
|
||||
assert_eq!(pipeline.handler_count(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_nevermind_response() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("never mind", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.speech.to_lowercase().contains("okay")
|
||||
|| resp.speech.to_lowercase().contains("never")
|
||||
|| resp.speech.to_lowercase().contains("cancel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_use_homecore_service_fn_handler() {
|
||||
use homecore::service::FnHandler;
|
||||
let hc = HomeCore::new();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({"ok": true})) }),
|
||||
)
|
||||
.await;
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"on (?P<entity_id>\S+)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut pipeline = AssistPipeline::new(r);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
let resp = pipeline.process("on light.bed", "en", &hc).await.unwrap();
|
||||
assert!(resp.speech.contains("light.bed"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
//! Intent recognizer trait + P1 regex-based implementation.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.IntentRecognizer` and the
|
||||
//! `homeassistant/components/conversation/default_agent.py` regex pattern
|
||||
//! approach used in HA's classic intent matching.
|
||||
//!
|
||||
//! ## P1: `RegexIntentRecognizer`
|
||||
//!
|
||||
//! Tries each registered pattern in order; the first match wins.
|
||||
//! Slot values are extracted from named capture groups.
|
||||
//!
|
||||
//! ## P2 (stub only): `SemanticIntentRecognizer`
|
||||
//!
|
||||
//! Will embed the utterance with ruvector-core and compare it to a
|
||||
//! HNSW index of intent exemplars. Falls back to regex when similarity
|
||||
//! is below a configurable threshold (default 0.75).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
// serde imports used by SemanticIntentRecognizer and future P2 code
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::intent::{Intent, IntentName};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecognizerError {
|
||||
#[error("regex compile error: {0}")]
|
||||
BadPattern(String),
|
||||
#[error("recognizer internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core trait every recognizer must implement.
|
||||
///
|
||||
/// Returns `Ok(None)` when no intent matches (pipeline falls through to
|
||||
/// the "not understood" path).
|
||||
#[async_trait]
|
||||
pub trait IntentRecognizer: Send + Sync + 'static {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError>;
|
||||
}
|
||||
|
||||
/// A single registered intent pattern.
|
||||
#[derive(Clone)]
|
||||
struct IntentPattern {
|
||||
name: IntentName,
|
||||
/// Pre-compiled regex. Named capture groups become slot keys.
|
||||
regex: Regex,
|
||||
/// Language tag this pattern applies to. `"*"` means any language.
|
||||
language: String,
|
||||
}
|
||||
|
||||
/// P1 recognizer that matches utterances against pre-registered regex patterns.
|
||||
///
|
||||
/// Thread-safe: patterns are stored in a `Vec` behind an `Arc<RwLock<_>>` so
|
||||
/// that `register` can be called from multiple tasks.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RegexIntentRecognizer {
|
||||
patterns: std::sync::Arc<tokio::sync::RwLock<Vec<IntentPattern>>>,
|
||||
}
|
||||
|
||||
impl RegexIntentRecognizer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a regex pattern for the given intent name and language.
|
||||
///
|
||||
/// Named capture groups (e.g. `(?P<entity_id>\w+\.\w+)`) become slot keys.
|
||||
/// `language` may be a BCP-47 tag (`"en"`) or `"*"` to match any language.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `RecognizerError::BadPattern` if the regex fails to compile.
|
||||
pub async fn register(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
pattern: &str,
|
||||
language: impl Into<String>,
|
||||
) -> Result<(), RecognizerError> {
|
||||
let regex = Regex::new(pattern).map_err(|e| RecognizerError::BadPattern(e.to_string()))?;
|
||||
self.patterns.write().await.push(IntentPattern {
|
||||
name: IntentName::new(name),
|
||||
regex,
|
||||
language: language.into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntentRecognizer for RegexIntentRecognizer {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
let normalised = utterance.trim().to_lowercase();
|
||||
let patterns = self.patterns.read().await;
|
||||
for pattern in patterns.iter() {
|
||||
if pattern.language != "*" && pattern.language != language {
|
||||
continue;
|
||||
}
|
||||
if let Some(caps) = pattern.regex.captures(&normalised) {
|
||||
let mut slots: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
for name in pattern.regex.capture_names().flatten() {
|
||||
if let Some(m) = caps.name(name) {
|
||||
slots.insert(name.to_owned(), serde_json::Value::String(m.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
return Ok(Some(Intent {
|
||||
name: pattern.name.clone(),
|
||||
slots,
|
||||
language: language.to_owned(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// P2 stub: semantic recognizer backed by ruvector HNSW.
|
||||
///
|
||||
/// Currently always delegates to the inner `RegexIntentRecognizer`.
|
||||
/// P2 will populate a HNSW index at startup and compare embedded
|
||||
/// utterances before falling back to regex.
|
||||
pub struct SemanticIntentRecognizer {
|
||||
fallback: RegexIntentRecognizer,
|
||||
}
|
||||
|
||||
impl SemanticIntentRecognizer {
|
||||
pub fn new(fallback: RegexIntentRecognizer) -> Self {
|
||||
Self { fallback }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntentRecognizer for SemanticIntentRecognizer {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
// TODO P2: embed utterance + HNSW search before falling through.
|
||||
self.fallback.recognize(utterance, language).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn turn_on_recognizer() -> RegexIntentRecognizer {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register(
|
||||
"HassTurnOff",
|
||||
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recognizes_turn_on_entity() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let intent = r
|
||||
.recognize("turn on the kitchen light", "en")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
assert!(intent.slots.contains_key("entity_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recognizes_dotted_entity_id() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let intent = r
|
||||
.recognize("turn on light.kitchen", "en")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
assert_eq!(intent.entity_id(), Some("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrecognized_utterance_returns_none() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let result = r.recognize("play jazz music", "en").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn language_filter_skips_non_matching() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("HassTurnOn", r"turn on (?P<entity_id>\S+)", "de")
|
||||
.await
|
||||
.unwrap();
|
||||
// German-only pattern must not match an English utterance.
|
||||
let result = r.recognize("turn on light.kitchen", "en").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
// But it must match a German-tagged utterance.
|
||||
let result = r.recognize("turn on licht.kueche", "de").await.unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_recognizer_delegates_to_fallback() {
|
||||
let regex = turn_on_recognizer().await;
|
||||
let semantic = SemanticIntentRecognizer::new(regex);
|
||||
let result = semantic
|
||||
.recognize("turn on light.kitchen", "en")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
//! RufloRunner trait + NoopRunner (P1 stub).
|
||||
//!
|
||||
//! The ruflo agent is a Node.js process that exposes an MCP-over-stdio
|
||||
//! interface for LLM-grade intent disambiguation. HOMECORE-ASSIST manages
|
||||
//! a long-lived subprocess via `tokio::process::Child`.
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! Only the trait + `NoopRunner` stub ship in P1. No subprocess is spawned.
|
||||
//!
|
||||
//! ## P2 scope
|
||||
//!
|
||||
//! Real subprocess management with Windows-safe teardown per ADR-133 §Q3:
|
||||
//! - `Child` wrapped in `Arc<Mutex<Option<Child>>>`.
|
||||
//! - Explicit `async shutdown()` calls `child.kill().await` before drop.
|
||||
//! - `tokio::signal` handler registered for `Ctrl+C`/`SIGINT` that calls
|
||||
//! `shutdown()` before exit.
|
||||
//! - Windows job object approach (option 3 per Q3) deferred to P3.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::intent::Intent;
|
||||
|
||||
/// Error type for the assist pipeline (runner + pipeline-level errors).
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AssistError {
|
||||
#[error("runner not started")]
|
||||
NotStarted,
|
||||
#[error("runner IO error: {0}")]
|
||||
Io(String),
|
||||
#[error("runner response parse error: {0}")]
|
||||
ParseError(String),
|
||||
#[error("recognizer error: {0}")]
|
||||
Recognizer(#[from] crate::recognizer::RecognizerError),
|
||||
#[error("handler error: {0}")]
|
||||
Handler(#[from] crate::handler::HandlerError),
|
||||
#[error("no handler registered for intent: {0}")]
|
||||
NoHandler(String),
|
||||
}
|
||||
|
||||
/// Configuration for launching the ruflo agent subprocess.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RufloRunnerOpts {
|
||||
/// Path to the `ruflo-agent.js` entry point.
|
||||
pub script_path: String,
|
||||
/// Additional environment variables to pass to the subprocess.
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// Request timeout in milliseconds (default 5000).
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for RufloRunnerOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
script_path: "ruflo-agent.js".into(),
|
||||
env: Default::default(),
|
||||
timeout_ms: 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON response from the ruflo agent subprocess.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RufloResponse {
|
||||
/// Recognised intent, if the LLM resolved one.
|
||||
pub intent: Option<Intent>,
|
||||
/// Spoken text from the LLM, if any.
|
||||
pub speech: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for the ruflo agent subprocess runner.
|
||||
///
|
||||
/// P1 ships only this trait + `NoopRunner`. The real subprocess runner
|
||||
/// lands in P2 with Windows-safe teardown (ADR-133 §Q3).
|
||||
#[async_trait]
|
||||
pub trait RufloRunner: Send + Sync + 'static {
|
||||
/// Spawn (or reconnect to) the ruflo agent subprocess.
|
||||
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
|
||||
|
||||
/// Send an utterance payload to the agent and await a response.
|
||||
///
|
||||
/// `payload` is an arbitrary JSON object; at minimum it should include
|
||||
/// `{ "utterance": "...", "language": "..." }`.
|
||||
async fn send_request(
|
||||
&self,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<RufloResponse, AssistError>;
|
||||
|
||||
/// Gracefully shut down the subprocess.
|
||||
///
|
||||
/// Must be idempotent — calling `shutdown` on an already-stopped runner
|
||||
/// must return `Ok(())` rather than an error.
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError>;
|
||||
}
|
||||
|
||||
/// P1 no-op implementation. Spawn/send/shutdown are all immediate Ok.
|
||||
///
|
||||
/// `send_request` returns an empty `RufloResponse` (no intent, no speech),
|
||||
/// which causes the pipeline to fall through to the regex recognizer path.
|
||||
#[derive(Default)]
|
||||
pub struct NoopRunner {
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl NoopRunner {
|
||||
pub fn new() -> Self {
|
||||
Self { started: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RufloRunner for NoopRunner {
|
||||
async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> {
|
||||
self.started = true;
|
||||
tracing::debug!("NoopRunner: spawn called (P1 stub — no subprocess started)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&self,
|
||||
_payload: serde_json::Value,
|
||||
) -> Result<RufloResponse, AssistError> {
|
||||
// P1 stub: always returns empty response so the pipeline falls through
|
||||
// to the regex recognizer.
|
||||
Ok(RufloResponse {
|
||||
intent: None,
|
||||
speech: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError> {
|
||||
// Idempotent: Ok whether or not spawn was called.
|
||||
self.started = false;
|
||||
tracing::debug!("NoopRunner: shutdown called (idempotent no-op in P1)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_spawn_returns_ok() {
|
||||
let mut runner = NoopRunner::new();
|
||||
let result = runner.spawn(RufloRunnerOpts::default()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_send_request_returns_empty_response() {
|
||||
let runner = NoopRunner::new();
|
||||
let resp = runner
|
||||
.send_request(serde_json::json!({"utterance": "turn on the light", "language": "en"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.intent.is_none());
|
||||
assert!(resp.speech.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_shutdown_is_idempotent() {
|
||||
let mut runner = NoopRunner::new();
|
||||
// First shutdown without spawn — must not error.
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
// Spawn then shutdown — must not error.
|
||||
runner.spawn(RufloRunnerOpts::default()).await.unwrap();
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
// Second shutdown — must still not error.
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# homecore-automation — HOMECORE automation engine, trigger evaluator, and
|
||||
# MiniJinja template evaluator.
|
||||
# Implements ADR-129 (HOMECORE-AUTO): YAML automation parser, trigger/condition/
|
||||
# action evaluation, AutomationEngine runtime that subscribes to the HOMECORE
|
||||
# event bus and fires automations.
|
||||
|
||||
[package]
|
||||
name = "homecore-automation"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Automation engine, trigger evaluator, and MiniJinja template evaluator for HOMECORE (ADR-129)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_automation"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE core — state machine, event bus, service registry, entity types
|
||||
homecore = { path = "../homecore" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
|
||||
# Serialization — YAML automation files + JSON service call data
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1)
|
||||
minijinja = { version = "2", features = ["json", "loader"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
# Time — chrono DateTime for triggers + condition evaluation
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Async trait for EvaluateTrigger + condition evaluate
|
||||
async-trait = "0.1"
|
||||
|
||||
# Unique IDs for automation instances
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
//! `Action` enum and async execution.
|
||||
//!
|
||||
//! Implements the ADR-129 P1 action set: `service_call`, `delay`, `scene`,
|
||||
//! `wait_for_trigger`, `choose`. Complex variants (parallel, repeat, if,
|
||||
//! stop, fire_event, wait_template) land in P2.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
|
||||
|
||||
use crate::error::AutomationError;
|
||||
|
||||
/// Runtime context passed into action execution.
|
||||
pub struct ExecutionContext {
|
||||
/// HOMECORE handle — provides service registry + state machine.
|
||||
pub hc: HomeCore,
|
||||
/// Causality context for service calls triggered by this automation.
|
||||
pub context: Context,
|
||||
/// Automation ID for tracing/logging.
|
||||
pub automation_id: String,
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn new(hc: HomeCore, automation_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
hc,
|
||||
context: Context::new(),
|
||||
automation_id: automation_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Action configuration. Deserialized from YAML `action:` blocks.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
/// Call a HOMECORE service.
|
||||
ServiceCall {
|
||||
domain: String,
|
||||
service: String,
|
||||
#[serde(default)]
|
||||
data: serde_json::Value,
|
||||
},
|
||||
/// Pause execution for a fixed duration (ISO 8601 or seconds float).
|
||||
Delay {
|
||||
/// Delay in seconds.
|
||||
seconds: f64,
|
||||
},
|
||||
/// Activate a named scene entity.
|
||||
Scene {
|
||||
scene: String,
|
||||
},
|
||||
/// Block until one of the listed triggers fires (or timeout).
|
||||
WaitForTrigger {
|
||||
timeout_seconds: Option<f64>,
|
||||
},
|
||||
/// Conditional branching — first matching branch wins.
|
||||
Choose {
|
||||
choices: Vec<ChoiceBranch>,
|
||||
#[serde(default)]
|
||||
default: Vec<Action>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single branch in a `Choose` action.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChoiceBranch {
|
||||
pub conditions: Vec<serde_yaml::Value>,
|
||||
pub sequence: Vec<Action>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Execute this action using the provided context.
|
||||
///
|
||||
/// Returns a JSON value (may be `null`) for callers that chain
|
||||
/// `wait_for_trigger` / `set_variable` patterns (P2).
|
||||
///
|
||||
/// Uses `Box::pin` for recursive variants (Choose) to satisfy the
|
||||
/// Rust requirement that recursive async fns introduce indirection.
|
||||
pub fn execute<'a>(
|
||||
&'a self,
|
||||
ctx: &'a mut ExecutionContext,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value, AutomationError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
match self {
|
||||
Action::ServiceCall { domain, service, data } => {
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new(domain.clone(), service.clone()),
|
||||
data: data.clone(),
|
||||
context: ctx.context.clone(),
|
||||
};
|
||||
let result = ctx.hc.services().call(call).await?;
|
||||
Ok(result)
|
||||
}
|
||||
Action::Delay { seconds } => {
|
||||
let dur = Duration::from_secs_f64(*seconds);
|
||||
sleep(dur).await;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
Action::Scene { scene } => {
|
||||
// Scene activation maps to homeassistant.turn_on with entity_id = scene
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_on"),
|
||||
data: serde_json::json!({ "entity_id": scene }),
|
||||
context: ctx.context.clone(),
|
||||
};
|
||||
let result = ctx.hc.services().call(call).await?;
|
||||
Ok(result)
|
||||
}
|
||||
Action::WaitForTrigger { timeout_seconds } => {
|
||||
// P1 stub — just sleeps for the timeout duration if specified.
|
||||
// Full trigger subscription lands in P2.
|
||||
if let Some(secs) = timeout_seconds {
|
||||
sleep(Duration::from_secs_f64(*secs)).await;
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
Action::Choose { choices: _, default } => {
|
||||
// P1 stub — condition evaluation for choices lands in P2;
|
||||
// for now, fall through to default branch.
|
||||
for a in default {
|
||||
a.execute(ctx).await?;
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_action_fires_handler() {
|
||||
let hc = HomeCore::new();
|
||||
let log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(vec![]));
|
||||
let log2 = Arc::clone(&log);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("light", "turn_on"),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let log3 = Arc::clone(&log2);
|
||||
async move {
|
||||
log3.lock().unwrap().push(call.data.clone());
|
||||
Ok(call.data)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let action = Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({"brightness": 255}),
|
||||
};
|
||||
let mut exec_ctx = ExecutionContext::new(hc, "test_auto");
|
||||
let res = action.execute(&mut exec_ctx).await.unwrap();
|
||||
assert_eq!(res["brightness"], 255);
|
||||
assert_eq!(log.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delay_action_completes() {
|
||||
let hc = HomeCore::new();
|
||||
let mut exec_ctx = ExecutionContext::new(hc, "test_auto");
|
||||
let action = Action::Delay { seconds: 0.001 };
|
||||
let result = action.execute(&mut exec_ctx).await.unwrap();
|
||||
assert!(result.is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_unregistered_returns_error() {
|
||||
let hc = HomeCore::new();
|
||||
let mut exec_ctx = ExecutionContext::new(hc, "test_auto");
|
||||
let action = Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
};
|
||||
let err = action.execute(&mut exec_ctx).await.unwrap_err();
|
||||
assert!(matches!(err, AutomationError::ServiceCall(ServiceError::NotRegistered { .. })));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
//! `Automation` — the parsed representation of one HA automation YAML block.
|
||||
//!
|
||||
//! Mirrors HA's `AutomationConfig` / `AutomationEntity`. Deserialized from
|
||||
//! YAML via serde; validated at construction time by the engine.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::condition::Condition;
|
||||
use crate::trigger::Trigger;
|
||||
|
||||
/// Script run mode. Mirrors HA's `ScriptRunMode` (`script/__init__.py`).
|
||||
///
|
||||
/// Controls what happens when a second trigger fires while the automation
|
||||
/// is already running.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunMode {
|
||||
/// Only one instance runs at a time. If already running, the new
|
||||
/// trigger is silently dropped (HA default).
|
||||
#[default]
|
||||
Single,
|
||||
/// Kill the running instance and start a fresh one.
|
||||
Restart,
|
||||
/// Queue new triggers; execute sequentially when the prior run finishes.
|
||||
Queued,
|
||||
/// Allow unlimited concurrent runs.
|
||||
Parallel,
|
||||
/// Same as `Single` but also skips the first trigger (rarely used).
|
||||
IgnoreFirst,
|
||||
}
|
||||
|
||||
/// A parsed automation. Cheap to clone — all heaps are `Arc`-free vecs of
|
||||
/// enums; the engine holds `Arc<Automation>` copies.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Automation {
|
||||
/// Unique identifier. HA auto-assigns a 32-char hex ID if omitted.
|
||||
pub id: String,
|
||||
|
||||
/// Human-readable alias shown in the HA UI.
|
||||
#[serde(default)]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Optional free-text description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Whether the automation is enabled. Disabled automations are loaded
|
||||
/// but their triggers are not evaluated.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Script run mode.
|
||||
#[serde(default)]
|
||||
pub mode: RunMode,
|
||||
|
||||
/// Maximum concurrent runs when mode is `Queued` or `Parallel`.
|
||||
#[serde(default)]
|
||||
pub max: Option<usize>,
|
||||
|
||||
/// One or more trigger definitions. At least one must be present.
|
||||
pub trigger: Vec<Trigger>,
|
||||
|
||||
/// Optional conditions — all must pass before actions run.
|
||||
#[serde(default)]
|
||||
pub condition: Vec<Condition>,
|
||||
|
||||
/// Action sequence to execute when triggered + conditions pass.
|
||||
pub action: Vec<Action>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Minimal constructor for tests.
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
triggers: Vec<Trigger>,
|
||||
actions: Vec<Action>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
alias: None,
|
||||
description: None,
|
||||
enabled: true,
|
||||
mode: RunMode::Single,
|
||||
max: None,
|
||||
trigger: triggers,
|
||||
condition: vec![],
|
||||
action: actions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::trigger::Trigger;
|
||||
|
||||
#[test]
|
||||
fn run_mode_defaults_to_single() {
|
||||
let a = Automation::new("test.1", vec![Trigger::Event { event_type: "t".into() }], vec![]);
|
||||
assert_eq!(a.mode, RunMode::Single);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_enabled_by_default() {
|
||||
let a = Automation::new("test.2", vec![], vec![]);
|
||||
assert!(a.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_mode_roundtrip_yaml() {
|
||||
// RunMode is a plain string enum; deserialize from a bare YAML string.
|
||||
let mode: RunMode = serde_yaml::from_str("restart").unwrap();
|
||||
assert_eq!(mode, RunMode::Restart);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
//! `Condition` enum + async evaluation.
|
||||
//!
|
||||
//! Mirrors HA's 7 condition types. P1 ships: `state`, `numeric_state`,
|
||||
//! `template`, `and`, `or`, `not`. Time/zone/sun/device land in P2.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::{EntityId, StateMachine};
|
||||
|
||||
use crate::template::TemplateEnvironment;
|
||||
|
||||
/// Context passed to condition evaluation. Holds a snapshot of the state
|
||||
/// machine and the optional template evaluator.
|
||||
#[derive(Clone)]
|
||||
pub struct EvalContext {
|
||||
pub states: Arc<StateMachine>,
|
||||
pub template_env: Option<Arc<TemplateEnvironment>>,
|
||||
}
|
||||
|
||||
impl EvalContext {
|
||||
pub fn new(states: Arc<StateMachine>) -> Self {
|
||||
Self { states, template_env: None }
|
||||
}
|
||||
|
||||
pub fn with_templates(states: Arc<StateMachine>, env: Arc<TemplateEnvironment>) -> Self {
|
||||
Self { states, template_env: Some(env) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Condition configuration. Deserialized from YAML `condition:` blocks.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "condition", rename_all = "snake_case")]
|
||||
pub enum Condition {
|
||||
/// Entity state equals a specific value.
|
||||
State {
|
||||
entity_id: EntityId,
|
||||
state: String,
|
||||
},
|
||||
/// Entity numeric state satisfies threshold bounds.
|
||||
NumericState {
|
||||
entity_id: EntityId,
|
||||
#[serde(default)]
|
||||
above: Option<f64>,
|
||||
#[serde(default)]
|
||||
below: Option<f64>,
|
||||
},
|
||||
/// Jinja2 template evaluates to truthy.
|
||||
Template {
|
||||
value_template: String,
|
||||
},
|
||||
/// All child conditions must be true (logical AND).
|
||||
And {
|
||||
conditions: Vec<Condition>,
|
||||
},
|
||||
/// At least one child condition must be true (logical OR).
|
||||
Or {
|
||||
conditions: Vec<Condition>,
|
||||
},
|
||||
/// Inner condition must be false (logical NOT).
|
||||
Not {
|
||||
conditions: Vec<Condition>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
/// Evaluate this condition against the provided context.
|
||||
///
|
||||
/// Uses `Box::pin` for recursive variants (And/Or/Not) to satisfy the
|
||||
/// Rust requirement that recursive async fns introduce indirection.
|
||||
pub fn evaluate<'a>(&'a self, ctx: &'a EvalContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
match self {
|
||||
Condition::State { entity_id, state } => {
|
||||
ctx.states
|
||||
.get(entity_id)
|
||||
.map_or(false, |s| s.state == *state)
|
||||
}
|
||||
Condition::NumericState { entity_id, above, below } => {
|
||||
let value: Option<f64> = ctx
|
||||
.states
|
||||
.get(entity_id)
|
||||
.and_then(|s| s.state.parse().ok());
|
||||
match value {
|
||||
None => false,
|
||||
Some(v) => {
|
||||
above.map_or(true, |a| v > a) && below.map_or(true, |b| v < b)
|
||||
}
|
||||
}
|
||||
}
|
||||
Condition::Template { value_template } => {
|
||||
if let Some(env) = &ctx.template_env {
|
||||
match env.render_bool(value_template) {
|
||||
Ok(v) => v,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Condition::And { conditions } => {
|
||||
for c in conditions {
|
||||
if !c.evaluate(ctx).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Condition::Or { conditions } => {
|
||||
for c in conditions {
|
||||
if c.evaluate(ctx).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Condition::Not { conditions } => {
|
||||
for c in conditions {
|
||||
if c.evaluate(ctx).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{Context, EntityId, StateMachine};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn sm_with(entity_id: &str, state: &str) -> Arc<StateMachine> {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(
|
||||
EntityId::parse(entity_id).unwrap(),
|
||||
state,
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
sm
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_condition_matches() {
|
||||
let sm = sm_with("light.kitchen", "on");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
state: "on".into(),
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_condition_no_match() {
|
||||
let sm = sm_with("light.kitchen", "off");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
state: "on".into(),
|
||||
};
|
||||
assert!(!cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn numeric_condition_above() {
|
||||
let sm = sm_with("sensor.temperature", "28");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::NumericState {
|
||||
entity_id: EntityId::parse("sensor.temperature").unwrap(),
|
||||
above: Some(25.0),
|
||||
below: None,
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn and_combinator_all_true() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse("light.a").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
sm.set(EntityId::parse("light.b").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::And {
|
||||
conditions: vec![
|
||||
Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() },
|
||||
Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() },
|
||||
],
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn and_combinator_one_false() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse("light.a").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
sm.set(EntityId::parse("light.b").unwrap(), "off", serde_json::json!({}), Context::new());
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::And {
|
||||
conditions: vec![
|
||||
Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() },
|
||||
Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() },
|
||||
],
|
||||
};
|
||||
assert!(!cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn or_combinator_one_true() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse("light.a").unwrap(), "off", serde_json::json!({}), Context::new());
|
||||
sm.set(EntityId::parse("light.b").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::Or {
|
||||
conditions: vec![
|
||||
Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() },
|
||||
Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() },
|
||||
],
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_condition_inverts() {
|
||||
let sm = sm_with("light.kitchen", "off");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::Not {
|
||||
conditions: vec![
|
||||
Condition::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
state: "on".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
//! `AutomationEngine` — subscribes to the HOMECORE event bus, evaluates
|
||||
//! triggers, and runs automation action sequences.
|
||||
//!
|
||||
//! ADR-129 §2 design: one Tokio task per running automation instance.
|
||||
//! RunMode::Single is enforced via a per-automation `AtomicBool` flag.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::action::ExecutionContext;
|
||||
use crate::automation::Automation;
|
||||
use crate::condition::EvalContext;
|
||||
use crate::trigger::TriggerContext;
|
||||
|
||||
/// The automation engine. Holds a HOMECORE handle and a list of registered
|
||||
/// automations. Call `start()` to begin listening for events.
|
||||
pub struct AutomationEngine {
|
||||
hc: HomeCore,
|
||||
automations: Arc<Mutex<Vec<Arc<Automation>>>>,
|
||||
}
|
||||
|
||||
impl AutomationEngine {
|
||||
/// Create a new engine backed by the given HOMECORE handle.
|
||||
pub fn new(hc: HomeCore) -> Self {
|
||||
Self {
|
||||
hc,
|
||||
automations: Arc::new(Mutex::new(vec![])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an automation. Can be called before or after `start()`.
|
||||
pub fn register(&self, automation: Automation) {
|
||||
self.automations.lock().unwrap().push(Arc::new(automation));
|
||||
}
|
||||
|
||||
/// Subscribe to the state-machine broadcast channel and start
|
||||
/// evaluating triggers. Returns a join handle for the background task.
|
||||
///
|
||||
/// The task runs until the broadcast sender is dropped (i.e. the
|
||||
/// `HomeCore` instance is destroyed).
|
||||
pub fn start(&self) -> tokio::task::JoinHandle<()> {
|
||||
let mut rx = self.hc.states().subscribe();
|
||||
let automations = Arc::clone(&self.automations);
|
||||
let hc = self.hc.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let autos = automations.lock().unwrap().clone();
|
||||
for automation in autos {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
let trigger_ctx = TriggerContext::state_changed(
|
||||
event.entity_id.clone(),
|
||||
event.old_state.clone(),
|
||||
event.new_state.clone(),
|
||||
);
|
||||
// Check all triggers — fire on first match
|
||||
let triggered = automation
|
||||
.trigger
|
||||
.iter()
|
||||
.any(|t| t.matches_sync(&trigger_ctx));
|
||||
if !triggered {
|
||||
continue;
|
||||
}
|
||||
// Evaluate conditions
|
||||
let sm = Arc::new(hc.states().clone());
|
||||
let eval_ctx = EvalContext::new(sm);
|
||||
let mut conditions_pass = true;
|
||||
for cond in &automation.condition {
|
||||
if !cond.evaluate(&eval_ctx).await {
|
||||
conditions_pass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !conditions_pass {
|
||||
continue;
|
||||
}
|
||||
// Execute actions in a spawned task (non-blocking)
|
||||
let auto_clone = Arc::clone(&automation);
|
||||
let hc_clone = hc.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut exec_ctx =
|
||||
ExecutionContext::new(hc_clone, auto_clone.id.clone());
|
||||
for action in &auto_clone.action {
|
||||
if let Err(e) = action.execute(&mut exec_ctx).await {
|
||||
// P1: log errors to stderr; structured logging in P2
|
||||
eprintln!(
|
||||
"[homecore-automation] action error in {}: {e}",
|
||||
auto_clone.id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!("[homecore-automation] state-changed receiver lagged by {n} events");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Action;
|
||||
use crate::automation::Automation;
|
||||
use crate::trigger::Trigger;
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
/// Register a recording handler that captures all calls.
|
||||
async fn register_recorder(
|
||||
hc: &HomeCore,
|
||||
domain: &str,
|
||||
service: &str,
|
||||
) -> Arc<Mutex<Vec<serde_json::Value>>> {
|
||||
let log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(vec![]));
|
||||
let log2 = Arc::clone(&log);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let l = Arc::clone(&log2);
|
||||
async move {
|
||||
l.lock().unwrap().push(call.data.clone());
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
log
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_fires_automation_on_state_change() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
engine.register(Automation::new(
|
||||
"test_auto_1",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.living").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({"brightness": 100}),
|
||||
}],
|
||||
));
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
// Fire a matching state change
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.living").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
// Give the async task time to run
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(log.lock().unwrap().len(), 1);
|
||||
assert_eq!(log.lock().unwrap()[0]["brightness"], 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_does_not_fire_on_wrong_entity() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
engine.register(Automation::new(
|
||||
"test_auto_2",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.living").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
));
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
// Fire on a DIFFERENT entity
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.bedroom").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 0, "should not fire on wrong entity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_disabled_automation_does_not_fire() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = Automation::new(
|
||||
"test_auto_3",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.living").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
auto.enabled = false;
|
||||
engine.register(auto);
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.living").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 0, "disabled automation should not fire");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
//! Crate-wide error type for homecore-automation.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use homecore::ServiceError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AutomationError {
|
||||
#[error("YAML parse error: {0}")]
|
||||
YamlParse(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("template render error: {0}")]
|
||||
TemplateRender(String),
|
||||
|
||||
#[error("service call failed: {0}")]
|
||||
ServiceCall(#[from] ServiceError),
|
||||
|
||||
#[error("entity id invalid: {0}")]
|
||||
EntityId(#[from] homecore::EntityIdError),
|
||||
|
||||
#[error("automation {id} not found")]
|
||||
NotFound { id: String },
|
||||
|
||||
#[error("automation action timed out after {secs}s")]
|
||||
ActionTimeout { secs: u64 },
|
||||
|
||||
#[error("numeric state parse error for '{entity_id}': {value}")]
|
||||
NumericParse { entity_id: String, value: String },
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
//! homecore-automation — ADR-129 HOMECORE-AUTO
|
||||
//!
|
||||
//! Automation engine, trigger evaluator, MiniJinja template evaluator, and
|
||||
//! script action executor for the HOMECORE Home Assistant port.
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
//! - [`automation`] — `Automation` struct: id, alias, mode, triggers, conditions, actions
|
||||
//! - [`trigger`] — `Trigger` enum + `EvaluateTrigger` trait
|
||||
//! - [`condition`] — `Condition` enum + async `evaluate` method + `EvalContext`
|
||||
//! - [`action`] — `Action` enum + async `execute` method + `ExecutionContext`
|
||||
//! - [`template`] — MiniJinja environment with HA-compat globals (states, state_attr, is_state, now)
|
||||
//! - [`engine`] — `AutomationEngine`: subscribes to event bus, drives trigger→condition→action pipeline
|
||||
//! - [`error`] — crate-wide `AutomationError`
|
||||
|
||||
pub mod automation;
|
||||
pub mod trigger;
|
||||
pub mod condition;
|
||||
pub mod action;
|
||||
pub mod template;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
|
||||
pub use automation::{Automation, RunMode};
|
||||
pub use trigger::{EvaluateTrigger, Trigger, TriggerContext};
|
||||
pub use condition::{Condition, EvalContext};
|
||||
pub use action::{Action, ExecutionContext};
|
||||
pub use template::TemplateEnvironment;
|
||||
pub use engine::AutomationEngine;
|
||||
pub use error::AutomationError;
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
//! MiniJinja-based template environment with HA-compatible globals.
|
||||
//!
|
||||
//! ADR-129 §2.1 — P1 ships four HA globals: `states()`, `state_attr()`,
|
||||
//! `is_state()`, `now()`. The `utcnow()`, `as_timestamp()`, `distance()`,
|
||||
//! and `iif()` globals plus custom filters land in P2.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use minijinja::{Environment, Value};
|
||||
|
||||
use homecore::{EntityId, StateMachine};
|
||||
|
||||
use crate::error::AutomationError;
|
||||
|
||||
/// MiniJinja environment pre-loaded with HA-compatible globals.
|
||||
///
|
||||
/// Constructed once per `AutomationEngine` and shared via `Arc`. The
|
||||
/// globals close over an `Arc<StateMachine>` so every template render
|
||||
/// sees the live current state.
|
||||
pub struct TemplateEnvironment {
|
||||
env: Environment<'static>,
|
||||
}
|
||||
|
||||
impl TemplateEnvironment {
|
||||
/// Build a new environment backed by the given state machine.
|
||||
pub fn new(states: Arc<StateMachine>) -> Self {
|
||||
let mut env = Environment::new();
|
||||
|
||||
// --- states(entity_id) ---
|
||||
// Returns the current state string of an entity, or "unavailable".
|
||||
let states_sm = Arc::clone(&states);
|
||||
env.add_global(
|
||||
"states",
|
||||
Value::from_function(move |entity_id: String| -> String {
|
||||
EntityId::parse(&entity_id)
|
||||
.ok()
|
||||
.and_then(|eid| states_sm.get(&eid))
|
||||
.map(|s| s.state.clone())
|
||||
.unwrap_or_else(|| "unavailable".into())
|
||||
}),
|
||||
);
|
||||
|
||||
// --- state_attr(entity_id, attribute) ---
|
||||
// Returns an attribute value as a JSON string, or empty string.
|
||||
let attr_sm = Arc::clone(&states);
|
||||
env.add_global(
|
||||
"state_attr",
|
||||
Value::from_function(move |entity_id: String, attr: String| -> String {
|
||||
EntityId::parse(&entity_id)
|
||||
.ok()
|
||||
.and_then(|eid| attr_sm.get(&eid))
|
||||
.and_then(|s| s.attributes.get(&attr).cloned())
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
|
||||
// --- is_state(entity_id, state) ---
|
||||
// Returns true if the entity's current state matches the given value.
|
||||
let is_state_sm = Arc::clone(&states);
|
||||
env.add_global(
|
||||
"is_state",
|
||||
Value::from_function(move |entity_id: String, expected: String| -> bool {
|
||||
EntityId::parse(&entity_id)
|
||||
.ok()
|
||||
.and_then(|eid| is_state_sm.get(&eid))
|
||||
.map(|s| s.state == expected)
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
);
|
||||
|
||||
// --- now() ---
|
||||
// Returns the current UTC datetime as an ISO 8601 string.
|
||||
// HA returns a Python datetime; MiniJinja returns a string which
|
||||
// templates can further format with the `strftime` filter.
|
||||
env.add_global(
|
||||
"now",
|
||||
Value::from_function(|| -> String {
|
||||
Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
|
||||
}),
|
||||
);
|
||||
|
||||
Self { env }
|
||||
}
|
||||
|
||||
/// Render a template string and return the string output.
|
||||
pub fn render(&self, template_str: &str) -> Result<String, AutomationError> {
|
||||
// Wrap bare expressions like `{{ states('light.kitchen') }}`
|
||||
// in a minimal template wrapper.
|
||||
let tmpl = self
|
||||
.env
|
||||
.template_from_str(template_str)
|
||||
.map_err(|e| AutomationError::TemplateRender(e.to_string()))?;
|
||||
tmpl.render(())
|
||||
.map_err(|e| AutomationError::TemplateRender(e.to_string()))
|
||||
}
|
||||
|
||||
/// Render a template and interpret the output as a boolean.
|
||||
/// "true", "1", "yes", "on" → true. Everything else → false.
|
||||
pub fn render_bool(&self, template_str: &str) -> Result<bool, AutomationError> {
|
||||
let raw = self.render(template_str)?;
|
||||
let v = raw.trim().to_ascii_lowercase();
|
||||
Ok(matches!(v.as_str(), "true" | "1" | "yes" | "on"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{Context, EntityId, StateMachine};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn sm_with(entity_id: &str, state: &str, attrs: serde_json::Value) -> Arc<StateMachine> {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse(entity_id).unwrap(), state, attrs, Context::new());
|
||||
sm
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_global_returns_current_state() {
|
||||
let sm = sm_with("light.kitchen", "on", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ states('light.kitchen') }}").unwrap();
|
||||
assert_eq!(out.trim(), "on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_global_unknown_entity_returns_unavailable() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ states('sensor.unknown') }}").unwrap();
|
||||
assert_eq!(out.trim(), "unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_attr_returns_attribute_value() {
|
||||
let sm = sm_with(
|
||||
"light.kitchen",
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
);
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ state_attr('light.kitchen', 'brightness') }}").unwrap();
|
||||
assert_eq!(out.trim(), "200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_state_global_true_when_matches() {
|
||||
let sm = sm_with("switch.fan", "on", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap();
|
||||
assert_eq!(out.trim(), "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_state_global_false_when_no_match() {
|
||||
let sm = sm_with("switch.fan", "off", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap();
|
||||
assert_eq!(out.trim(), "false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_global_returns_timestamp_string() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ now() }}").unwrap();
|
||||
// Should be an ISO 8601 datetime string containing 'T'
|
||||
assert!(out.contains('T'), "now() returned: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bool_true_values() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
for tmpl in &["true", "1", "yes", "on"] {
|
||||
let result = env.render_bool(tmpl).unwrap();
|
||||
assert!(result, "expected true for: {tmpl}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bool_false_for_other() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
assert!(!env.render_bool("false").unwrap());
|
||||
assert!(!env.render_bool("0").unwrap());
|
||||
assert!(!env.render_bool("off").unwrap());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
//! `Trigger` enum and `EvaluateTrigger` trait.
|
||||
//!
|
||||
//! Covers the four most common HA trigger platforms as required by ADR-129 P1:
|
||||
//! `state`, `numeric_state`, `time`, and `event`. Additional platforms land
|
||||
//! in P2 (template, zone, sun, MQTT, webhook, etc.).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::{EntityId, State};
|
||||
|
||||
/// Context produced by a fired trigger. Passed into condition evaluation and
|
||||
/// template rendering as `trigger.*` variables.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TriggerContext {
|
||||
/// Which trigger platform fired.
|
||||
pub platform: String,
|
||||
/// Entity ID (for state / numeric_state triggers).
|
||||
pub entity_id: Option<EntityId>,
|
||||
/// New state snapshot (for state / numeric_state triggers).
|
||||
pub to_state: Option<Arc<State>>,
|
||||
/// Previous state snapshot (for state / numeric_state triggers).
|
||||
pub from_state: Option<Arc<State>>,
|
||||
/// When the trigger fired.
|
||||
pub fired_at: DateTime<Utc>,
|
||||
/// Event type (for event triggers).
|
||||
pub event_type: Option<String>,
|
||||
}
|
||||
|
||||
impl TriggerContext {
|
||||
pub fn state_changed(
|
||||
entity_id: EntityId,
|
||||
from: Option<Arc<State>>,
|
||||
to: Option<Arc<State>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
platform: "state".into(),
|
||||
entity_id: Some(entity_id),
|
||||
to_state: to,
|
||||
from_state: from,
|
||||
fired_at: Utc::now(),
|
||||
event_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event(event_type: impl Into<String>) -> Self {
|
||||
Self {
|
||||
platform: "event".into(),
|
||||
entity_id: None,
|
||||
to_state: None,
|
||||
from_state: None,
|
||||
fired_at: Utc::now(),
|
||||
event_type: Some(event_type.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async evaluation trait. Each trigger variant implements this to decide
|
||||
/// whether a given `TriggerContext` matches its configuration.
|
||||
#[async_trait]
|
||||
pub trait EvaluateTrigger: Send + Sync {
|
||||
async fn matches(&self, ctx: &TriggerContext) -> bool;
|
||||
}
|
||||
|
||||
/// Trigger configuration. Deserialized from YAML `trigger:` blocks.
|
||||
///
|
||||
/// Only four platforms are implemented in P1 (ADR-129 §6 Phase 1).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "platform", rename_all = "snake_case")]
|
||||
pub enum Trigger {
|
||||
/// Fires when an entity's state changes.
|
||||
State {
|
||||
entity_id: EntityId,
|
||||
/// Optional: only fire if state was previously this value.
|
||||
#[serde(default)]
|
||||
from: Option<String>,
|
||||
/// Optional: only fire if state transitions to this value.
|
||||
#[serde(default)]
|
||||
to: Option<String>,
|
||||
},
|
||||
/// Fires when an entity's numeric state crosses a threshold.
|
||||
NumericState {
|
||||
entity_id: EntityId,
|
||||
/// Fire when value rises above this threshold.
|
||||
#[serde(default)]
|
||||
above: Option<f64>,
|
||||
/// Fire when value drops below this threshold.
|
||||
#[serde(default)]
|
||||
below: Option<f64>,
|
||||
},
|
||||
/// Fires at a specific time of day (HH:MM:SS).
|
||||
Time {
|
||||
at: String,
|
||||
},
|
||||
/// Fires when a named domain event is published on the event bus.
|
||||
Event {
|
||||
event_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
/// Synchronous check — does this trigger configuration match the provided
|
||||
/// context? Used directly in tests and by the engine's event loop.
|
||||
pub fn matches_sync(&self, ctx: &TriggerContext) -> bool {
|
||||
match self {
|
||||
Trigger::State { entity_id, from, to } => {
|
||||
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
|
||||
if !eid_match {
|
||||
return false;
|
||||
}
|
||||
if let Some(expected_from) = from {
|
||||
let actual_from = ctx.from_state.as_ref().map(|s| s.state.as_str()).unwrap_or("unavailable");
|
||||
if actual_from != expected_from.as_str() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(expected_to) = to {
|
||||
let actual_to = ctx.to_state.as_ref().map(|s| s.state.as_str()).unwrap_or("unavailable");
|
||||
if actual_to != expected_to.as_str() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Trigger::NumericState { entity_id, above, below } => {
|
||||
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
|
||||
if !eid_match {
|
||||
return false;
|
||||
}
|
||||
let value: f64 = ctx
|
||||
.to_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.state.parse().ok())
|
||||
.unwrap_or(f64::NAN);
|
||||
if value.is_nan() {
|
||||
return false;
|
||||
}
|
||||
if let Some(a) = above {
|
||||
if value <= *a {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(b) = below {
|
||||
if value >= *b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Trigger::Time { .. } => {
|
||||
// Time triggers are evaluated by the engine's timer task, not here.
|
||||
false
|
||||
}
|
||||
Trigger::Event { event_type } => {
|
||||
ctx.event_type.as_deref() == Some(event_type.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EvaluateTrigger for Trigger {
|
||||
async fn matches(&self, ctx: &TriggerContext) -> bool {
|
||||
self.matches_sync(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{Context, EntityId, State};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_state(entity_id: &str, state: &str) -> Arc<State> {
|
||||
Arc::new(State::new(
|
||||
EntityId::parse(entity_id).unwrap(),
|
||||
state,
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
))
|
||||
}
|
||||
|
||||
fn state_ctx(entity_id: &str, from: &str, to: &str) -> TriggerContext {
|
||||
let eid = EntityId::parse(entity_id).unwrap();
|
||||
TriggerContext::state_changed(
|
||||
eid,
|
||||
Some(make_state(entity_id, from)),
|
||||
Some(make_state(entity_id, to)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_exact_from_to_match() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: Some("off".into()),
|
||||
to: Some("on".into()),
|
||||
};
|
||||
let ctx = state_ctx("light.kitchen", "off", "on");
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_wrong_entity_no_match() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
};
|
||||
let ctx = state_ctx("switch.hallway", "off", "on");
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_wrong_to_no_match() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
};
|
||||
let ctx = state_ctx("light.kitchen", "on", "off");
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_no_constraints_matches_any_change() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
};
|
||||
let ctx = state_ctx("light.kitchen", "off", "on");
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_trigger_above_threshold_fires() {
|
||||
let trigger = Trigger::NumericState {
|
||||
entity_id: EntityId::parse("sensor.temperature").unwrap(),
|
||||
above: Some(25.0),
|
||||
below: None,
|
||||
};
|
||||
let mut ctx = state_ctx("sensor.temperature", "20", "26");
|
||||
ctx.to_state = Some(make_state("sensor.temperature", "26"));
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_trigger_below_threshold_no_fire() {
|
||||
let trigger = Trigger::NumericState {
|
||||
entity_id: EntityId::parse("sensor.temperature").unwrap(),
|
||||
above: Some(25.0),
|
||||
below: None,
|
||||
};
|
||||
let mut ctx = state_ctx("sensor.temperature", "20", "24");
|
||||
ctx.to_state = Some(make_state("sensor.temperature", "24"));
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_trigger_between_bounds() {
|
||||
let trigger = Trigger::NumericState {
|
||||
entity_id: EntityId::parse("sensor.humidity").unwrap(),
|
||||
above: Some(30.0),
|
||||
below: Some(80.0),
|
||||
};
|
||||
let mut ctx = state_ctx("sensor.humidity", "20", "50");
|
||||
ctx.to_state = Some(make_state("sensor.humidity", "50"));
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_trigger_matches_type() {
|
||||
let trigger = Trigger::Event { event_type: "my_custom_event".into() };
|
||||
let ctx = TriggerContext::event("my_custom_event");
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_trigger_no_match_wrong_type() {
|
||||
let trigger = Trigger::Event { event_type: "my_custom_event".into() };
|
||||
let ctx = TriggerContext::event("other_event");
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_trigger_trait_object() {
|
||||
let trigger: Box<dyn EvaluateTrigger> = Box::new(Trigger::Event {
|
||||
event_type: "boot".into(),
|
||||
});
|
||||
let ctx = TriggerContext::event("boot");
|
||||
assert!(trigger.matches(&ctx).await);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# homecore-hap — Apple Home HomeKit Accessory Protocol bridge (ADR-125 P1 scaffold)
|
||||
#
|
||||
# P1 ships the trait surface, accessory/characteristic types, entity→HAP mapping,
|
||||
# bridge API, and an mDNS-advertise stub. The actual HAP-1.1 server and real
|
||||
# mDNS integration are feature-gated to P2 via the `hap-server` feature flag.
|
||||
|
||||
[package]
|
||||
name = "homecore-hap"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Apple Home HomeKit Accessory Protocol bridge — ADR-125 P1 scaffold"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
|
||||
[lib]
|
||||
name = "homecore_hap"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd
|
||||
hap-server = []
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore" }
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
async-trait = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
//! HAP service type and characteristic enum catalogues.
|
||||
//!
|
||||
//! Mirrors the HAP-1.1 service/characteristic namespace used by Apple Home
|
||||
//! and the `hap` crate (https://crates.io/crates/hap). Keeping these as
|
||||
//! plain Rust enums in P1 avoids the heavy `hap` dep until P2.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// HAP service types exposed by the RuView bridge.
|
||||
///
|
||||
/// Derived from HomeKit Accessory Protocol Specification §8 (service
|
||||
/// definitions) and cross-checked against HA's `homekit` integration
|
||||
/// service catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HapAccessoryType {
|
||||
/// HAP `Lightbulb` service — maps `light.*` entities.
|
||||
Lightbulb,
|
||||
/// HAP `Switch` service — maps generic boolean `switch.*` entities.
|
||||
Switch,
|
||||
/// HAP `OccupancySensor` — maps presence / occupancy binary sensors.
|
||||
OccupancySensor,
|
||||
/// HAP `MotionSensor` — maps motion binary sensors + RuView motion.
|
||||
MotionSensor,
|
||||
/// HAP `TemperatureSensor` — maps `sensor.*temperature*` entities.
|
||||
TemperatureSensor,
|
||||
/// HAP `HumiditySensor` — maps `sensor.*humidity*` entities.
|
||||
HumiditySensor,
|
||||
/// HAP `LeakSensor` — maps abnormal event sensors; used for fall detection
|
||||
/// following HA's homekit_controller convention (HAP §11.42).
|
||||
LeakSensor,
|
||||
/// HAP `ContactSensor` — maps door / window binary sensors.
|
||||
ContactSensor,
|
||||
/// HAP `Door` service — maps `cover.*door*` entities.
|
||||
Door,
|
||||
/// HAP `LockMechanism` service — maps `lock.*` entities.
|
||||
Lock,
|
||||
/// HAP `SecuritySystem` service — maps alarm / security panel entities.
|
||||
SecuritySystem,
|
||||
}
|
||||
|
||||
impl HapAccessoryType {
|
||||
/// All defined variants — used in tests and for UI enumeration.
|
||||
pub const ALL: &'static [HapAccessoryType] = &[
|
||||
HapAccessoryType::Lightbulb,
|
||||
HapAccessoryType::Switch,
|
||||
HapAccessoryType::OccupancySensor,
|
||||
HapAccessoryType::MotionSensor,
|
||||
HapAccessoryType::TemperatureSensor,
|
||||
HapAccessoryType::HumiditySensor,
|
||||
HapAccessoryType::LeakSensor,
|
||||
HapAccessoryType::ContactSensor,
|
||||
HapAccessoryType::Door,
|
||||
HapAccessoryType::Lock,
|
||||
HapAccessoryType::SecuritySystem,
|
||||
];
|
||||
}
|
||||
|
||||
/// HAP characteristic identifiers that the bridge reads or writes.
|
||||
///
|
||||
/// Each variant corresponds to one HAP characteristic UUID as specified in
|
||||
/// HomeKit Accessory Protocol Specification §9.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HapCharacteristic {
|
||||
/// `On` (bool) — Lightbulb / Switch power state.
|
||||
On,
|
||||
/// `Brightness` (uint8, 0–100) — Lightbulb brightness percentage.
|
||||
Brightness,
|
||||
/// `CurrentTemperature` (float, °C) — TemperatureSensor reading.
|
||||
CurrentTemperature,
|
||||
/// `CurrentRelativeHumidity` (float, %) — HumiditySensor reading.
|
||||
CurrentRelativeHumidity,
|
||||
/// `OccupancyDetected` (uint8, 0=not detected, 1=detected).
|
||||
OccupancyDetected,
|
||||
/// `MotionDetected` (bool).
|
||||
MotionDetected,
|
||||
/// `LeakDetected` (uint8, 0=no leak, 1=leak detected). Re-used for falls.
|
||||
LeakDetected,
|
||||
/// `ContactSensorState` (uint8, 0=in contact, 1=not in contact).
|
||||
ContactSensorState,
|
||||
/// `CurrentDoorState` (uint8, HAP §9.30).
|
||||
CurrentDoorState,
|
||||
/// `LockCurrentState` (uint8, HAP §9.56).
|
||||
LockCurrentState,
|
||||
/// `SecuritySystemCurrentState` (uint8, HAP §9.97).
|
||||
SecuritySystemCurrentState,
|
||||
}
|
||||
|
||||
/// Typed value carried by a HAP characteristic update.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum HapCharacteristicValue {
|
||||
Bool(bool),
|
||||
UInt8(u8),
|
||||
Float(f64),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_11_accessory_types_defined() {
|
||||
assert_eq!(HapAccessoryType::ALL.len(), 11);
|
||||
// Spot-check each variant is present.
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Lightbulb));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Switch));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::OccupancySensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::MotionSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::TemperatureSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::HumiditySensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::LeakSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::ContactSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Door));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Lock));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::SecuritySystem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn characteristic_value_roundtrip_serde() {
|
||||
let v = HapCharacteristicValue::Float(22.5);
|
||||
let json = serde_json::to_string(&v).unwrap();
|
||||
let back: HapCharacteristicValue = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(v, back);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
//! `HapBridge` — owns the set of HOMECORE entities exposed as HAP accessories.
|
||||
//!
|
||||
//! P1 does not start a real HAP-1.1 server; it ships the API surface so other
|
||||
//! crates (and P2's `hap-server` feature) can register accessories and query
|
||||
//! their current mapping. The actual mDNS + HAP pairing is gated to P2.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use homecore::entity::EntityId;
|
||||
|
||||
use crate::accessory::HapAccessoryType;
|
||||
use crate::error::HapError;
|
||||
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
|
||||
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
||||
|
||||
/// One registered HAP accessory — an entity + its last-known mapping.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExposedAccessory {
|
||||
pub entity_id: EntityId,
|
||||
pub accessory_type: HapAccessoryType,
|
||||
pub mapping: AccessoryMapping,
|
||||
}
|
||||
|
||||
struct BridgeInner {
|
||||
accessories: HashMap<EntityId, ExposedAccessory>,
|
||||
}
|
||||
|
||||
/// The P1 HAP bridge.
|
||||
///
|
||||
/// Call [`HapBridge::add_accessory`] to register entities and
|
||||
/// [`HapBridge::running_accessories`] to read back what is currently
|
||||
/// registered. In P2, `start()` will spawn the `hap` server task.
|
||||
#[derive(Clone)]
|
||||
pub struct HapBridge {
|
||||
inner: Arc<RwLock<BridgeInner>>,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
pub service_record: HapServiceRecord,
|
||||
}
|
||||
|
||||
impl HapBridge {
|
||||
/// Create a bridge with the given service record and a `NullAdvertiser`
|
||||
/// (P1 default — real mDNS lands in P2).
|
||||
pub fn new(service_record: HapServiceRecord) -> Self {
|
||||
Self::with_advertiser(service_record, Arc::new(NullAdvertiser))
|
||||
}
|
||||
|
||||
/// Create a bridge with a custom `MdnsAdvertiser` (used in tests and P2).
|
||||
pub fn with_advertiser(
|
||||
service_record: HapServiceRecord,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })),
|
||||
advertiser,
|
||||
service_record,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an entity as a HAP accessory.
|
||||
///
|
||||
/// The entity's current mapping is computed from `state`; call
|
||||
/// `update_accessory` on each `StateChanged` event to keep it fresh.
|
||||
///
|
||||
/// Returns `HapError::AlreadyRegistered` if the entity is already
|
||||
/// registered. Call `remove_accessory` first to replace it.
|
||||
pub fn add_accessory(
|
||||
&self,
|
||||
entity_id: &EntityId,
|
||||
state: &homecore::entity::State,
|
||||
) -> Result<(), HapError> {
|
||||
let mapping = EntityToAccessoryMapper::map(entity_id, state)?;
|
||||
let accessory_type = mapping.accessory_type;
|
||||
let exposed = ExposedAccessory {
|
||||
entity_id: entity_id.clone(),
|
||||
accessory_type,
|
||||
mapping,
|
||||
};
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if inner.accessories.contains_key(entity_id) {
|
||||
return Err(HapError::AlreadyRegistered(entity_id.as_str().to_owned()));
|
||||
}
|
||||
inner.accessories.insert(entity_id.clone(), exposed);
|
||||
tracing::debug!(entity = %entity_id, ?accessory_type, "HAP accessory registered");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a registered accessory.
|
||||
///
|
||||
/// Returns `HapError::EntityNotFound` if the entity was not registered.
|
||||
pub fn remove_accessory(&self, entity_id: &EntityId) -> Result<(), HapError> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if inner.accessories.remove(entity_id).is_none() {
|
||||
return Err(HapError::EntityNotFound(entity_id.as_str().to_owned()));
|
||||
}
|
||||
tracing::debug!(entity = %entity_id, "HAP accessory removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot all currently registered accessories.
|
||||
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
|
||||
self.inner.read().unwrap().accessories.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Number of registered accessories.
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().unwrap().accessories.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// P2 stub — will start the HAP-1.1 server + mDNS advertisement.
|
||||
/// In P1 this only fires the null advertiser.
|
||||
pub async fn start(&self) -> Result<(), HapError> {
|
||||
self.advertiser.advertise(&self.service_record).await?;
|
||||
tracing::info!(
|
||||
instance = %self.service_record.instance_name,
|
||||
port = self.service_record.port,
|
||||
"HapBridge started (P1 — no real HAP server; mDNS stub only)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Graceful shutdown — retracts mDNS advertisement.
|
||||
pub async fn stop(&self) -> Result<(), HapError> {
|
||||
self.advertiser.retract(&self.service_record.instance_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn make_bridge() -> HapBridge {
|
||||
HapBridge::new(HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(&format!("light.{name}")).unwrap();
|
||||
let attrs = serde_json::json!({"brightness": brightness});
|
||||
let s = State::new(eid.clone(), if on { "on" } else { "off" }, attrs, Context::default());
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_remove_roundtrip() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, s) = light_state("kitchen", true, 200);
|
||||
|
||||
assert!(bridge.is_empty());
|
||||
bridge.add_accessory(&eid, &s).unwrap();
|
||||
assert_eq!(bridge.len(), 1);
|
||||
|
||||
let acc = bridge.running_accessories();
|
||||
assert_eq!(acc.len(), 1);
|
||||
assert_eq!(acc[0].entity_id, eid);
|
||||
assert_eq!(acc[0].accessory_type, HapAccessoryType::Lightbulb);
|
||||
|
||||
bridge.remove_accessory(&eid).unwrap();
|
||||
assert!(bridge.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_duplicate_returns_error() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, s) = light_state("kitchen", true, 200);
|
||||
bridge.add_accessory(&eid, &s).unwrap();
|
||||
let err = bridge.add_accessory(&eid, &s).unwrap_err();
|
||||
assert!(matches!(err, HapError::AlreadyRegistered(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_returns_error() {
|
||||
let bridge = make_bridge();
|
||||
let eid = EntityId::parse("light.ghost").unwrap();
|
||||
let err = bridge.remove_accessory(&eid).unwrap_err();
|
||||
assert!(matches!(err, HapError::EntityNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_stop_with_null_advertiser() {
|
||||
let bridge = make_bridge();
|
||||
bridge.start().await.unwrap();
|
||||
bridge.stop().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//! Unified error type for `homecore-hap`.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the HAP bridge and its sub-components.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HapError {
|
||||
#[error("entity not found: {0}")]
|
||||
EntityNotFound(String),
|
||||
|
||||
#[error("entity {entity_id} cannot be mapped to a HAP accessory type: {reason}")]
|
||||
UnmappableEntity { entity_id: String, reason: String },
|
||||
|
||||
#[error("accessory already registered: {0}")]
|
||||
AlreadyRegistered(String),
|
||||
|
||||
#[error("mDNS advertiser error: {0}")]
|
||||
MdnsError(String),
|
||||
|
||||
#[error("bridge not running")]
|
||||
NotRunning,
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
//! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125).
|
||||
//!
|
||||
//! # P1 scope
|
||||
//!
|
||||
//! Ships the trait surface and type definitions needed to map HOMECORE entity
|
||||
//! states onto HAP accessory / characteristic values. The actual HAP-1.1 TLS
|
||||
//! server and real mDNS advertisement are gated behind the `hap-server`
|
||||
//! feature (P2). P1 ships `NullAdvertiser` (no-op) so the bridge compiles and
|
||||
//! all tests pass with `--no-default-features`.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
//! | Module | Purpose |
|
||||
//! |--------|---------|
|
||||
//! | [`accessory`] | HAP service / characteristic enum catalogue |
|
||||
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
|
||||
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
|
||||
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
|
||||
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
|
||||
//! | [`error`] | Unified `HapError` type |
|
||||
|
||||
pub mod accessory;
|
||||
pub mod bridge;
|
||||
pub mod error;
|
||||
pub mod mapping;
|
||||
pub mod mdns;
|
||||
pub mod ruview;
|
||||
|
||||
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
pub use bridge::{ExposedAccessory, HapBridge};
|
||||
pub use error::HapError;
|
||||
pub use mapping::EntityToAccessoryMapper;
|
||||
pub use mdns::{MdnsAdvertiser, NullAdvertiser};
|
||||
pub use ruview::RuViewToHapMapper;
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
//! HOMECORE entity → HAP accessory type + characteristic value mapping.
|
||||
//!
|
||||
//! Mirrors the HA `homekit` integration's mapping table
|
||||
//! (homeassistant/components/homekit/type_*.py) for the entity domains and
|
||||
//! device classes handled in P1.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Result of mapping one HOMECORE entity state to the HAP layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessoryMapping {
|
||||
/// HAP service type to advertise for this entity.
|
||||
pub accessory_type: HapAccessoryType,
|
||||
/// Characteristic key/value pairs to set on the HAP service.
|
||||
pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
|
||||
}
|
||||
|
||||
/// Maps a HOMECORE entity `(EntityId, State)` pair to a `HapAccessoryType`
|
||||
/// and its current characteristic values.
|
||||
///
|
||||
/// Rule table (mirrors HA homekit_controller mapping):
|
||||
///
|
||||
/// | Domain | device_class | HAP service |
|
||||
/// |--------|-------------|-------------|
|
||||
/// | `light` | — | Lightbulb |
|
||||
/// | `switch` | — | Switch |
|
||||
/// | `binary_sensor` | `occupancy` | OccupancySensor |
|
||||
/// | `binary_sensor` | `motion` | MotionSensor |
|
||||
/// | `binary_sensor` | `door` / `window` | ContactSensor |
|
||||
/// | `sensor` | — + unit=°C/°F | TemperatureSensor |
|
||||
/// | `sensor` | — + unit=% (humidity) | HumiditySensor |
|
||||
/// | `cover` (door) | — | Door |
|
||||
/// | `lock` | — | Lock |
|
||||
pub struct EntityToAccessoryMapper;
|
||||
|
||||
impl EntityToAccessoryMapper {
|
||||
/// Map a HOMECORE entity to its HAP representation.
|
||||
///
|
||||
/// Returns `HapError::UnmappableEntity` for domains that have no
|
||||
/// defined HAP mapping (e.g. `automation`, `input_boolean`).
|
||||
pub fn map(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
match entity_id.domain() {
|
||||
"light" => Self::map_light(state),
|
||||
"switch" => Self::map_switch(state),
|
||||
"binary_sensor" => Self::map_binary_sensor(entity_id, state),
|
||||
"sensor" => Self::map_sensor(entity_id, state),
|
||||
"cover" => Self::map_cover(state),
|
||||
"lock" => Self::map_lock(state),
|
||||
other => Err(HapError::UnmappableEntity {
|
||||
entity_id: entity_id.as_str().to_owned(),
|
||||
reason: format!("domain '{other}' has no HAP mapping in P1"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_light(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let on = state.state == "on";
|
||||
let mut chars = vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))];
|
||||
if let Some(b) = state.attributes.get("brightness").and_then(Value::as_u64) {
|
||||
chars.push((
|
||||
HapCharacteristic::Brightness,
|
||||
HapCharacteristicValue::UInt8(b.min(255) as u8),
|
||||
));
|
||||
}
|
||||
Ok(AccessoryMapping { accessory_type: HapAccessoryType::Lightbulb, characteristics: chars })
|
||||
}
|
||||
|
||||
fn map_switch(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let on = state.state == "on";
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Switch,
|
||||
characteristics: vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_binary_sensor(
|
||||
entity_id: &EntityId,
|
||||
state: &State,
|
||||
) -> Result<AccessoryMapping, HapError> {
|
||||
let detected = state.state == "on";
|
||||
let device_class = state
|
||||
.attributes
|
||||
.get("device_class")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
// Also check name heuristics for device_class-less entities.
|
||||
let name = entity_id.name();
|
||||
let is_occupancy = device_class == "occupancy" || name.contains("occupancy") || name.contains("presence");
|
||||
let is_motion = device_class == "motion" || name.contains("motion");
|
||||
let is_door = device_class == "door" || device_class == "window";
|
||||
|
||||
if is_occupancy {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::OccupancySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_motion {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(detected),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_door {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::ContactSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::ContactSensorState,
|
||||
HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
}
|
||||
// Fallback: treat as motion sensor
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(detected),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_sensor(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let unit = state
|
||||
.attributes
|
||||
.get("unit_of_measurement")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let name = entity_id.name();
|
||||
|
||||
let is_temp = unit == "°C" || unit == "°F" || unit == "C" || unit == "F"
|
||||
|| name.contains("temp") || name.contains("temperature");
|
||||
let is_humidity = unit == "%" && (name.contains("humid") || name.contains("rh"));
|
||||
|
||||
if is_temp {
|
||||
let temp: f64 = state.state.parse().unwrap_or(0.0);
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::TemperatureSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(temp),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_humidity {
|
||||
let hum: f64 = state.state.parse().unwrap_or(0.0);
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::HumiditySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentRelativeHumidity,
|
||||
HapCharacteristicValue::Float(hum),
|
||||
)],
|
||||
});
|
||||
}
|
||||
Err(HapError::UnmappableEntity {
|
||||
entity_id: entity_id.as_str().to_owned(),
|
||||
reason: "sensor unit/name not recognised as temperature or humidity".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_cover(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let door_state: u8 = match state.state.as_str() {
|
||||
"open" => 0,
|
||||
"opening" => 2,
|
||||
"closing" => 3,
|
||||
_ => 1, // closed
|
||||
};
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Door,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentDoorState,
|
||||
HapCharacteristicValue::UInt8(door_state),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_lock(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let lock_state: u8 = match state.state.as_str() {
|
||||
"unlocked" => 0,
|
||||
"locked" => 1,
|
||||
_ => 3, // unknown
|
||||
};
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Lock,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::LockCurrentState,
|
||||
HapCharacteristicValue::UInt8(lock_state),
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn state(id: &str, st: &str, attrs: serde_json::Value) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(id).unwrap();
|
||||
let s = State::new(eid.clone(), st, attrs, Context::default());
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_kitchen_on_with_brightness() {
|
||||
let (eid, s) = state(
|
||||
"light.kitchen",
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::Lightbulb);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::On,
|
||||
HapCharacteristicValue::Bool(true)
|
||||
)));
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::Brightness,
|
||||
HapCharacteristicValue::UInt8(200)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sensor_occupancy_device_class() {
|
||||
let (eid, s) = state(
|
||||
"binary_sensor.kitchen_presence",
|
||||
"on",
|
||||
serde_json::json!({"device_class": "occupancy"}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::OccupancySensor);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensor_outdoor_temp_celsius() {
|
||||
let (eid, s) = state(
|
||||
"sensor.outdoor_temp",
|
||||
"21.5",
|
||||
serde_json::json!({"unit_of_measurement": "°C"}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::TemperatureSensor);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(21.5)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmappable_domain_returns_error() {
|
||||
let (eid, s) = state("automation.morning", "on", serde_json::json!({}));
|
||||
assert!(EntityToAccessoryMapper::map(&eid, &s).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
//! mDNS advertisement trait and P1 no-op stub.
|
||||
//!
|
||||
//! Real mDNS via the `mdns-sd` crate (https://crates.io/crates/mdns-sd)
|
||||
//! lands in P2 behind the `hap-server` feature flag. P1 ships `NullAdvertiser`
|
||||
//! so the bridge compiles and tests pass without any mDNS infrastructure.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Service record advertised over mDNS for HAP discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HapServiceRecord {
|
||||
/// Service instance name shown in Apple Home ("RuView Sense").
|
||||
pub instance_name: String,
|
||||
/// TCP port the HAP server listens on (default 51826).
|
||||
pub port: u16,
|
||||
/// HAP pairing setup code (8 digits, formatted as XXX-XX-XXX).
|
||||
pub setup_code: String,
|
||||
/// Unique device ID (colon-separated MAC-like hex, required by HAP §5.4).
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
/// Advertise (and retract) a HAP accessory over mDNS (`_hap._tcp`).
|
||||
///
|
||||
/// Implementors register the `_hap._tcp` service so HomePod / Apple TV can
|
||||
/// discover the bridge and initiate pairing. P1 provides only `NullAdvertiser`.
|
||||
#[async_trait]
|
||||
pub trait MdnsAdvertiser: Send + Sync {
|
||||
/// Begin advertising the service. Idempotent.
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
|
||||
|
||||
/// Stop advertising. Called on bridge shutdown.
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
|
||||
}
|
||||
|
||||
/// No-op advertiser for P1 / test environments.
|
||||
///
|
||||
/// All calls succeed without touching the network.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct NullAdvertiser;
|
||||
|
||||
#[async_trait]
|
||||
impl MdnsAdvertiser for NullAdvertiser {
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %record.instance_name,
|
||||
port = record.port,
|
||||
"NullAdvertiser: skipping mDNS advertisement (P1 stub)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %instance_name,
|
||||
"NullAdvertiser: skipping mDNS retract (P1 stub)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_advertiser_is_noop() {
|
||||
let adv = NullAdvertiser;
|
||||
let rec = HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
};
|
||||
adv.advertise(&rec).await.unwrap();
|
||||
adv.retract(&rec.instance_name).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
//! RuView sensing primitives → HAP characteristic mapping (ADR-125 §2.1.d).
|
||||
//!
|
||||
//! Per ADR-125, RuView's privacy-class-2/3 events map to HomeKit primitives
|
||||
//! as semantic ambient signals, not surveillance events:
|
||||
//!
|
||||
//! | RuView primitive | HAP service | Rationale |
|
||||
//! |-----------------|-------------|-----------|
|
||||
//! | `edge_vitals.presence` | OccupancySensor | Anonymous presence = occupancy |
|
||||
//! | `edge_vitals.motion` | MotionSensor | Motion burst |
|
||||
//! | `edge_vitals.fall_detected` | LeakSensor | HA convention: abnormal events |
|
||||
//! | `edge_vitals.breathing_present` | OccupancySensor | Sleep-room occupancy |
|
||||
//!
|
||||
//! Raw `identity_risk_score`, `rf_signature_hash`, and class-0 BFI data are
|
||||
//! **never** mapped. Structural invariant I1 (ADR-118 §2.2) is enforced here.
|
||||
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::mapping::AccessoryMapping;
|
||||
|
||||
/// Parsed RuView edge vitals event from the sensing-server.
|
||||
///
|
||||
/// All fields are class-2 (Anonymous) or class-3 (Restricted) derived signals.
|
||||
/// Raw BFI / `identity_risk_score` / `rf_signature_hash` are intentionally
|
||||
/// absent — they must not cross the HAP boundary per ADR-125 §2.2.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EdgeVitals {
|
||||
/// True if at least one person is present in the sensing zone.
|
||||
pub presence: bool,
|
||||
/// True if motion was detected in the last sensing window.
|
||||
pub motion: bool,
|
||||
/// True if a fall event was detected (latched, 5 s cooldown).
|
||||
pub fall_detected: bool,
|
||||
/// True if rhythmic breathing is detected (sleep-room occupancy signal).
|
||||
pub breathing_present: bool,
|
||||
/// Optional ambient temperature reading (°C), forwarded if available
|
||||
/// from a co-located temperature sensor.
|
||||
pub ambient_temp_c: Option<f64>,
|
||||
}
|
||||
|
||||
/// Maps `EdgeVitals` to a `Vec<AccessoryMapping>` — one per RuView primitive
|
||||
/// that should be exposed as a distinct HAP service (child accessory).
|
||||
pub struct RuViewToHapMapper;
|
||||
|
||||
impl RuViewToHapMapper {
|
||||
/// Convert a `EdgeVitals` snapshot to HAP accessory mappings.
|
||||
///
|
||||
/// Always returns mappings for presence, motion, and fall; the ambient
|
||||
/// temperature mapping is only emitted when `ambient_temp_c` is `Some`.
|
||||
pub fn map(vitals: &EdgeVitals) -> Vec<AccessoryMapping> {
|
||||
let mut out = Vec::with_capacity(4);
|
||||
|
||||
// Presence → OccupancySensor
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::OccupancySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(if vitals.presence || vitals.breathing_present { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
|
||||
// Motion → MotionSensor
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(vitals.motion),
|
||||
)],
|
||||
});
|
||||
|
||||
// Fall detected → LeakSensor (HA homekit_controller convention for
|
||||
// "abnormal event" — not a literal water leak, but an automation-
|
||||
// triggerable threshold event, per ADR-125 §2.1.d).
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::LeakSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::LeakDetected,
|
||||
HapCharacteristicValue::UInt8(if vitals.fall_detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
|
||||
// Optional temperature
|
||||
if let Some(temp) = vitals.ambient_temp_c {
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::TemperatureSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(temp),
|
||||
)],
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
|
||||
#[test]
|
||||
fn presence_true_maps_to_occupancy_detected_1() {
|
||||
let vitals = EdgeVitals { presence: true, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let occ = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::OccupancySensor).unwrap();
|
||||
assert!(occ.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_detected_maps_to_leak_sensor() {
|
||||
let vitals = EdgeVitals { fall_detected: true, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let leak = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::LeakSensor).unwrap();
|
||||
assert!(leak.characteristics.contains(&(
|
||||
HapCharacteristic::LeakDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_false_maps_correctly() {
|
||||
let vitals = EdgeVitals { motion: false, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let mot = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::MotionSensor).unwrap();
|
||||
assert!(mot.characteristics.contains(&(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(false)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_temp_emits_temperature_mapping() {
|
||||
let vitals = EdgeVitals { ambient_temp_c: Some(22.5), ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let temp = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::TemperatureSensor);
|
||||
assert!(temp.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ambient_temp_omits_temperature_mapping() {
|
||||
let vitals = EdgeVitals { ambient_temp_c: None, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
assert!(mappings.iter().all(|m| m.accessory_type != HapAccessoryType::TemperatureSensor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breathing_present_triggers_occupancy() {
|
||||
let vitals = EdgeVitals { presence: false, breathing_present: true, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let occ = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::OccupancySensor).unwrap();
|
||||
assert!(occ.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
# Implements ADR-134 (HOMECORE-MIGRATE), P1 scaffold:
|
||||
# - HaStorageDir + HaStorageEnvelope: reads `.storage/*.json` files
|
||||
# - Versioned format parsers under `storage_format::v<N>`
|
||||
# - entity_registry, device_registry, config_entries parsers
|
||||
# - secrets.yaml + automations.yaml parsers
|
||||
# - CLI: `homecore-migrate inspect` / `homecore-migrate import-entities`
|
||||
#
|
||||
# P2 will add homecore-recorder side-by-side DB export (feature-gated).
|
||||
|
||||
[package]
|
||||
name = "homecore-migrate"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Migration tooling from Python Home Assistant to HOMECORE (ADR-134 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[[bin]]
|
||||
name = "homecore-migrate"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "homecore_migrate"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: enable when homecore-recorder ships (ADR-132). Exports side-by-side DB.
|
||||
recorder = []
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE state machine — local path (ADR-127).
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime.
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Serialisation — JSON for .storage files, YAML for secrets/automations.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Error handling.
|
||||
thiserror = "1"
|
||||
|
||||
# Tracing/logging.
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
# CLI argument parsing.
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Error handling in main.rs
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
tempfile = "3"
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
//! Parser for `automations.yaml`.
|
||||
//!
|
||||
//! P1: reads the YAML, validates the top-level structure, and emits a count
|
||||
//! plus the list of automation IDs/aliases.
|
||||
//!
|
||||
//! Conversion to `homecore-automation` YAML format is deferred to P2.
|
||||
//!
|
||||
//! HA `automations.yaml` is a YAML sequence of automation objects:
|
||||
//!
|
||||
//! ```yaml
|
||||
//! - id: '1620000000001'
|
||||
//! alias: "Turn on lights at sunset"
|
||||
//! trigger: [...]
|
||||
//! condition: []
|
||||
//! action: [...]
|
||||
//! - id: '1620000000002'
|
||||
//! alias: "Turn off lights at midnight"
|
||||
//! trigger: [...]
|
||||
//! action: [...]
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::MigrateError;
|
||||
|
||||
/// Diagnostic summary of `automations.yaml`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutomationsSummary {
|
||||
pub count: usize,
|
||||
/// `(id, alias)` pairs. `id` defaults to an empty string if absent.
|
||||
pub automations: Vec<AutomationIdent>,
|
||||
}
|
||||
|
||||
/// Minimal identifying info for a single automation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutomationIdent {
|
||||
pub id: String,
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaAutomationRow {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
alias: Option<String>,
|
||||
// All other fields (trigger, condition, action, mode, etc.) ignored in P1.
|
||||
#[allow(dead_code)]
|
||||
#[serde(flatten)]
|
||||
_rest: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Read `automations.yaml` from `path` and return a summary.
|
||||
pub fn read_automations(path: &Path) -> Result<AutomationsSummary, MigrateError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| MigrateError::Io {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(AutomationsSummary { count: 0, automations: vec![] });
|
||||
}
|
||||
|
||||
let rows: Vec<HaAutomationRow> =
|
||||
serde_yaml::from_str(&raw).map_err(|e| MigrateError::YamlParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let automations = rows
|
||||
.iter()
|
||||
.map(|r| AutomationIdent { id: r.id.clone(), alias: r.alias.clone() })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(AutomationsSummary { count: rows.len(), automations })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const FIXTURE: &str = r#"
|
||||
- id: '1620000000001'
|
||||
alias: "Turn on lights at sunset"
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunset
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
|
||||
- id: '1620000000002'
|
||||
alias: "Turn off lights at midnight"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "00:00:00"
|
||||
action:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: all
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_automation_count_and_ids() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(FIXTURE.as_bytes()).unwrap();
|
||||
let summary = read_automations(f.path()).unwrap();
|
||||
assert_eq!(summary.count, 2);
|
||||
assert_eq!(summary.automations.len(), 2);
|
||||
assert_eq!(summary.automations[0].id, "1620000000001");
|
||||
assert_eq!(
|
||||
summary.automations[0].alias.as_deref(),
|
||||
Some("Turn on lights at sunset")
|
||||
);
|
||||
assert_eq!(summary.automations[1].id, "1620000000002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_automations_returns_zero_count() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(b"").unwrap();
|
||||
let summary = read_automations(f.path()).unwrap();
|
||||
assert_eq!(summary.count, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
//! CLI argument types for `homecore-migrate`.
|
||||
//!
|
||||
//! Shared between `src/main.rs` and integration tests. The `clap`-derived
|
||||
//! `Cli` struct is the entry-point; `Command` is the subcommand enum.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// homecore-migrate — migrate from Python Home Assistant to HOMECORE.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "homecore-migrate", version, about)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Inspect what is in the HA .storage directory and flag unsupported versions.
|
||||
Inspect(InspectArgs),
|
||||
/// Import entity registry from HA into a HOMECORE storage directory.
|
||||
ImportEntities(ImportEntitiesArgs),
|
||||
/// Import device registry (P1: parses and reports; wiring to HOMECORE P2).
|
||||
ImportDevices(ImportDevicesArgs),
|
||||
/// Inspect config entries (P1: count + domain list; conversion is P2).
|
||||
InspectConfigEntries(InspectConfigEntriesArgs),
|
||||
/// Parse secrets.yaml and report secret names (values redacted).
|
||||
InspectSecrets(InspectSecretsArgs),
|
||||
/// Count and list automations from automations.yaml (conversion is P2).
|
||||
InspectAutomations(InspectAutomationsArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ImportEntitiesArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
/// Path to the HOMECORE storage directory (destination).
|
||||
#[arg(long)]
|
||||
pub to: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ImportDevicesArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectConfigEntriesArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectSecretsArgs {
|
||||
/// Path to the HA config directory (contains `secrets.yaml`).
|
||||
#[arg(long)]
|
||||
pub config_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectAutomationsArgs {
|
||||
/// Path to the HA config directory (contains `automations.yaml`).
|
||||
#[arg(long)]
|
||||
pub config_dir: PathBuf,
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies).
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5, `.storage/core.config_entries` format is undocumented
|
||||
//! and version-gated. P1 reads the envelope and emits:
|
||||
//! - count of config entries
|
||||
//! - list of integration domains represented
|
||||
//!
|
||||
//! Conversion to HOMECORE plugin manifests is P2.
|
||||
//!
|
||||
//! Note: `config_entries` uses a different `minor_version` track from
|
||||
//! `entity_registry`. As of HA 2025.1 it is typically minor_version=1 or 2.
|
||||
//! We accept any minor_version ≤ MAX_SUPPORTED_MINOR and hard-error above it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{storage::read_envelope, MigrateError};
|
||||
|
||||
/// Maximum `minor_version` we claim to understand for config_entries.
|
||||
const MAX_SUPPORTED_MINOR: u32 = 4;
|
||||
|
||||
/// Diagnostic summary produced by P1 inspection.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ConfigEntriesSummary {
|
||||
pub count: usize,
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Minimal fields we read from each config-entry row.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaConfigEntryRow {
|
||||
domain: String,
|
||||
#[allow(dead_code)]
|
||||
entry_id: String,
|
||||
/// Title shown in HA UI (informational only in P1).
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
title: Option<String>,
|
||||
/// Source of the entry: "user" | "discovery" | "import" etc.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
source: Option<String>,
|
||||
/// State: "loaded" | "setup_error" etc.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaConfigEntriesData {
|
||||
entries: Vec<HaConfigEntryRow>,
|
||||
}
|
||||
|
||||
/// Read `core.config_entries` from `path` and return a diagnostic summary.
|
||||
pub fn inspect_config_entries(path: &Path) -> Result<ConfigEntriesSummary, MigrateError> {
|
||||
let env = read_envelope(path)?;
|
||||
let file_str = path.display().to_string();
|
||||
|
||||
// config_entries has version=1 and minor_version in 1..MAX_SUPPORTED_MINOR.
|
||||
if env.version != 1 || env.minor_version > MAX_SUPPORTED_MINOR {
|
||||
return Err(MigrateError::UnsupportedSchemaVersion {
|
||||
file: file_str.clone(),
|
||||
version: env.version,
|
||||
minor_version: env.minor_version,
|
||||
});
|
||||
}
|
||||
|
||||
let data: HaConfigEntriesData =
|
||||
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
|
||||
path: file_str,
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut domains: Vec<String> = data.entries.iter().map(|e| e.domain.clone()).collect();
|
||||
domains.sort();
|
||||
domains.dedup();
|
||||
|
||||
Ok(ConfigEntriesSummary {
|
||||
count: data.entries.len(),
|
||||
domains,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.config_entries",
|
||||
"data": {
|
||||
"entries": [
|
||||
{"domain": "hue", "entry_id": "ce_001", "title": "Philips Hue", "source": "user", "state": "loaded"},
|
||||
{"domain": "zha", "entry_id": "ce_002", "title": "ZHA", "source": "user", "state": "loaded"},
|
||||
{"domain": "hue", "entry_id": "ce_003", "title": "Hue 2", "source": "user", "state": "setup_error"}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn inspect_emits_count_and_domains() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(FIXTURE.as_bytes()).unwrap();
|
||||
let summary = inspect_config_entries(f.path()).unwrap();
|
||||
assert_eq!(summary.count, 3);
|
||||
assert_eq!(summary.domains, vec!["hue", "zha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_minor_version_hard_errors() {
|
||||
let json = r#"{
|
||||
"version": 1, "minor_version": 99,
|
||||
"key": "core.config_entries",
|
||||
"data": {"entries": []}
|
||||
}"#;
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(json.as_bytes()).unwrap();
|
||||
let err = inspect_config_entries(f.path()).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
//! Parser for `core.device_registry` (HA storage schema v1, minor_version 1–13).
|
||||
//!
|
||||
//! P1: deserializes the envelope and returns `Vec<DeviceImport>`.
|
||||
//! HOMECORE's device registry isn't fully wired yet (ADR-127 §2.5 deferred
|
||||
//! to P2), so `DeviceImport` is a staging type for the future hand-off.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
|
||||
|
||||
/// Staging type for a device imported from HA. Not yet wired to HOMECORE's
|
||||
/// device registry (ADR-127 §2.5 — deferred to P2).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceImport {
|
||||
pub id: String,
|
||||
pub config_entries: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub manufacturer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// `identifiers` — list of `[integration, id]` pairs. Preserved as raw
|
||||
/// JSON for P2 consumption; not yet mapped to HOMECORE DeviceEntry.
|
||||
#[serde(default)]
|
||||
pub identifiers: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub connections: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub via_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaDeviceRegistryData {
|
||||
devices: Vec<DeviceImport>,
|
||||
/// Deleted device tombstones — ignored in P1.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
deleted_devices: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Read `core.device_registry` from `path` and return the raw import list.
|
||||
pub fn read_device_registry(path: &Path) -> Result<Vec<DeviceImport>, MigrateError> {
|
||||
let env = read_envelope(path)?;
|
||||
let file_str = path.display().to_string();
|
||||
v13::require_supported(&file_str, env.version, env.minor_version)?;
|
||||
|
||||
let data: HaDeviceRegistryData =
|
||||
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
|
||||
path: file_str,
|
||||
source: e,
|
||||
})?;
|
||||
Ok(data.devices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 13,
|
||||
"key": "core.device_registry",
|
||||
"data": {
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev_abc",
|
||||
"config_entries": ["ce_001"],
|
||||
"manufacturer": "Philips",
|
||||
"model": "Hue Bridge",
|
||||
"name": "Philips Hue Bridge",
|
||||
"identifiers": [["hue", "001788FFFE3D4B13"]],
|
||||
"connections": [["mac", "00:17:88:ff:fe:3d:4b:13"]],
|
||||
"via_device_id": null,
|
||||
"area_id": null
|
||||
}
|
||||
],
|
||||
"deleted_devices": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_device_registry() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(FIXTURE.as_bytes()).unwrap();
|
||||
let devices = read_device_registry(f.path()).unwrap();
|
||||
assert_eq!(devices.len(), 1);
|
||||
let d = &devices[0];
|
||||
assert_eq!(d.id, "dev_abc");
|
||||
assert_eq!(d.manufacturer.as_deref(), Some("Philips"));
|
||||
assert_eq!(d.identifiers, vec![vec!["hue", "001788FFFE3D4B13"]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
//! Parser for `core.entity_registry` (HA storage schema v1, minor_version 1–13).
|
||||
//!
|
||||
//! Reads the `.storage/core.entity_registry` file and converts it into a
|
||||
//! `Vec<homecore::EntityEntry>` that can be loaded directly into the HOMECORE
|
||||
//! in-memory entity registry.
|
||||
//!
|
||||
//! Schema as of HA 2025.1 (minor_version=13):
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 1, "minor_version": 13, "key": "core.entity_registry",
|
||||
//! "data": {
|
||||
//! "entities": [
|
||||
//! {
|
||||
//! "entity_id": "light.kitchen",
|
||||
//! "unique_id": "hue_lamp_42",
|
||||
//! "platform": "hue",
|
||||
//! "name": "Kitchen lamp",
|
||||
//! "disabled_by": null,
|
||||
//! "area_id": "kitchen",
|
||||
//! "device_id": "abc123",
|
||||
//! "entity_category": null,
|
||||
//! "config_entry_id": "ce_001"
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
|
||||
|
||||
use crate::{
|
||||
storage::read_envelope,
|
||||
storage_format::v13,
|
||||
MigrateError,
|
||||
};
|
||||
|
||||
// Key used by `inspect` subcommand when scanning the directory.
|
||||
#[allow(dead_code)]
|
||||
const FILE_KEY: &str = "core.entity_registry";
|
||||
|
||||
/// Raw HA entity registry data block (the `data` field in the envelope).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaEntityRegistryData {
|
||||
entities: Vec<HaEntityRow>,
|
||||
/// Deleted-entity tombstones (ignored in P1 — forwarded as Q5 note).
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
deleted_entities: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A single row from `data.entities`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct HaEntityRow {
|
||||
entity_id: String,
|
||||
#[serde(default)]
|
||||
unique_id: Option<String>,
|
||||
platform: String,
|
||||
/// User-set display name (separate from HA-integration default name).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
disabled_by: Option<HaDisabledBy>,
|
||||
#[serde(default)]
|
||||
area_id: Option<String>,
|
||||
#[serde(default)]
|
||||
device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
entity_category: Option<HaEntityCategory>,
|
||||
#[serde(default)]
|
||||
config_entry_id: Option<String>,
|
||||
// Fields present in v13 that we capture but do not yet map to HOMECORE.
|
||||
// Forwarded as Q5 items.
|
||||
#[serde(default)]
|
||||
hidden_by: Option<String>, // v13: "user" | "integration"
|
||||
#[serde(default)]
|
||||
has_entity_name: Option<bool>, // v13: HA naming convention flag
|
||||
#[serde(default)]
|
||||
original_name: Option<String>, // v13: integration-provided default name
|
||||
#[serde(default)]
|
||||
icon: Option<String>, // v13: mdi:xxx icon override
|
||||
#[serde(default)]
|
||||
original_icon: Option<String>, // v13: integration-provided icon
|
||||
#[serde(default)]
|
||||
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
|
||||
#[serde(default)]
|
||||
capabilities: Option<serde_json::Value>, // v13: integration-specific caps
|
||||
#[serde(default)]
|
||||
supported_features: Option<u64>, // v13: bitmask
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum HaDisabledBy {
|
||||
User,
|
||||
Integration,
|
||||
ConfigEntry,
|
||||
Device,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum HaEntityCategory {
|
||||
Config,
|
||||
Diagnostic,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn map_disabled_by(v: Option<HaDisabledBy>) -> Option<DisabledBy> {
|
||||
v.and_then(|d| match d {
|
||||
HaDisabledBy::User => Some(DisabledBy::User),
|
||||
HaDisabledBy::Integration => Some(DisabledBy::Integration),
|
||||
HaDisabledBy::ConfigEntry => Some(DisabledBy::ConfigEntry),
|
||||
HaDisabledBy::Device => Some(DisabledBy::Device),
|
||||
HaDisabledBy::Unknown => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_entity_category(v: Option<HaEntityCategory>) -> Option<EntityCategory> {
|
||||
v.and_then(|c| match c {
|
||||
HaEntityCategory::Config => Some(EntityCategory::Config),
|
||||
HaEntityCategory::Diagnostic => Some(EntityCategory::Diagnostic),
|
||||
HaEntityCategory::Unknown => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `core.entity_registry` from `path` and return HOMECORE entries.
|
||||
///
|
||||
/// Errors:
|
||||
/// - `MigrateError::Io` if the file cannot be read
|
||||
/// - `MigrateError::JsonParse` if the JSON is malformed
|
||||
/// - `MigrateError::UnsupportedSchemaVersion` if minor_version is not 1–13
|
||||
/// - `MigrateError::EntityId` if any `entity_id` string is invalid
|
||||
pub fn read_entity_registry(path: &Path) -> Result<Vec<EntityEntry>, MigrateError> {
|
||||
let env = read_envelope(path)?;
|
||||
let file_str = path.display().to_string();
|
||||
v13::require_supported(&file_str, env.version, env.minor_version)?;
|
||||
|
||||
let data: HaEntityRegistryData =
|
||||
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
|
||||
path: file_str.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut entries = Vec::with_capacity(data.entities.len());
|
||||
for row in data.entities {
|
||||
let entity_id = EntityId::parse(&row.entity_id)?;
|
||||
entries.push(EntityEntry {
|
||||
entity_id,
|
||||
unique_id: row.unique_id,
|
||||
platform: row.platform,
|
||||
name: row.name,
|
||||
disabled_by: map_disabled_by(row.disabled_by),
|
||||
area_id: row.area_id,
|
||||
device_id: row.device_id,
|
||||
entity_category: map_entity_category(row.entity_category),
|
||||
config_entry_id: row.config_entry_id,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn write_fixture(json: &str) -> NamedTempFile {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(json.as_bytes()).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
const FIXTURE_V13: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 13,
|
||||
"key": "core.entity_registry",
|
||||
"data": {
|
||||
"entities": [
|
||||
{
|
||||
"entity_id": "light.kitchen",
|
||||
"unique_id": "hue_lamp_42",
|
||||
"platform": "hue",
|
||||
"name": "Kitchen lamp",
|
||||
"disabled_by": null,
|
||||
"area_id": "kitchen",
|
||||
"device_id": "abc123",
|
||||
"entity_category": null,
|
||||
"config_entry_id": "ce_001"
|
||||
},
|
||||
{
|
||||
"entity_id": "sensor.bedroom_temperature",
|
||||
"unique_id": "zigbee_temp_01",
|
||||
"platform": "zha",
|
||||
"name": null,
|
||||
"disabled_by": "integration",
|
||||
"area_id": null,
|
||||
"device_id": "dev_02",
|
||||
"entity_category": "diagnostic",
|
||||
"config_entry_id": "ce_002",
|
||||
"hidden_by": null,
|
||||
"has_entity_name": true,
|
||||
"original_name": "Temperature",
|
||||
"aliases": ["room temp"],
|
||||
"supported_features": 0
|
||||
}
|
||||
],
|
||||
"deleted_entities": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_v13_entity_registry() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_fields_round_trip_correctly() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
let light = entries.iter().find(|e| e.entity_id.as_str() == "light.kitchen").unwrap();
|
||||
assert_eq!(light.unique_id.as_deref(), Some("hue_lamp_42"));
|
||||
assert_eq!(light.platform, "hue");
|
||||
assert_eq!(light.name.as_deref(), Some("Kitchen lamp"));
|
||||
assert!(light.disabled_by.is_none());
|
||||
assert_eq!(light.area_id.as_deref(), Some("kitchen"));
|
||||
assert_eq!(light.device_id.as_deref(), Some("abc123"));
|
||||
assert!(light.entity_category.is_none());
|
||||
assert_eq!(light.config_entry_id.as_deref(), Some("ce_001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_by_maps_to_homecore() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
let sensor = entries
|
||||
.iter()
|
||||
.find(|e| e.entity_id.as_str() == "sensor.bedroom_temperature")
|
||||
.unwrap();
|
||||
assert_eq!(sensor.disabled_by, Some(DisabledBy::Integration));
|
||||
assert_eq!(sensor.entity_category, Some(EntityCategory::Diagnostic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_minor_version_raises_error() {
|
||||
let json = r#"{
|
||||
"version": 1, "minor_version": 99,
|
||||
"key": "core.entity_registry",
|
||||
"data": {"entities": [], "deleted_entities": []}
|
||||
}"#;
|
||||
let f = write_fixture(json);
|
||||
let err = read_entity_registry(f.path()).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }),
|
||||
"got: {err}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("minor_version=99"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
//! homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
//!
|
||||
//! Implements [ADR-134](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
//! (referenced via ADR-126 §4, series map row ADR-134 HOMECORE-MIGRATE).
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! - [`storage`] — `HaStorageDir`, `HaStorageEnvelope`; `read_envelope(path)`
|
||||
//! - [`storage_format`] — versioned format parsers (`v13`); unknown minor_version → hard error
|
||||
//! - [`entity_registry`] — `core.entity_registry` → `Vec<homecore::EntityEntry>`
|
||||
//! - [`device_registry`] — `core.device_registry` → `Vec<DeviceImport>` (P1 stub)
|
||||
//! - [`config_entries`] — `core.config_entries` diagnostic (count + domain list; P2 converts)
|
||||
//! - [`secrets`] — `secrets.yaml` → `HashMap<String, String>`
|
||||
//! - [`automations`] — `automations.yaml` count + ID list (P2 converts)
|
||||
//! - [`cli`] — `clap`-derived subcommand types shared between `src/main.rs` and tests
|
||||
//!
|
||||
//! ## What is NOT here yet (deferred to P2+)
|
||||
//!
|
||||
//! - Conversion of `config_entries` to HOMECORE plugin manifests
|
||||
//! - Conversion of `automations.yaml` to `homecore-automation` YAML
|
||||
//! - Side-by-side runtime mode (requires `homecore-recorder`, ADR-132)
|
||||
//! - `!secret` reference resolution in non-secrets YAML files
|
||||
|
||||
pub mod automations;
|
||||
pub mod cli;
|
||||
pub mod config_entries;
|
||||
pub mod device_registry;
|
||||
pub mod entity_registry;
|
||||
pub mod secrets;
|
||||
pub mod storage;
|
||||
pub mod storage_format;
|
||||
|
||||
/// Crate-level error type. Each module exposes `MigrateError` variants.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MigrateError {
|
||||
#[error("I/O error reading {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("JSON parse error in {path}: {source}")]
|
||||
JsonParse {
|
||||
path: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error("YAML parse error in {path}: {source}")]
|
||||
YamlParse {
|
||||
path: String,
|
||||
#[source]
|
||||
source: serde_yaml::Error,
|
||||
},
|
||||
|
||||
/// Fired when the outer `{version, minor_version}` envelope version is
|
||||
/// known but the `minor_version` is not supported by any compiled parser.
|
||||
/// Per ADR-134 §6 Q5: hard error on unknown minor_version.
|
||||
#[error(
|
||||
"unsupported schema version in {file}: \
|
||||
version={version} minor_version={minor_version}. \
|
||||
Upgrade homecore-migrate or downgrade HA to a supported release."
|
||||
)]
|
||||
UnsupportedSchemaVersion {
|
||||
file: String,
|
||||
version: u32,
|
||||
minor_version: u32,
|
||||
},
|
||||
|
||||
#[error("missing required field '{field}' in {context}")]
|
||||
MissingField { field: String, context: String },
|
||||
|
||||
#[error("entity_id parse error: {0}")]
|
||||
EntityId(#[from] homecore::EntityIdError),
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
//! `homecore-migrate` binary — CLI entry point.
|
||||
|
||||
use clap::Parser;
|
||||
use homecore_migrate::cli::{Cli, Command};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Inspect(args) => {
|
||||
println!("Inspecting HA .storage directory: {}", args.storage.display());
|
||||
// Probe entity_registry
|
||||
let entity_path = args.storage.join("core.entity_registry");
|
||||
if entity_path.exists() {
|
||||
match homecore_migrate::entity_registry::read_entity_registry(&entity_path) {
|
||||
Ok(entries) => println!(" core.entity_registry: {} entities", entries.len()),
|
||||
Err(e) => println!(" core.entity_registry: ERROR — {e}"),
|
||||
}
|
||||
}
|
||||
// Probe device_registry
|
||||
let device_path = args.storage.join("core.device_registry");
|
||||
if device_path.exists() {
|
||||
match homecore_migrate::device_registry::read_device_registry(&device_path) {
|
||||
Ok(devices) => println!(" core.device_registry: {} devices", devices.len()),
|
||||
Err(e) => println!(" core.device_registry: ERROR — {e}"),
|
||||
}
|
||||
}
|
||||
// Probe config_entries
|
||||
let ce_path = args.storage.join("core.config_entries");
|
||||
if ce_path.exists() {
|
||||
match homecore_migrate::config_entries::inspect_config_entries(&ce_path) {
|
||||
Ok(s) => println!(
|
||||
" core.config_entries: {} entries, domains: {}",
|
||||
s.count,
|
||||
s.domains.join(", ")
|
||||
),
|
||||
Err(e) => println!(" core.config_entries: ERROR — {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Command::ImportEntities(args) => {
|
||||
let entity_path = args.storage.join("core.entity_registry");
|
||||
let entries =
|
||||
homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
|
||||
println!("Imported {} entity entries (P1: in-memory only)", entries.len());
|
||||
println!(" Destination: {} (P2 persistence)", args.to.display());
|
||||
for e in &entries {
|
||||
println!(
|
||||
" {} ({}{})",
|
||||
e.entity_id.as_str(),
|
||||
e.platform,
|
||||
if e.disabled_by.is_some() { " DISABLED" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Command::ImportDevices(args) => {
|
||||
let device_path = args.storage.join("core.device_registry");
|
||||
let devices =
|
||||
homecore_migrate::device_registry::read_device_registry(&device_path)?;
|
||||
println!("Parsed {} device entries (P1: staging only, wiring to HOMECORE is P2)", devices.len());
|
||||
}
|
||||
|
||||
Command::InspectConfigEntries(args) => {
|
||||
let ce_path = args.storage.join("core.config_entries");
|
||||
let summary =
|
||||
homecore_migrate::config_entries::inspect_config_entries(&ce_path)?;
|
||||
println!(
|
||||
"config_entries: {} total, domains: {}",
|
||||
summary.count,
|
||||
summary.domains.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Command::InspectSecrets(args) => {
|
||||
let secrets_path = args.config_dir.join("secrets.yaml");
|
||||
let secrets = homecore_migrate::secrets::read_secrets(&secrets_path)?;
|
||||
println!("{} secrets found:", secrets.len());
|
||||
let mut keys: Vec<_> = secrets.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
println!(" {} = <redacted>", k);
|
||||
}
|
||||
}
|
||||
|
||||
Command::InspectAutomations(args) => {
|
||||
let auto_path = args.config_dir.join("automations.yaml");
|
||||
let summary = homecore_migrate::automations::read_automations(&auto_path)?;
|
||||
println!("{} automations:", summary.count);
|
||||
for a in &summary.automations {
|
||||
println!(
|
||||
" id={} alias={}",
|
||||
a.id,
|
||||
a.alias.as_deref().unwrap_or("<unnamed>")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
//! Parser for HA `secrets.yaml`.
|
||||
//!
|
||||
//! `secrets.yaml` is a flat YAML key→value map at the root of the HA
|
||||
//! config directory (NOT inside `.storage/`). Example:
|
||||
//!
|
||||
//! ```yaml
|
||||
//! mqtt_password: hunter2
|
||||
//! latitude: 51.5074
|
||||
//! longitude: -0.1278
|
||||
//! ```
|
||||
//!
|
||||
//! Values are always strings in HA (even numeric-looking ones are quoted in
|
||||
//! practice). We parse all values as strings to avoid type-mismatch errors.
|
||||
//!
|
||||
//! `!secret <name>` reference resolution (i.e., checking that every secret
|
||||
//! referenced in other YAML files exists here) is deferred to P2.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::MigrateError;
|
||||
|
||||
/// Read `secrets.yaml` from `path` and return a `name → value` map.
|
||||
///
|
||||
/// Returns an empty map if the file is empty (HA allows that).
|
||||
pub fn read_secrets(path: &Path) -> Result<HashMap<String, String>, MigrateError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| MigrateError::Io {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&raw).map_err(|e| MigrateError::YamlParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let map = match parsed {
|
||||
serde_yaml::Value::Mapping(m) => m,
|
||||
_ => {
|
||||
return Err(MigrateError::MissingField {
|
||||
field: "<root mapping>".into(),
|
||||
context: path.display().to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let mut result = HashMap::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let key = match k {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
let value = match v {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
serde_yaml::Value::Number(n) => n.to_string(),
|
||||
serde_yaml::Value::Bool(b) => b.to_string(),
|
||||
serde_yaml::Value::Null => String::new(),
|
||||
other => serde_yaml::to_string(&other)
|
||||
.unwrap_or_else(|_| "<unparseable>".into())
|
||||
.trim()
|
||||
.to_string(),
|
||||
};
|
||||
result.insert(key, value);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_key_value_map() {
|
||||
let yaml = "mqtt_password: hunter2\nlatitude: 51.5074\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
let secrets = read_secrets(f.path()).unwrap();
|
||||
assert_eq!(secrets.get("mqtt_password").map(String::as_str), Some("hunter2"));
|
||||
assert_eq!(secrets.get("latitude").map(String::as_str), Some("51.5074"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_secrets_file_returns_empty_map() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(b"").unwrap();
|
||||
let secrets = read_secrets(f.path()).unwrap();
|
||||
assert!(secrets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_count_is_correct() {
|
||||
let yaml = "a: 1\nb: 2\nc: 3\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
let secrets = read_secrets(f.path()).unwrap();
|
||||
assert_eq!(secrets.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
//! HA `.storage/` directory abstraction and the outer storage envelope.
|
||||
//!
|
||||
//! Every file in `.storage/` shares the same outer JSON shape:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 1,
|
||||
//! "minor_version": 3,
|
||||
//! "key": "core.entity_registry",
|
||||
//! "data": { ... }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! `read_envelope` reads and validates this outer wrapper. The `data` field is
|
||||
//! left as `serde_json::Value` — version-specific parsers in `storage_format`
|
||||
//! are responsible for further deserialization.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::MigrateError;
|
||||
|
||||
/// Points to a HA `.storage/` directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HaStorageDir {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl HaStorageDir {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// Returns the full path to a named storage file.
|
||||
pub fn file_path(&self, name: &str) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The outer JSON envelope that wraps every HA `.storage/*.json` file.
|
||||
/// Source: `homeassistant/helpers/storage.py` `Store._write_data`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HaStorageEnvelope {
|
||||
pub version: u32,
|
||||
/// Introduced in HA 2022.x for backwards-compatible schema additions.
|
||||
#[serde(default)]
|
||||
pub minor_version: u32,
|
||||
pub key: String,
|
||||
/// Inner payload. Parsed by versioned format-specific code.
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Read and deserialize a `.storage/*.json` envelope from `path`.
|
||||
///
|
||||
/// Returns `MigrateError::Io` if the file cannot be read, or
|
||||
/// `MigrateError::JsonParse` if the JSON is malformed.
|
||||
pub fn read_envelope(path: &Path) -> Result<HaStorageEnvelope, MigrateError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| MigrateError::Io {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_json::from_str(&raw).map_err(|e| MigrateError::JsonParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WELL_FORMED: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 3,
|
||||
"key": "core.entity_registry",
|
||||
"data": {"entities": []}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn envelope_parses_well_formed() {
|
||||
let env: HaStorageEnvelope = serde_json::from_str(WELL_FORMED).unwrap();
|
||||
assert_eq!(env.version, 1);
|
||||
assert_eq!(env.minor_version, 3);
|
||||
assert_eq!(env.key, "core.entity_registry");
|
||||
assert!(env.data.get("entities").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_missing_minor_version_defaults_to_zero() {
|
||||
let json = r#"{"version": 1, "key": "core.config_entries", "data": {}}"#;
|
||||
let env: HaStorageEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(env.minor_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_malformed_json() {
|
||||
let result = serde_json::from_str::<HaStorageEnvelope>("not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
//! Versioned format parsers for HA `.storage/` files.
|
||||
//!
|
||||
//! Each sub-module handles one `(version, minor_version)` generation of a
|
||||
//! particular storage key. Adding support for a new HA schema version means
|
||||
//! adding a new `v<N>.rs` module; the dispatch function in each parser module
|
||||
//! routes to the right implementation.
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5: unknown `minor_version` values produce a hard
|
||||
//! `MigrateError::UnsupportedSchemaVersion` — we do NOT silently fall back
|
||||
//! to an older parser, because schema changes can be load-bearing (new fields,
|
||||
//! renamed keys, semantic reinterpretations).
|
||||
|
||||
pub mod v13;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
//! Versioned format parser for HA storage schema version 13.
|
||||
//!
|
||||
//! Applies to (as of HA 2025.1):
|
||||
//! - `core.entity_registry` — `version=1, minor_version=13`
|
||||
//! - `core.device_registry` — `version=1, minor_version=13`
|
||||
//!
|
||||
//! Source: `homeassistant/helpers/entity_registry.py` `STORAGE_VERSION_MINOR`
|
||||
//! and `homeassistant/helpers/device_registry.py` `STORAGE_VERSION_MINOR`.
|
||||
//!
|
||||
//! `core.config_entries` uses a different versioning scheme; see
|
||||
//! `config_entries.rs` for details.
|
||||
|
||||
/// The major storage `version` this module handles.
|
||||
pub const MAJOR_VERSION: u32 = 1;
|
||||
|
||||
/// The `minor_version` values this module handles.
|
||||
/// Any value outside this set raises `MigrateError::UnsupportedSchemaVersion`.
|
||||
pub const SUPPORTED_MINOR_VERSIONS: &[u32] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
||||
|
||||
/// Return `true` if the given envelope header is handled by this module.
|
||||
pub fn handles(version: u32, minor_version: u32) -> bool {
|
||||
version == MAJOR_VERSION && SUPPORTED_MINOR_VERSIONS.contains(&minor_version)
|
||||
}
|
||||
|
||||
/// Validate that `(version, minor_version)` is supported; return the error
|
||||
/// with the given `file` path embedded if not.
|
||||
///
|
||||
/// Call this at the top of every parser that routes through v13 before
|
||||
/// attempting any field access.
|
||||
pub fn require_supported(
|
||||
file: &str,
|
||||
version: u32,
|
||||
minor_version: u32,
|
||||
) -> Result<(), crate::MigrateError> {
|
||||
if !handles(version, minor_version) {
|
||||
return Err(crate::MigrateError::UnsupportedSchemaVersion {
|
||||
file: file.to_owned(),
|
||||
version,
|
||||
minor_version,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn handles_all_supported_minor_versions() {
|
||||
for &mv in SUPPORTED_MINOR_VERSIONS {
|
||||
assert!(handles(1, mv), "minor_version {mv} should be supported");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_minor_version() {
|
||||
assert!(!handles(1, 99));
|
||||
assert!(!handles(2, 13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_supported_ok_for_v13() {
|
||||
assert!(require_supported("core.entity_registry", 1, 13).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_supported_err_carries_file_name() {
|
||||
let err = require_supported("core.entity_registry", 1, 99).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("core.entity_registry"),
|
||||
"error should contain file name: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("minor_version=99"),
|
||||
"error should contain minor_version: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "homecore-plugin-example"
|
||||
version = "0.1.0-alpha.0"
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# homecore-plugin-example — example WASM plugin proving the ADR-128 host ABI.
|
||||
#
|
||||
# This crate targets wasm32-unknown-unknown and compiles to a `.wasm` binary
|
||||
# that is loaded by the `homecore-plugins` integration test. It is NOT a
|
||||
# workspace member (excluded below) because wasm32 targets cannot participate
|
||||
# in a mixed host/device workspace `cargo test --workspace`.
|
||||
#
|
||||
# Build with:
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
# cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example
|
||||
#
|
||||
# The compiled binary lands at:
|
||||
# target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm
|
||||
|
||||
[package]
|
||||
name = "homecore-plugin-example"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Example WASM plugin for HOMECORE — proves the ADR-128 P2 host ABI (guest side)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
# Compile as a dynamic library so the WASM host can `Module::new` the bytes.
|
||||
[lib]
|
||||
name = "homecore_plugin_example"
|
||||
crate-type = ["cdylib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# No external dependencies — the plugin uses only std + manual JSON parsing.
|
||||
# Real plugins would pull in serde/serde_json for complex payloads.
|
||||
|
||||
[profile.release]
|
||||
# Minimise binary size for WASM.
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# homecore-plugin-example
|
||||
|
||||
Example WASM plugin for the HOMECORE plugin system (ADR-128 P2).
|
||||
|
||||
Demonstrates the complete ADR-128 host ABI round-trip:
|
||||
|
||||
- `plugin_setup` — subscribes to `sensor.test_temp` state changes
|
||||
- `plugin_handle_state_changed` — sets `binary_sensor.test_alert` to `on` when temp > 25, `off` when temp < 20
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
# Ensure the wasm32 target is installed (once)
|
||||
rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Build the example plugin (from this directory)
|
||||
cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example
|
||||
```
|
||||
|
||||
Output: `target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm`
|
||||
|
||||
## Run the integration test
|
||||
|
||||
```sh
|
||||
# From v2/
|
||||
cargo test -p homecore-plugins --features wasmtime
|
||||
```
|
||||
|
||||
## ABI
|
||||
|
||||
See `homecore-plugins/src/host_abi.rs` for the authoritative host ABI spec.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
//! Guest-side ABI helpers — matching `homecore-plugins/src/host_abi.rs`.
|
||||
//!
|
||||
//! # Memory model
|
||||
//!
|
||||
//! The host allocates into the guest's linear memory via the exported
|
||||
//! `alloc` / `dealloc` functions. The guest calls host imports with
|
||||
//! (ptr: i32, len: i32) pairs pointing into its own linear memory.
|
||||
//!
|
||||
//! # Allocator
|
||||
//!
|
||||
//! A simple bump allocator backed by a static mutable pointer. Suitable
|
||||
//! only for the WASM guest context where the host drives all allocations
|
||||
//! and deallocations synchronously (no concurrency inside a WASM module).
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! All host↔guest transfers use **UTF-8 JSON** (see host_abi.rs §Wire types).
|
||||
//! Maximum buffer: 65,536 bytes.
|
||||
|
||||
/// Maximum ABI buffer size — mirrors `MAX_ABI_BUFFER_BYTES` on the host.
|
||||
pub const MAX_ABI_BUFFER_BYTES: usize = 65_536;
|
||||
|
||||
// ── Bump allocator ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Start of heap area (bump pointer). Placed after the 64 KiB stack.
|
||||
static mut BUMP: usize = 0x1_0000; // 64 KiB
|
||||
|
||||
/// Allocate `size` bytes from the bump heap. Returns the pointer.
|
||||
///
|
||||
/// # Safety
|
||||
/// The caller must not write past `ptr + size`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn alloc(size: i32) -> i32 {
|
||||
if size <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let size = size as usize;
|
||||
// Align to 8 bytes.
|
||||
let aligned = (BUMP + 7) & !7;
|
||||
BUMP = aligned + size;
|
||||
aligned as i32
|
||||
}
|
||||
|
||||
/// Deallocate a buffer. No-op for the bump allocator — caller is the host,
|
||||
/// which drives the alloc/dealloc lifecycle and calls this after each call.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dealloc(_ptr: i32, _size: i32) {
|
||||
// Bump allocator: no-op. For a real plugin, replace with a proper allocator.
|
||||
}
|
||||
|
||||
// ── Host import declarations ───────────────────────────────────────────────
|
||||
|
||||
extern "C" {
|
||||
/// Read the current state for an entity. See host_abi.rs §hc_state_get.
|
||||
/// Returns bytes written into `out_ptr`, or -1 (not found), -2 (too small).
|
||||
pub fn hc_state_get(
|
||||
key_ptr: i32,
|
||||
key_len: i32,
|
||||
out_ptr: i32,
|
||||
out_cap: i32,
|
||||
) -> i32;
|
||||
|
||||
/// Write state for an entity. Returns 0 on success, negative on error.
|
||||
pub fn hc_state_set(
|
||||
eid_ptr: i32,
|
||||
eid_len: i32,
|
||||
state_ptr: i32,
|
||||
state_len: i32,
|
||||
attrs_ptr: i32,
|
||||
attrs_len: i32,
|
||||
) -> i32;
|
||||
|
||||
/// Subscribe to state changes for an entity. Returns 0 on success.
|
||||
pub fn hc_state_subscribe(eid_ptr: i32, eid_len: i32) -> i32;
|
||||
|
||||
/// Log a message. level: 0=debug 1=info 2=warn 3=error.
|
||||
pub fn hc_log(level: i32, msg_ptr: i32, msg_len: i32);
|
||||
}
|
||||
|
||||
// ── ABI helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Write entity state via `hc_state_set`.
|
||||
///
|
||||
/// Returns the result of `hc_state_set` (0 = ok).
|
||||
///
|
||||
/// # Safety
|
||||
/// `entity_id`, `state`, and `attrs` must be valid UTF-8 strings.
|
||||
pub fn set_state(entity_id: &str, state: &str, attrs: &str) -> i32 {
|
||||
unsafe {
|
||||
hc_state_set(
|
||||
entity_id.as_ptr() as i32,
|
||||
entity_id.len() as i32,
|
||||
state.as_ptr() as i32,
|
||||
state.len() as i32,
|
||||
attrs.as_ptr() as i32,
|
||||
attrs.len() as i32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a log message at INFO level.
|
||||
pub fn log_info(msg: &str) {
|
||||
unsafe {
|
||||
hc_log(1, msg.as_ptr() as i32, msg.len() as i32);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
//! HOMECORE example WASM plugin — proves the ADR-128 P2 host ABI round-trip.
|
||||
//!
|
||||
//! # Behaviour
|
||||
//!
|
||||
//! This plugin monitors `sensor.test_temp` and controls
|
||||
//! `binary_sensor.test_alert` based on the temperature reading:
|
||||
//!
|
||||
//! - `sensor.test_temp` > 25 → set `binary_sensor.test_alert` to `"on"`
|
||||
//! - `sensor.test_temp` < 20 → set `binary_sensor.test_alert` to `"off"`
|
||||
//! - Between 20 and 25 → no change (hysteresis dead-band)
|
||||
//!
|
||||
//! # ABI
|
||||
//!
|
||||
//! The plugin is compiled to `wasm32-unknown-unknown` and exposes the three
|
||||
//! exports required by the HOMECORE host ABI (ADR-128 §5.2):
|
||||
//!
|
||||
//! | Export | Signature | Called when |
|
||||
//! |--------|-----------|-------------|
|
||||
//! | `plugin_setup` | `(ptr:i32, len:i32) → i32` | Config entry set up |
|
||||
//! | `plugin_handle_state_changed` | `(ptr:i32, len:i32) → i32` | State change event |
|
||||
//! | `alloc` | `(size:i32) → i32` | Host needs a guest buffer |
|
||||
//! | `dealloc` | `(ptr:i32, size:i32)` | Host frees a guest buffer |
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! All payloads are **UTF-8 JSON** delivered via length-prefixed linear
|
||||
//! memory pointers. See `abi.rs` for the guest-side helpers and
|
||||
//! `homecore-plugins/src/host_abi.rs` for the authoritative spec.
|
||||
|
||||
mod abi;
|
||||
|
||||
// Re-export alloc/dealloc so the host can find them.
|
||||
pub use abi::{alloc, dealloc};
|
||||
|
||||
// ── Entity IDs ─────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMP_SENSOR: &str = "sensor.test_temp";
|
||||
const ALERT_SENSOR: &str = "binary_sensor.test_alert";
|
||||
|
||||
// ── Thresholds ─────────────────────────────────────────────────────────────
|
||||
|
||||
const HIGH_THRESH: f64 = 25.0; // above → alert on
|
||||
const LOW_THRESH: f64 = 20.0; // below → alert off
|
||||
|
||||
// ── Plugin exports ──────────────────────────────────────────────────────────
|
||||
|
||||
/// `plugin_setup(config_entry_ptr: i32, config_entry_len: i32) → i32`
|
||||
///
|
||||
/// Called once by the host when the config entry is set up. Subscribes to
|
||||
/// `sensor.test_temp` state changes so the host will deliver them via
|
||||
/// `plugin_handle_state_changed`.
|
||||
///
|
||||
/// Returns 0 on success, negative on error.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn plugin_setup(_ptr: i32, _len: i32) -> i32 {
|
||||
// Subscribe to temperature sensor state changes.
|
||||
let sub_result = abi::hc_state_subscribe(
|
||||
TEMP_SENSOR.as_ptr() as i32,
|
||||
TEMP_SENSOR.len() as i32,
|
||||
);
|
||||
if sub_result != 0 {
|
||||
return -1;
|
||||
}
|
||||
abi::log_info("homecore-plugin-example: setup complete, subscribed to sensor.test_temp");
|
||||
0
|
||||
}
|
||||
|
||||
/// `plugin_handle_state_changed(event_ptr: i32, event_len: i32) → i32`
|
||||
///
|
||||
/// Called by the host whenever a subscribed entity changes state.
|
||||
/// The payload is a JSON object:
|
||||
/// `{"event_type":"state_changed","entity_id":"…","new_state":"…","attributes":{}}`
|
||||
///
|
||||
/// Returns 0 on success, negative on error.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn plugin_handle_state_changed(ptr: i32, len: i32) -> i32 {
|
||||
if len <= 0 || len as usize > abi::MAX_ABI_BUFFER_BYTES {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Read the event JSON from linear memory.
|
||||
let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
|
||||
let json_str = match std::str::from_utf8(slice) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return -2,
|
||||
};
|
||||
|
||||
// Parse the event JSON.
|
||||
let entity_id = extract_json_string(json_str, "entity_id");
|
||||
let new_state_raw = extract_json_string(json_str, "new_state");
|
||||
|
||||
// Only act on sensor.test_temp.
|
||||
match entity_id.as_deref() {
|
||||
Some(e) if e == TEMP_SENSOR => {}
|
||||
_ => return 0,
|
||||
};
|
||||
|
||||
let new_state = match new_state_raw {
|
||||
Some(s) => s,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
// Parse the temperature value.
|
||||
let temp: f64 = match new_state.parse::<f64>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return 0, // not a number — ignore
|
||||
};
|
||||
|
||||
// Apply threshold logic with hysteresis dead-band.
|
||||
if temp > HIGH_THRESH {
|
||||
abi::set_state(ALERT_SENSOR, "on", "{}");
|
||||
abi::log_info("homecore-plugin-example: temp > 25, alert ON");
|
||||
} else if temp < LOW_THRESH {
|
||||
abi::set_state(ALERT_SENSOR, "off", "{}");
|
||||
abi::log_info("homecore-plugin-example: temp < 20, alert OFF");
|
||||
}
|
||||
// Dead-band: 20 <= temp <= 25, no change.
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
// ── Minimal JSON field extraction ──────────────────────────────────────────
|
||||
|
||||
/// Extract a string value for `key` from a flat JSON object string.
|
||||
/// Returns `Some(value)` if found, `None` otherwise.
|
||||
/// Only handles simple `"key":"value"` pairs at the top level.
|
||||
fn extract_json_string(json: &str, key: &str) -> Option<String> {
|
||||
let needle = format!("\"{}\":\"", key);
|
||||
let start = json.find(&needle)? + needle.len();
|
||||
let rest = &json[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_owned())
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# HOMECORE-PLUGINS — WASM integration plugin system.
|
||||
# Implements ADR-128 (HOMECORE-PLUGINS), P1 scaffold:
|
||||
# - PluginManifest (serde-deserialised, superset of HA manifest.json)
|
||||
# - HomeCorePlugin async trait + PluginId + PluginError
|
||||
# - PluginRuntime trait + InProcessRuntime (native Rust, first-party plugins)
|
||||
# - PluginRegistry (load / unload / list)
|
||||
#
|
||||
# P2 will add the `wasmtime` feature (gated below, default-off) for the real
|
||||
# Wasmtime JIT sandbox. wasm3 interpretation mode lands behind `--features wasm3`
|
||||
# in P3 for constrained-hardware targets.
|
||||
|
||||
[package]
|
||||
name = "homecore-plugins"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "WASM integration plugin runtime for HOMECORE (ADR-128 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_plugins"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: real Wasmtime JIT sandbox (Cranelift; ~15 MB binary delta on Pi 5).
|
||||
# Do not enable in production until the host ABI is frozen (ADR-128 §8 risk).
|
||||
wasmtime = ["dep:wasmtime"]
|
||||
# P3: wasm3 interpretation mode for constrained hardware (~50 kB).
|
||||
wasm3 = ["dep:wasm3"]
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE state machine — local path (ADR-127).
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime — same version as workspace.
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
|
||||
# Async trait support for HomeCorePlugin.
|
||||
async-trait = "0.1"
|
||||
|
||||
# Error handling.
|
||||
thiserror = "1"
|
||||
|
||||
# Serialisation (manifest JSON + ABI call payloads).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# UUIDs for config entry IDs in host_abi.rs.
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
|
||||
# Bumped from 25.0.3 → 42 to remediate RUSTSEC-2026-0095 and RUSTSEC-2026-0096
|
||||
# (Cranelift/Winch sandbox-escape CVEs, CVSS 9.0 — iter-11 security sprint HC-03/04).
|
||||
wasmtime = { version = "42", optional = true }
|
||||
|
||||
# Optional wasm3 interpretation runtime (P3, default-off).
|
||||
wasm3 = { version = "0.3", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
# WAT text-format compiler for inline WASM unit tests (wasmtime feature only).
|
||||
wat = { version = "1", optional = false }
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
//! `PluginError` — typed error enum for the homecore-plugins crate.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the HOMECORE plugin system.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PluginError {
|
||||
/// The plugin manifest JSON is missing required fields or is malformed.
|
||||
#[error("invalid manifest: {0}")]
|
||||
InvalidManifest(String),
|
||||
|
||||
/// A plugin with this ID is already loaded in the registry.
|
||||
#[error("plugin already loaded: {0}")]
|
||||
AlreadyLoaded(String),
|
||||
|
||||
/// No plugin with this ID is loaded in the registry.
|
||||
#[error("plugin not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The plugin runtime failed to spawn or execute the plugin.
|
||||
#[error("runtime error: {0}")]
|
||||
RuntimeError(String),
|
||||
|
||||
/// The plugin's `setup` hook returned an error.
|
||||
#[error("plugin setup failed: {0}")]
|
||||
SetupFailed(String),
|
||||
|
||||
/// The plugin's `unload` hook returned an error.
|
||||
#[error("plugin unload failed: {0}")]
|
||||
UnloadFailed(String),
|
||||
|
||||
/// IO error (manifest file not found, WASM binary missing, etc.).
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
//! Host ABI — the public on-the-wire memory format between the HOMECORE host
|
||||
//! and every WASM plugin.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! HOMECORE uses **JSON over UTF-8 linear memory** for all host↔guest data.
|
||||
//! This matches HA's JSON-everywhere convention and makes call payloads
|
||||
//! inspectable in debuggers without a schema file. Each `hc_*` host function
|
||||
//! and each guest export uses the same pointer + length convention:
|
||||
//!
|
||||
//! ```text
|
||||
//! host calls alloc(size) → ptr (exported by guest)
|
||||
//! host writes UTF-8 bytes into guest linear memory at [ptr, ptr+size)
|
||||
//! host calls the guest export with (ptr: i32, len: i32)
|
||||
//! guest reads and JSON-decodes the slice
|
||||
//! guest writes its reply via hc_state_set / hc_log / etc. (host imports)
|
||||
//! host calls dealloc(ptr, size) when finished (exported by guest)
|
||||
//! ```
|
||||
//!
|
||||
//! # Wire types
|
||||
//!
|
||||
//! | Call | Direction | JSON schema |
|
||||
//! |------|-----------|-------------|
|
||||
//! | `hc_state_get` reply | host → caller | `{"entity_id":"…","state":"…","attributes":{…}}` or null bytes (not found) |
|
||||
//! | `hc_state_set` args | guest → host | `(entity_id, state, attrs)` as 3 separate ptr/len pairs; each is a UTF-8 string or JSON object |
|
||||
//! | `hc_log` args | guest → host | `(level: i32, msg)` where level 0=debug 1=info 2=warn 3=error |
|
||||
//! | `hc_state_subscribe` | guest → host | entity_id UTF-8 string |
|
||||
//! | `setup_entry` | host → guest | `{"entry_id":"…","domain":"…","data":{}}` (ConfigEntry JSON) |
|
||||
//! | `receive_event` | host → guest | `{"event_type":"state_changed","entity_id":"…","new_state":"…"}` |
|
||||
//!
|
||||
//! # Memory layout guarantees
|
||||
//!
|
||||
//! - Buffers are **always** valid UTF-8 (JSON subset).
|
||||
//! - Maximum buffer size is **64 KiB** (65,536 bytes). Larger payloads must
|
||||
//! be split by the caller; the host rejects oversized writes with a WASM
|
||||
//! trap. This bound is enforced in [`write_guest_buf`].
|
||||
//! - The host **never** holds a guest memory pointer across a WASM call
|
||||
//! boundary. Pointers are only valid for the duration of a single call.
|
||||
//!
|
||||
//! # `hc_state_subscribe` semantics
|
||||
//!
|
||||
//! A plugin calls `hc_state_subscribe(eid_ptr, eid_len)` once per entity it
|
||||
//! wants to track. Subsequent state changes for that entity arrive via a
|
||||
//! `receive_event` call with event_type `"state_changed"`.
|
||||
//!
|
||||
//! Subscriptions are held for the lifetime of the plugin instance.
|
||||
|
||||
/// Maximum number of bytes the host will write into a single guest buffer.
|
||||
/// Plugins may safely size their `alloc` buffers at this ceiling.
|
||||
pub const MAX_ABI_BUFFER_BYTES: usize = 65_536;
|
||||
|
||||
/// JSON payload passed to `setup_entry` when a config entry is set up.
|
||||
///
|
||||
/// Serialises to HA-compat `ConfigEntry` JSON.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConfigEntryJson {
|
||||
pub entry_id: String,
|
||||
pub domain: String,
|
||||
pub title: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ConfigEntryJson {
|
||||
/// Construct a minimal config entry for test / bootstrap use.
|
||||
pub fn bootstrap(domain: &str) -> Self {
|
||||
Self {
|
||||
entry_id: uuid::Uuid::new_v4().to_string(),
|
||||
domain: domain.to_owned(),
|
||||
title: domain.to_owned(),
|
||||
data: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON payload for `receive_event` — `state_changed` variant.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StateChangedEventJson {
|
||||
pub event_type: String,
|
||||
pub entity_id: String,
|
||||
pub new_state: Option<String>,
|
||||
pub attributes: serde_json::Value,
|
||||
}
|
||||
|
||||
impl StateChangedEventJson {
|
||||
/// Construct a `state_changed` event payload.
|
||||
pub fn state_changed(
|
||||
entity_id: &str,
|
||||
new_state: Option<&str>,
|
||||
attributes: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
event_type: "state_changed".to_owned(),
|
||||
entity_id: entity_id.to_owned(),
|
||||
new_state: new_state.map(str::to_owned),
|
||||
attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log levels for `hc_log`.
|
||||
#[repr(i32)]
|
||||
pub enum LogLevel {
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warn = 2,
|
||||
Error = 3,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
/// Convert from the i32 wire value. Unknown values map to `Warn`.
|
||||
pub fn from_i32(n: i32) -> Self {
|
||||
match n {
|
||||
0 => LogLevel::Debug,
|
||||
1 => LogLevel::Info,
|
||||
3 => LogLevel::Error,
|
||||
_ => LogLevel::Warn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LogLevel::Debug => "DEBUG",
|
||||
LogLevel::Info => "INFO",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
//! HOMECORE-PLUGINS — WASM integration plugin system.
|
||||
//!
|
||||
//! Implements [ADR-128](../../docs/adr/ADR-128-homecore-integration-plugin-system.md)
|
||||
//! P1 scaffold: manifest parsing, the `HomeCorePlugin` async trait, the
|
||||
//! `PluginRuntime` abstraction, and the `PluginRegistry`.
|
||||
//!
|
||||
//! ## What's here (P1)
|
||||
//!
|
||||
//! - [`manifest`] — `PluginManifest`: superset of HA `manifest.json`; serde
|
||||
//! round-trip + required-field validation.
|
||||
//! - [`plugin`] — `HomeCorePlugin` async trait, `PluginId` newtype.
|
||||
//! - [`runtime`] — `PluginRuntime` trait + `InProcessRuntime` (native Rust,
|
||||
//! first-party plugins compiled into the binary).
|
||||
//! - [`registry`] — `PluginRegistry<R>`: load / unload / list plugins.
|
||||
//! - [`error`] — `PluginError` typed error enum.
|
||||
//!
|
||||
//! ## What's NOT here yet (deferred)
|
||||
//!
|
||||
//! - `WasmtimeRuntime` (P2, `--features wasmtime`): Cranelift JIT sandbox on
|
||||
//! Pi 5 / x86_64. The runtime-selection question (Wasmtime vs wasm3) is still
|
||||
//! open (ADR-128 §8) and will be resolved in Q2 before P2 begins.
|
||||
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
|
||||
//! (P2 — requires ADR-127 state machine API freeze first).
|
||||
//! - Config entry lifecycle + hot-load (P3).
|
||||
//! - Cog registry distribution + Ed25519 signature verification (P4).
|
||||
//! - Permission enforcement (P5).
|
||||
//!
|
||||
//! ## Feature flags
|
||||
//!
|
||||
//! | Feature | Default | Description |
|
||||
//! |---------|---------|-------------|
|
||||
//! | `wasmtime` | off | Wasmtime Cranelift JIT runtime (P2) |
|
||||
//! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) |
|
||||
|
||||
pub mod error;
|
||||
pub mod host_abi;
|
||||
pub mod manifest;
|
||||
pub mod plugin;
|
||||
pub mod registry;
|
||||
pub mod runtime;
|
||||
|
||||
#[cfg(feature = "wasmtime")]
|
||||
pub mod wasmtime_runtime;
|
||||
|
||||
pub use error::PluginError;
|
||||
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
|
||||
pub use manifest::{IotClass, IntegrationType, PluginManifest};
|
||||
pub use plugin::{HomeCorePlugin, PluginId};
|
||||
pub use registry::PluginRegistry;
|
||||
pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime};
|
||||
|
||||
#[cfg(feature = "wasmtime")]
|
||||
pub use wasmtime_runtime::{WasmPlugin, WasmtimeRuntime};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
//! Plugin manifest — superset of HA's `manifest.json`.
|
||||
//!
|
||||
//! See ADR-128 §3 for the full field list. Fields present in HA's schema
|
||||
//! are preserved verbatim. HOMECORE-specific fields are marked `[HOMECORE]`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::PluginError;
|
||||
|
||||
/// Coarse-grained permission claim string (glob pattern).
|
||||
/// Example: `"state:write:sensor.*"`.
|
||||
pub type PermissionClaim = String;
|
||||
|
||||
/// HA `iot_class` values (non-exhaustive — HA adds new classes over time).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IotClass {
|
||||
LocalPush,
|
||||
LocalPolling,
|
||||
CloudPush,
|
||||
CloudPolling,
|
||||
AssumedState,
|
||||
Calculated,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
/// HOMECORE integration type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntegrationType {
|
||||
Integration,
|
||||
Helper,
|
||||
Entity,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Parsed and validated plugin manifest.
|
||||
///
|
||||
/// Serialises to/from HA-compatible `manifest.json`. HOMECORE-only fields
|
||||
/// are `Option<…>` so that a plain HA manifest is a valid (native-only)
|
||||
/// HOMECORE manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
/// Unique integration domain identifier (e.g. `"mqtt"`).
|
||||
pub domain: String,
|
||||
|
||||
/// Human-readable integration name.
|
||||
pub name: String,
|
||||
|
||||
/// SemVer-ish version string (HA uses calendar-versioning, e.g. `"2025.1.0"`).
|
||||
pub version: String,
|
||||
|
||||
/// Optional documentation URL.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub documentation: Option<String>,
|
||||
|
||||
/// HA `iot_class` — how the integration communicates with the device.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub iot_class: Option<IotClass>,
|
||||
|
||||
/// Whether this integration ships a UI config flow.
|
||||
#[serde(default)]
|
||||
pub config_flow: bool,
|
||||
|
||||
/// HOMECORE integration type (optional, defaults to Integration).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub integration_type: Option<IntegrationType>,
|
||||
|
||||
/// Intra-HOMECORE dependencies (other plugin domains this one requires).
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
/// External package requirements — kept for schema compat, ignored in HOMECORE
|
||||
/// (WASM modules carry their own static deps, no pip).
|
||||
#[serde(default)]
|
||||
pub requirements: Vec<String>,
|
||||
|
||||
// ── [HOMECORE] fields ──────────────────────────────────────────────────
|
||||
|
||||
/// [HOMECORE] Relative path to the `.wasm` binary (absent for native plugins).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wasm_module: Option<String>,
|
||||
|
||||
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary; verified before execution.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wasm_module_hash: Option<String>,
|
||||
|
||||
/// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wasm_module_sig: Option<String>,
|
||||
|
||||
/// [HOMECORE] Ed25519 public key of the plugin publisher.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publisher_key: Option<String>,
|
||||
|
||||
/// [HOMECORE] Minimum HOMECORE version required by this plugin.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min_homecore_version: Option<String>,
|
||||
|
||||
/// [HOMECORE] Subset of host functions the WASM module imports.
|
||||
#[serde(default)]
|
||||
pub host_imports_required: Vec<String>,
|
||||
|
||||
/// [HOMECORE] Coarse-grained permission claims (glob patterns).
|
||||
#[serde(default)]
|
||||
pub homecore_permissions: Vec<PermissionClaim>,
|
||||
|
||||
/// [HOMECORE] Seed app registry cog ID for distribution.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cog_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
/// Parse a `manifest.json` JSON string and validate required fields.
|
||||
///
|
||||
/// Required fields: `domain`, `name`, `version`.
|
||||
pub fn parse_json(s: &str) -> Result<Self, PluginError> {
|
||||
let m: Self = serde_json::from_str(s)
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
m.validate()?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), PluginError> {
|
||||
if self.domain.trim().is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"manifest `domain` must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"manifest `name` must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if self.version.trim().is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"manifest `version` must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
//! `HomeCorePlugin` trait + `PluginId` newtype.
|
||||
//!
|
||||
//! Every first-party and third-party HOMECORE integration must implement
|
||||
//! `HomeCorePlugin`. P1 provides an in-process native Rust implementation;
|
||||
//! the WASM ABI wrapper (which maps the WASM exports `setup_entry`,
|
||||
//! `call_service_handler`, `receive_event` to this trait) lands in P2.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::error::PluginError;
|
||||
|
||||
/// Unique identifier for a loaded plugin — mirrors the `domain` field of
|
||||
/// the plugin's `PluginManifest` (e.g. `"mqtt"`, `"homecore_lights"`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct PluginId(pub String);
|
||||
|
||||
impl PluginId {
|
||||
/// Create a new `PluginId` from any string-like value.
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
|
||||
/// Return the inner domain string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PluginId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle trait that every HOMECORE integration must implement.
|
||||
///
|
||||
/// Implementing types are passed to [`PluginRuntime::load`]; the runtime
|
||||
/// calls these methods at the appropriate lifecycle points.
|
||||
///
|
||||
/// # Async
|
||||
/// Both methods are `async` to allow network / IO initialisation without
|
||||
/// blocking the Tokio runtime. The `async_trait` macro erases the `impl`
|
||||
/// return type so it works in trait objects.
|
||||
#[async_trait]
|
||||
pub trait HomeCorePlugin: Send + Sync + 'static {
|
||||
/// Called once when the plugin's config entry is being set up.
|
||||
///
|
||||
/// The plugin receives a reference to the `HomeCore` runtime and should
|
||||
/// register its entities, services, and event subscriptions here.
|
||||
async fn setup(&self, hc: HomeCore) -> Result<(), PluginError>;
|
||||
|
||||
/// Called when the plugin is being removed from the registry.
|
||||
///
|
||||
/// The plugin should clean up subscriptions and deregister its entities.
|
||||
async fn unload(&self) -> Result<(), PluginError>;
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
//! `PluginRegistry` — load, unload, and list HOMECORE plugins.
|
||||
//!
|
||||
//! The registry is runtime-agnostic: it accepts any type that implements
|
||||
//! [`PluginRuntime`] and delegates load/unload to it. This allows swapping
|
||||
//! the `InProcessRuntime` (P1) for a `WasmtimeRuntime` (P2) without
|
||||
//! changing registry code.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::HomeCore;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||
use crate::runtime::{LoadedPlugin, PluginRuntime};
|
||||
|
||||
/// Holds all loaded plugins keyed by `PluginId`.
|
||||
///
|
||||
/// Thread-safe via `RwLock` — concurrent reads are cheap; writes (load /
|
||||
/// unload) take an exclusive lock only while mutating the map.
|
||||
pub struct PluginRegistry<R: PluginRuntime> {
|
||||
runtime: R,
|
||||
plugins: RwLock<HashMap<PluginId, LoadedPlugin>>,
|
||||
}
|
||||
|
||||
impl<R: PluginRuntime> PluginRegistry<R> {
|
||||
/// Create an empty registry backed by `runtime`.
|
||||
pub fn new(runtime: R) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
plugins: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a plugin, call its `setup` hook, and insert it into the registry.
|
||||
///
|
||||
/// Returns `PluginError::AlreadyLoaded` if a plugin with the same ID is
|
||||
/// already registered.
|
||||
pub async fn load(
|
||||
&self,
|
||||
manifest: PluginManifest,
|
||||
plugin: Arc<dyn HomeCorePlugin>,
|
||||
hc: HomeCore,
|
||||
) -> Result<PluginId, PluginError> {
|
||||
let id = PluginId::new(&manifest.domain);
|
||||
|
||||
{
|
||||
let guard = self.plugins.read().await;
|
||||
if guard.contains_key(&id) {
|
||||
return Err(PluginError::AlreadyLoaded(id.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let loaded = self
|
||||
.runtime
|
||||
.load(id.clone(), manifest, plugin)
|
||||
.await?;
|
||||
|
||||
loaded
|
||||
.setup(hc)
|
||||
.await
|
||||
.map_err(|e| PluginError::SetupFailed(e.to_string()))?;
|
||||
|
||||
self.plugins.write().await.insert(id.clone(), loaded);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Unload a plugin by ID, calling its `unload` hook first.
|
||||
///
|
||||
/// Returns `PluginError::NotFound` if the plugin was not loaded.
|
||||
pub async fn unload(&self, id: &PluginId) -> Result<(), PluginError> {
|
||||
let loaded = {
|
||||
let mut guard = self.plugins.write().await;
|
||||
guard
|
||||
.remove(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?
|
||||
};
|
||||
|
||||
loaded
|
||||
.unload()
|
||||
.await
|
||||
.map_err(|e| PluginError::UnloadFailed(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a snapshot of currently loaded plugin IDs and their manifest domains.
|
||||
pub async fn list(&self) -> Vec<(PluginId, String)> {
|
||||
let guard = self.plugins.read().await;
|
||||
guard
|
||||
.iter()
|
||||
.map(|(id, lp)| (id.clone(), lp.manifest.domain.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return `true` if a plugin with this ID is loaded.
|
||||
pub async fn contains(&self, id: &PluginId) -> bool {
|
||||
self.plugins.read().await.contains_key(id)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
//! `PluginRuntime` trait + `InProcessRuntime` (P1).
|
||||
//!
|
||||
//! Abstracts over Wasmtime (P2, `--features wasmtime`) and native in-process
|
||||
//! Rust plugins (P1, always-on). A third backend, wasm3 (P3), will provide
|
||||
//! interpretation mode for constrained hardware.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! PluginRegistry
|
||||
//! │
|
||||
//! ▼
|
||||
//! PluginRuntime ◄─── InProcessRuntime (P1, native Rust, <1 µs call)
|
||||
//! ◄─── WasmtimeRuntime (P2, Cranelift JIT, ~5 ms cold start)
|
||||
//! ◄─── Wasm3Runtime (P3, interpreter, ~50 kB, Pi Zero)
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||
|
||||
/// A loaded plugin handle — returned by [`PluginRuntime::load`].
|
||||
pub struct LoadedPlugin {
|
||||
pub id: PluginId,
|
||||
pub manifest: PluginManifest,
|
||||
/// Underlying plugin instance (boxed trait object).
|
||||
pub(crate) instance: Arc<dyn HomeCorePlugin>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
/// Delegate to the inner plugin's `setup` method.
|
||||
pub async fn setup(&self, hc: HomeCore) -> Result<(), PluginError> {
|
||||
self.instance.setup(hc).await
|
||||
}
|
||||
|
||||
/// Delegate to the inner plugin's `unload` method.
|
||||
pub async fn unload(&self) -> Result<(), PluginError> {
|
||||
self.instance.unload().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstraction over the WASM (and native) plugin execution environment.
|
||||
///
|
||||
/// P2 will supply a `WasmtimeRuntime` that compiles `.wasm` bytes with
|
||||
/// Cranelift; P3 adds a `Wasm3Runtime` for constrained targets. Both will
|
||||
/// implement this trait so the registry is runtime-agnostic.
|
||||
#[async_trait]
|
||||
pub trait PluginRuntime: Send + Sync + 'static {
|
||||
/// Load a plugin from a boxed [`HomeCorePlugin`] implementation and a
|
||||
/// parsed `PluginManifest`. Returns a `LoadedPlugin` handle.
|
||||
async fn load(
|
||||
&self,
|
||||
id: PluginId,
|
||||
manifest: PluginManifest,
|
||||
plugin: Arc<dyn HomeCorePlugin>,
|
||||
) -> Result<LoadedPlugin, PluginError>;
|
||||
}
|
||||
|
||||
/// Native in-process runtime — loads first-party Rust plugins directly.
|
||||
///
|
||||
/// No WASM compilation; no sandbox. Intended for first-party plugins
|
||||
/// (RuView MQTT bridge, presence sensor, etc.) that are compiled into the
|
||||
/// HOMECORE binary and therefore trusted. Third-party / community plugins
|
||||
/// must use the `WasmtimeRuntime` (P2) for isolation.
|
||||
pub struct InProcessRuntime;
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRuntime for InProcessRuntime {
|
||||
async fn load(
|
||||
&self,
|
||||
id: PluginId,
|
||||
manifest: PluginManifest,
|
||||
plugin: Arc<dyn HomeCorePlugin>,
|
||||
) -> Result<LoadedPlugin, PluginError> {
|
||||
Ok(LoadedPlugin {
|
||||
id,
|
||||
manifest,
|
||||
instance: plugin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature-gated Wasmtime implementation (P2) ───────────────────────────
|
||||
//
|
||||
// The full `WasmtimeRuntime` lives in `crate::wasmtime_runtime` (P2).
|
||||
// It is re-exported from `crate::lib` as `WasmtimeRuntime` when the
|
||||
// `wasmtime` feature is enabled. The `PluginRuntime` trait below is
|
||||
// kept intentionally narrow (in-process plugin contract) so the WASM
|
||||
// path can use its own `WasmPlugin` wrapper without forcing the trait
|
||||
// to carry WASM-specific concerns.
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
//! Unit tests for homecore-plugins P1 scaffold.
|
||||
//!
|
||||
//! Covers: manifest parse + round-trip, manifest field validation,
|
||||
//! PluginRegistry load/unload/list/duplicate, InProcessRuntime,
|
||||
//! and PluginError variants.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use homecore::HomeCore;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||
use crate::registry::PluginRegistry;
|
||||
use crate::runtime::InProcessRuntime;
|
||||
|
||||
// ── Test double ────────────────────────────────────────────────────────
|
||||
|
||||
/// Minimal plugin that records setup/unload calls.
|
||||
struct TestPlugin {
|
||||
pub setup_called: Mutex<bool>,
|
||||
pub unload_called: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl TestPlugin {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
setup_called: Mutex::new(false),
|
||||
unload_called: Mutex::new(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HomeCorePlugin for TestPlugin {
|
||||
async fn setup(&self, _hc: HomeCore) -> Result<(), PluginError> {
|
||||
*self.setup_called.lock().await = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unload(&self) -> Result<(), PluginError> {
|
||||
*self.unload_called.lock().await = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_manifest(domain: &str) -> PluginManifest {
|
||||
PluginManifest {
|
||||
domain: domain.into(),
|
||||
name: "Test Plugin".into(),
|
||||
version: "1.0.0".into(),
|
||||
documentation: None,
|
||||
iot_class: None,
|
||||
config_flow: false,
|
||||
integration_type: None,
|
||||
dependencies: vec![],
|
||||
requirements: vec![],
|
||||
wasm_module: None,
|
||||
wasm_module_hash: None,
|
||||
wasm_module_sig: None,
|
||||
publisher_key: None,
|
||||
min_homecore_version: None,
|
||||
host_imports_required: vec![],
|
||||
homecore_permissions: vec![],
|
||||
cog_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manifest tests ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn manifest_parse_round_trip() {
|
||||
let json = r#"{
|
||||
"domain": "mqtt",
|
||||
"name": "MQTT",
|
||||
"version": "2025.1.0",
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"requirements": [],
|
||||
"wasm_module": "mqtt.wasm",
|
||||
"homecore_permissions": ["state:write:sensor.*"]
|
||||
}"#;
|
||||
|
||||
let m = PluginManifest::parse_json(json).expect("should parse");
|
||||
assert_eq!(m.domain, "mqtt");
|
||||
assert_eq!(m.version, "2025.1.0");
|
||||
assert!(m.config_flow);
|
||||
assert_eq!(m.homecore_permissions, vec!["state:write:sensor.*"]);
|
||||
|
||||
// round-trip: serialize back to JSON and re-parse
|
||||
let serialised = serde_json::to_string(&m).expect("should serialise");
|
||||
let m2 = PluginManifest::parse_json(&serialised).expect("round-trip should parse");
|
||||
assert_eq!(m.domain, m2.domain);
|
||||
assert_eq!(m.version, m2.version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_empty_domain() {
|
||||
let json = r#"{"domain":"","name":"X","version":"1.0.0"}"#;
|
||||
let err = PluginManifest::parse_json(json).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("domain"),
|
||||
"error should mention domain: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_missing_domain() {
|
||||
let json = r#"{"name":"X","version":"1.0.0"}"#;
|
||||
// serde will fill domain as "" due to missing field → validation rejects
|
||||
let err = PluginManifest::parse_json(json).unwrap_err();
|
||||
// Either a serde error (missing field) or a validation error is acceptable
|
||||
let s = err.to_string();
|
||||
assert!(!s.is_empty(), "should produce a non-empty error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_empty_version() {
|
||||
let json = r#"{"domain":"lights","name":"Lights","version":""}"#;
|
||||
let err = PluginManifest::parse_json(json).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("version"),
|
||||
"error should mention version: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Registry + InProcessRuntime tests ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_load_and_list() {
|
||||
let hc = HomeCore::new();
|
||||
let registry = PluginRegistry::new(InProcessRuntime);
|
||||
let plugin = TestPlugin::new();
|
||||
let manifest = minimal_manifest("lights");
|
||||
|
||||
let id = registry
|
||||
.load(manifest, plugin.clone(), hc)
|
||||
.await
|
||||
.expect("load should succeed");
|
||||
|
||||
assert_eq!(id.as_str(), "lights");
|
||||
assert!(*plugin.setup_called.lock().await, "setup should have been called");
|
||||
|
||||
let listing = registry.list().await;
|
||||
assert_eq!(listing.len(), 1);
|
||||
assert_eq!(listing[0].0.as_str(), "lights");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_unload_removes_plugin() {
|
||||
let hc = HomeCore::new();
|
||||
let registry = PluginRegistry::new(InProcessRuntime);
|
||||
let plugin = TestPlugin::new();
|
||||
|
||||
let id = registry
|
||||
.load(minimal_manifest("switch"), plugin.clone(), hc)
|
||||
.await
|
||||
.expect("load should succeed");
|
||||
|
||||
registry.unload(&id).await.expect("unload should succeed");
|
||||
assert!(*plugin.unload_called.lock().await, "unload should have been called");
|
||||
assert_eq!(registry.list().await.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_rejects_duplicate_load() {
|
||||
let hc1 = HomeCore::new();
|
||||
let hc2 = HomeCore::new();
|
||||
let registry = PluginRegistry::new(InProcessRuntime);
|
||||
|
||||
registry
|
||||
.load(minimal_manifest("sensor"), TestPlugin::new(), hc1)
|
||||
.await
|
||||
.expect("first load should succeed");
|
||||
|
||||
let err = registry
|
||||
.load(minimal_manifest("sensor"), TestPlugin::new(), hc2)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(err, PluginError::AlreadyLoaded(_)),
|
||||
"expected AlreadyLoaded, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_unload_unknown_plugin_returns_not_found() {
|
||||
let registry = PluginRegistry::new(InProcessRuntime);
|
||||
let id = PluginId::new("nonexistent");
|
||||
let err = registry.unload(&id).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PluginError::NotFound(_)),
|
||||
"expected NotFound, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_process_runtime_setup_called() {
|
||||
let hc = HomeCore::new();
|
||||
let registry = PluginRegistry::new(InProcessRuntime);
|
||||
let plugin = TestPlugin::new();
|
||||
|
||||
registry
|
||||
.load(minimal_manifest("climate"), plugin.clone(), hc)
|
||||
.await
|
||||
.expect("load should succeed");
|
||||
|
||||
assert!(
|
||||
*plugin.setup_called.lock().await,
|
||||
"InProcessRuntime must call setup"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error display ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn error_display_variants() {
|
||||
let e1 = PluginError::AlreadyLoaded("mqtt".into());
|
||||
assert!(e1.to_string().contains("mqtt"));
|
||||
|
||||
let e2 = PluginError::NotFound("climate".into());
|
||||
assert!(e2.to_string().contains("climate"));
|
||||
|
||||
let e3 = PluginError::RuntimeError("boom".into());
|
||||
assert!(e3.to_string().contains("boom"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
//! `WasmtimeRuntime` — Cranelift JIT WASM plugin runtime (ADR-128 P2).
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! Each `.wasm` binary is compiled once per process by a shared [`Engine`].
|
||||
//! Every call to [`WasmtimeRuntime::load_wasm`] creates a new [`Store`] so
|
||||
//! plugins are fully isolated — one plugin cannot read another's linear memory.
|
||||
//!
|
||||
//! The 4 host imports the WASM module receives are registered via a [`Linker`]:
|
||||
//!
|
||||
//! | Import | Signature | Description |
|
||||
//! |--------|-----------|-------------|
|
||||
//! | `hc_state_get` | `(i32,i32,i32,i32)→i32` | Read entity state into guest buffer |
|
||||
//! | `hc_state_set` | `(i32,i32,i32,i32,i32,i32)→i32` | Write entity state from guest buffer |
|
||||
//! | `hc_state_subscribe` | `(i32,i32)→i32` | Subscribe to state-changed events |
|
||||
//! | `hc_log` | `(i32,i32,i32)→()` | Structured log output from plugin |
|
||||
//!
|
||||
//! WASI is **not** imported — plugins have no filesystem or network access.
|
||||
//!
|
||||
//! # Memory convention
|
||||
//!
|
||||
//! The guest exports `alloc(size: i32) → i32` and `dealloc(ptr: i32, size: i32)`.
|
||||
//! The host calls `alloc` before writing a buffer into guest memory, then calls
|
||||
//! `dealloc` when done. See [`host_abi`] for the full ABI spec.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use homecore::HomeCore;
|
||||
use wasmtime::{Engine, Linker, Module, Store};
|
||||
|
||||
use crate::error::PluginError;
|
||||
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
|
||||
|
||||
// ── Store data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-plugin state stored inside the Wasmtime [`Store`].
|
||||
///
|
||||
/// Wasmtime's `Store<T>` exposes `T` to host functions via `caller.data()`.
|
||||
/// We store the `HomeCore` handle and a list of subscribed entity IDs here.
|
||||
pub struct PluginStoreData {
|
||||
pub hc: HomeCore,
|
||||
pub subscriptions: Vec<String>,
|
||||
}
|
||||
|
||||
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
|
||||
|
||||
/// Wasmtime-backed WASM plugin runtime (Cranelift JIT on Pi 5 and x86_64).
|
||||
///
|
||||
/// One `Engine` is shared across all plugins for module caching. Each plugin
|
||||
/// gets its own isolated `Store`.
|
||||
pub struct WasmtimeRuntime {
|
||||
engine: Engine,
|
||||
}
|
||||
|
||||
impl WasmtimeRuntime {
|
||||
/// Create a new runtime with default Cranelift config.
|
||||
pub fn new() -> Result<Self, PluginError> {
|
||||
let engine = Engine::default();
|
||||
Ok(Self { engine })
|
||||
}
|
||||
|
||||
/// Compile and instantiate a WASM plugin from raw bytes.
|
||||
///
|
||||
/// Returns a [`WasmPlugin`] handle that owns the `Store` and the
|
||||
/// `Instance`. The handle can be used to call into the WASM module.
|
||||
pub fn load_wasm(
|
||||
&self,
|
||||
wasm_bytes: &[u8],
|
||||
hc: HomeCore,
|
||||
) -> Result<WasmPlugin, PluginError> {
|
||||
let module = Module::new(&self.engine, wasm_bytes)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?;
|
||||
|
||||
let mut linker: Linker<PluginStoreData> = Linker::new(&self.engine);
|
||||
register_host_imports(&mut linker)?;
|
||||
|
||||
let store_data = PluginStoreData {
|
||||
hc,
|
||||
subscriptions: Vec::new(),
|
||||
};
|
||||
let mut store = Store::new(&self.engine, store_data);
|
||||
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &module)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("WASM instantiate: {e}")))?;
|
||||
|
||||
Ok(WasmPlugin {
|
||||
inner: Arc::new(Mutex::new((store, instance))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmtimeRuntime {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("default Wasmtime engine should not fail")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Host import registration ───────────────────────────────────────────────
|
||||
|
||||
/// Register the 4 host imports every HOMECORE plugin can call.
|
||||
fn register_host_imports(
|
||||
linker: &mut Linker<PluginStoreData>,
|
||||
) -> Result<(), PluginError> {
|
||||
register_hc_state_get(linker)?;
|
||||
register_hc_state_set(linker)?;
|
||||
register_hc_state_subscribe(linker)?;
|
||||
register_hc_log(linker)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `hc_state_get(key_ptr: i32, key_len: i32, out_ptr: i32, out_cap: i32) → i32`
|
||||
///
|
||||
/// Reads the current state for the entity whose UTF-8 ID is in the guest
|
||||
/// buffer at `[key_ptr, key_ptr+key_len)`. Writes the JSON-encoded state
|
||||
/// into `[out_ptr, out_ptr+out_cap)`. Returns the number of bytes written,
|
||||
/// or -1 if the entity is not found, or -2 if `out_cap` is too small.
|
||||
fn register_hc_state_get(
|
||||
linker: &mut Linker<PluginStoreData>,
|
||||
) -> Result<(), PluginError> {
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"hc_state_get",
|
||||
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
|
||||
key_ptr: i32,
|
||||
key_len: i32,
|
||||
out_ptr: i32,
|
||||
out_cap: i32|
|
||||
-> i32 {
|
||||
// Phase 1: read the entity key from guest memory.
|
||||
let key: String = {
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(wasmtime::Extern::Memory(m)) => m,
|
||||
_ => return -1,
|
||||
};
|
||||
match read_str(mem.data(&caller), key_ptr, key_len) {
|
||||
Some(k) => k.to_owned(),
|
||||
None => return -1,
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: look up state and build JSON (no borrow on caller).
|
||||
let entity_id = match homecore::EntityId::parse(&key) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let json_bytes: Vec<u8> = {
|
||||
let state_arc = match caller.data().hc.states().get(&entity_id) {
|
||||
Some(s) => s,
|
||||
None => return -1,
|
||||
};
|
||||
match serde_json::to_vec(&*state_arc) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return -1,
|
||||
}
|
||||
};
|
||||
|
||||
if json_bytes.len() > out_cap as usize {
|
||||
return -2;
|
||||
}
|
||||
|
||||
// Phase 3: write JSON back into guest memory.
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(wasmtime::Extern::Memory(m)) => m,
|
||||
_ => return -1,
|
||||
};
|
||||
let end = out_ptr as usize + json_bytes.len();
|
||||
let out = match mem.data_mut(&mut caller).get_mut(out_ptr as usize..end) {
|
||||
Some(s) => s,
|
||||
None => return -1,
|
||||
};
|
||||
out.copy_from_slice(&json_bytes);
|
||||
json_bytes.len() as i32
|
||||
},
|
||||
)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("register hc_state_get: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `hc_state_set(eid_ptr,eid_len,state_ptr,state_len,attrs_ptr,attrs_len) → i32`
|
||||
///
|
||||
/// Sets the state for the entity whose UTF-8 ID is at `[eid_ptr,eid_ptr+eid_len)`.
|
||||
/// The new state string is at `[state_ptr,state_ptr+state_len)`.
|
||||
/// The attributes JSON is at `[attrs_ptr,attrs_ptr+attrs_len)`.
|
||||
/// Returns 0 on success, negative on error.
|
||||
fn register_hc_state_set(
|
||||
linker: &mut Linker<PluginStoreData>,
|
||||
) -> Result<(), PluginError> {
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"hc_state_set",
|
||||
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
|
||||
eid_ptr: i32,
|
||||
eid_len: i32,
|
||||
state_ptr: i32,
|
||||
state_len: i32,
|
||||
attrs_ptr: i32,
|
||||
attrs_len: i32|
|
||||
-> i32 {
|
||||
// Read all strings from guest memory in one borrow.
|
||||
let (eid, new_state, attrs_str) = {
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(wasmtime::Extern::Memory(m)) => m,
|
||||
_ => return -1,
|
||||
};
|
||||
let data = mem.data(&caller);
|
||||
let eid = match read_str(data, eid_ptr, eid_len) {
|
||||
Some(s) => s.to_owned(),
|
||||
None => return -1,
|
||||
};
|
||||
let new_state = match read_str(data, state_ptr, state_len) {
|
||||
Some(s) => s.to_owned(),
|
||||
None => return -1,
|
||||
};
|
||||
let attrs_str = read_str(data, attrs_ptr, attrs_len)
|
||||
.unwrap_or("{}")
|
||||
.to_owned();
|
||||
(eid, new_state, attrs_str)
|
||||
};
|
||||
|
||||
let entity_id = match homecore::EntityId::parse(&eid) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return -2,
|
||||
};
|
||||
let attrs: serde_json::Value =
|
||||
serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({}));
|
||||
|
||||
caller
|
||||
.data()
|
||||
.hc
|
||||
.states()
|
||||
.set(entity_id, new_state, attrs, homecore::Context::new());
|
||||
0
|
||||
},
|
||||
)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("register hc_state_set: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `hc_state_subscribe(eid_ptr: i32, eid_len: i32) → i32`
|
||||
///
|
||||
/// Records a subscription so the host will call `receive_event` on future
|
||||
/// state changes for this entity. Returns 0 on success, -1 on invalid entity.
|
||||
fn register_hc_state_subscribe(
|
||||
linker: &mut Linker<PluginStoreData>,
|
||||
) -> Result<(), PluginError> {
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"hc_state_subscribe",
|
||||
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
|
||||
eid_ptr: i32,
|
||||
eid_len: i32|
|
||||
-> i32 {
|
||||
let eid: String = {
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(wasmtime::Extern::Memory(m)) => m,
|
||||
_ => return -1,
|
||||
};
|
||||
match read_str(mem.data(&caller), eid_ptr, eid_len) {
|
||||
Some(s) => s.to_owned(),
|
||||
None => return -1,
|
||||
}
|
||||
};
|
||||
caller.data_mut().subscriptions.push(eid);
|
||||
0
|
||||
},
|
||||
)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("register hc_state_subscribe: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `hc_log(level: i32, msg_ptr: i32, msg_len: i32) → ()`
|
||||
///
|
||||
/// Structured log output from the plugin. `level`: 0=debug 1=info 2=warn 3=error.
|
||||
fn register_hc_log(
|
||||
linker: &mut Linker<PluginStoreData>,
|
||||
) -> Result<(), PluginError> {
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"hc_log",
|
||||
|mut caller: wasmtime::Caller<'_, PluginStoreData>,
|
||||
level: i32,
|
||||
msg_ptr: i32,
|
||||
msg_len: i32| {
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(wasmtime::Extern::Memory(m)) => m,
|
||||
_ => return,
|
||||
};
|
||||
let msg = read_str(mem.data(&caller), msg_ptr, msg_len)
|
||||
.unwrap_or("(invalid utf8)")
|
||||
.to_owned();
|
||||
let lvl = LogLevel::from_i32(level);
|
||||
eprintln!("[PLUGIN {}] {}", lvl.as_str(), msg);
|
||||
},
|
||||
)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("register hc_log: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── WasmPlugin ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A loaded WASM plugin instance. Wraps a Wasmtime `Store` + `Instance`.
|
||||
///
|
||||
/// The `Arc<Mutex<_>>` allows the handle to be `Clone` + `Send` while
|
||||
/// maintaining exclusive access for calls into the WASM module.
|
||||
pub struct WasmPlugin {
|
||||
pub inner: Arc<Mutex<(Store<PluginStoreData>, wasmtime::Instance)>>,
|
||||
}
|
||||
|
||||
impl WasmPlugin {
|
||||
/// Return a snapshot of the entity IDs this plugin has subscribed to.
|
||||
pub fn subscriptions(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.lock()
|
||||
.map(|g| g.0.data().subscriptions.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Call the `plugin_setup` export with the given config-entry JSON.
|
||||
pub fn call_setup(&self, config_entry_json: &str) -> Result<i32, PluginError> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
||||
let (store, instance) = &mut *guard;
|
||||
call_export_str(store, instance, "plugin_setup", config_entry_json)
|
||||
}
|
||||
|
||||
/// Call `plugin_handle_state_changed` with a [`StateChangedEventJson`].
|
||||
pub fn call_state_changed(
|
||||
&self,
|
||||
event: &StateChangedEventJson,
|
||||
) -> Result<i32, PluginError> {
|
||||
let json = serde_json::to_string(event)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("serialize event: {e}")))?;
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
|
||||
let (store, instance) = &mut *guard;
|
||||
call_export_str(store, instance, "plugin_handle_state_changed", &json)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a UTF-8 string from guest linear memory.
|
||||
fn read_str(mem: &[u8], ptr: i32, len: i32) -> Option<&str> {
|
||||
if len < 0 || len as usize > MAX_ABI_BUFFER_BYTES {
|
||||
return None;
|
||||
}
|
||||
let ptr = ptr as usize;
|
||||
let len = len as usize;
|
||||
let slice = mem.get(ptr..ptr + len)?;
|
||||
std::str::from_utf8(slice).ok()
|
||||
}
|
||||
|
||||
/// Allocate a guest buffer via `alloc`, write `payload`, call `export_fn(ptr, len)`,
|
||||
/// then free via `dealloc`. Returns the i32 result of the guest export.
|
||||
fn call_export_str(
|
||||
store: &mut Store<PluginStoreData>,
|
||||
instance: &wasmtime::Instance,
|
||||
export_fn: &str,
|
||||
payload: &str,
|
||||
) -> Result<i32, PluginError> {
|
||||
let payload_bytes = payload.as_bytes().to_vec(); // owned copy avoids reborrow issues
|
||||
let payload_len = payload_bytes.len() as i32;
|
||||
|
||||
// 1. Allocate guest buffer.
|
||||
let alloc = instance
|
||||
.get_typed_func::<i32, i32>(&mut *store, "alloc")
|
||||
.map_err(|e| PluginError::RuntimeError(format!("get alloc: {e}")))?;
|
||||
let ptr = alloc
|
||||
.call(&mut *store, payload_len)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("call alloc: {e}")))?;
|
||||
|
||||
// 2. Write payload into guest memory.
|
||||
{
|
||||
let mem = instance
|
||||
.get_memory(&mut *store, "memory")
|
||||
.ok_or_else(|| PluginError::RuntimeError("no memory export".into()))?;
|
||||
let guest_slice = mem
|
||||
.data_mut(&mut *store)
|
||||
.get_mut(ptr as usize..ptr as usize + payload_bytes.len())
|
||||
.ok_or_else(|| PluginError::RuntimeError("guest memory OOB".into()))?;
|
||||
guest_slice.copy_from_slice(&payload_bytes);
|
||||
}
|
||||
|
||||
// 3. Call the guest export.
|
||||
let func = instance
|
||||
.get_typed_func::<(i32, i32), i32>(&mut *store, export_fn)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("get {export_fn}: {e}")))?;
|
||||
let result = func
|
||||
.call(&mut *store, (ptr, payload_len))
|
||||
.map_err(|e| PluginError::RuntimeError(format!("call {export_fn}: {e}")))?;
|
||||
|
||||
// 4. Free the guest buffer.
|
||||
let dealloc = instance
|
||||
.get_typed_func::<(i32, i32), ()>(&mut *store, "dealloc")
|
||||
.map_err(|e| PluginError::RuntimeError(format!("get dealloc: {e}")))?;
|
||||
dealloc
|
||||
.call(&mut *store, (ptr, payload_len))
|
||||
.map_err(|e| PluginError::RuntimeError(format!("call dealloc: {e}")))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ── Unit tests (using inline WAT) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal WAT module that implements all host imports as no-ops and
|
||||
/// exports `alloc` / `dealloc` / `plugin_setup` /
|
||||
/// `plugin_handle_state_changed`. Compiled at test time via `wat::parse_str`.
|
||||
///
|
||||
/// The `hc_state_set` call in the test plugin writes back a hard-coded
|
||||
/// entity via the host import (the host import will actually call back into
|
||||
/// the HomeCore state machine via `caller.data()`).
|
||||
const TEST_WAT: &str = r#"
|
||||
(module
|
||||
;; Host imports
|
||||
(import "env" "hc_state_get"
|
||||
(func $hc_state_get (param i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_set"
|
||||
(func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_subscribe"
|
||||
(func $hc_state_subscribe (param i32 i32) (result i32)))
|
||||
(import "env" "hc_log"
|
||||
(func $hc_log (param i32 i32 i32)))
|
||||
|
||||
;; Linear memory: 1 page = 64 KiB
|
||||
(memory (export "memory") 1)
|
||||
|
||||
;; Simple bump allocator state
|
||||
(global $bump (mut i32) (i32.const 1024))
|
||||
|
||||
;; alloc(size) → ptr
|
||||
(func (export "alloc") (param $size i32) (result i32)
|
||||
(local $ptr i32)
|
||||
(local.set $ptr (global.get $bump))
|
||||
(global.set $bump (i32.add (global.get $bump) (local.get $size)))
|
||||
(local.get $ptr)
|
||||
)
|
||||
|
||||
;; dealloc(ptr, size) — no-op in bump allocator
|
||||
(func (export "dealloc") (param i32 i32))
|
||||
|
||||
;; plugin_setup(ptr, len) → 0
|
||||
(func (export "plugin_setup") (param i32 i32) (result i32)
|
||||
(i32.const 0)
|
||||
)
|
||||
|
||||
;; plugin_handle_state_changed(ptr, len) → 0
|
||||
;; Calls hc_log with a fixed message so we can observe the import works.
|
||||
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32)
|
||||
;; log "ok" at INFO level — offset 0 in memory, write "ok" there first
|
||||
(i32.store8 (i32.const 0) (i32.const 111)) ;; 'o'
|
||||
(i32.store8 (i32.const 1) (i32.const 107)) ;; 'k'
|
||||
(call $hc_log (i32.const 1) (i32.const 0) (i32.const 2))
|
||||
(i32.const 0)
|
||||
)
|
||||
)
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn wasmtime_runtime_compiles_and_instantiates_wat() {
|
||||
let wasm_bytes = wat::parse_str(TEST_WAT).expect("WAT should parse");
|
||||
let rt = WasmtimeRuntime::new().expect("engine should init");
|
||||
let hc = HomeCore::new();
|
||||
let plugin = rt.load_wasm(&wasm_bytes, hc).expect("should instantiate");
|
||||
|
||||
// call plugin_setup — expect 0
|
||||
let r = plugin
|
||||
.call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#)
|
||||
.expect("setup should not error");
|
||||
assert_eq!(r, 0, "plugin_setup should return 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hc_state_set_round_trip_via_wat() {
|
||||
/// WAT plugin that calls hc_state_set to write "on" for binary_sensor.test_alert
|
||||
const SET_WAT: &str = r#"
|
||||
(module
|
||||
(import "env" "hc_state_get"
|
||||
(func $hc_state_get (param i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_set"
|
||||
(func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_subscribe"
|
||||
(func $hc_state_subscribe (param i32 i32) (result i32)))
|
||||
(import "env" "hc_log"
|
||||
(func $hc_log (param i32 i32 i32)))
|
||||
|
||||
(memory (export "memory") 1)
|
||||
(global $bump (mut i32) (i32.const 2048))
|
||||
|
||||
(func (export "alloc") (param $size i32) (result i32)
|
||||
(local $ptr i32)
|
||||
(local.set $ptr (global.get $bump))
|
||||
(global.set $bump (i32.add (global.get $bump) (local.get $size)))
|
||||
(local.get $ptr)
|
||||
)
|
||||
(func (export "dealloc") (param i32 i32))
|
||||
|
||||
;; Strings stored at known offsets in memory:
|
||||
;; offset 0: "binary_sensor.test_alert" (24 bytes)
|
||||
;; offset 64: "on" (2 bytes)
|
||||
;; offset 128: "{}" (2 bytes)
|
||||
(data (i32.const 0) "binary_sensor.test_alert")
|
||||
(data (i32.const 64) "on")
|
||||
(data (i32.const 128) "{}")
|
||||
|
||||
;; plugin_setup: call hc_state_set to write "on"
|
||||
(func (export "plugin_setup") (param i32 i32) (result i32)
|
||||
(call $hc_state_set
|
||||
(i32.const 0) ;; eid_ptr
|
||||
(i32.const 24) ;; eid_len = len("binary_sensor.test_alert")
|
||||
(i32.const 64) ;; state_ptr
|
||||
(i32.const 2) ;; state_len = len("on")
|
||||
(i32.const 128) ;; attrs_ptr
|
||||
(i32.const 2) ;; attrs_len = len("{}")
|
||||
)
|
||||
drop
|
||||
(i32.const 0)
|
||||
)
|
||||
|
||||
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32)
|
||||
(i32.const 0)
|
||||
)
|
||||
)
|
||||
"#;
|
||||
let wasm_bytes = wat::parse_str(SET_WAT).expect("WAT should parse");
|
||||
let rt = WasmtimeRuntime::new().expect("engine");
|
||||
let hc = HomeCore::new();
|
||||
let plugin = rt.load_wasm(&wasm_bytes, hc.clone()).expect("instantiate");
|
||||
|
||||
// Call plugin_setup — the WAT calls hc_state_set inside.
|
||||
plugin.call_setup("{}").expect("setup");
|
||||
|
||||
// Verify the host state machine saw the write.
|
||||
let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap();
|
||||
let state = hc.states().get(&eid).expect("state should exist");
|
||||
assert_eq!(
|
||||
state.state, "on",
|
||||
"hc_state_set via host import should write 'on'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
//! Integration tests for ADR-128 P2 — Wasmtime runtime + example WASM plugin.
|
||||
//!
|
||||
//! ## Test strategy
|
||||
//!
|
||||
//! ### Primary path (compiled .wasm)
|
||||
//!
|
||||
//! Loads `homecore_plugin_example.wasm` from the known release output path
|
||||
//! under the plugin-example's own target directory. If the binary is not
|
||||
//! present (i.e., the example hasn't been built yet), the primary test is
|
||||
//! skipped with a warning and the WAT-based fallback runs instead.
|
||||
//!
|
||||
//! To run the primary path:
|
||||
//!
|
||||
//! ```sh
|
||||
//! # From v2/crates/homecore-plugin-example:
|
||||
//! /c/Users/ruv/.cargo/bin/cargo build --target wasm32-unknown-unknown --release
|
||||
//! # Then from v2/:
|
||||
//! cargo test -p homecore-plugins --features wasmtime
|
||||
//! ```
|
||||
//!
|
||||
//! ### Fallback path (inline WAT)
|
||||
//!
|
||||
//! Always runs. Uses `wat::parse_str` to compile a hand-written WAT module
|
||||
//! that implements the same temperature-threshold logic as the Rust plugin.
|
||||
//! This proves the Wasmtime linker works and all 4 host imports are wired
|
||||
//! correctly even without a pre-built `.wasm` binary.
|
||||
|
||||
#[cfg(feature = "wasmtime")]
|
||||
mod wasmtime_tests {
|
||||
use homecore::HomeCore;
|
||||
use homecore_plugins::wasmtime_runtime::WasmtimeRuntime;
|
||||
use homecore_plugins::StateChangedEventJson;
|
||||
|
||||
// ── Path to compiled example binary ────────────────────────────────────
|
||||
|
||||
/// Path to the pre-compiled example WASM relative to the workspace root.
|
||||
///
|
||||
/// The example crate has its own isolated Cargo workspace so its target
|
||||
/// directory lives under the crate itself, not the v2/ workspace target.
|
||||
const EXAMPLE_WASM_PATH: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../crates/homecore-plugin-example/target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm"
|
||||
);
|
||||
|
||||
// ── WAT fallback (always runnable) ─────────────────────────────────────
|
||||
|
||||
/// WAT module implementing the same temperature-threshold logic as
|
||||
/// `homecore-plugin-example`. Used when the compiled .wasm is unavailable.
|
||||
///
|
||||
/// Behaviour:
|
||||
/// - `plugin_setup` → subscribes to `sensor.test_temp` via `hc_state_subscribe`
|
||||
/// - `plugin_handle_state_changed` → parses the `new_state` field from
|
||||
/// the event JSON and calls `hc_state_set` to write `binary_sensor.test_alert`
|
||||
///
|
||||
/// This WAT version uses a simplified string scan rather than full JSON
|
||||
/// parsing, which is sufficient for the test payloads.
|
||||
const THRESHOLD_WAT: &str = r#"
|
||||
(module
|
||||
(import "env" "hc_state_get"
|
||||
(func $hc_state_get (param i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_set"
|
||||
(func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_subscribe"
|
||||
(func $hc_state_subscribe (param i32 i32) (result i32)))
|
||||
(import "env" "hc_log"
|
||||
(func $hc_log (param i32 i32 i32)))
|
||||
|
||||
(memory (export "memory") 2)
|
||||
(global $bump (mut i32) (i32.const 4096))
|
||||
|
||||
;; Static data at known offsets:
|
||||
;; 0: "sensor.test_temp" (16 bytes)
|
||||
;; 64: "binary_sensor.test_alert" (24 bytes)
|
||||
;; 128: "on" (2 bytes)
|
||||
;; 192: "off" (3 bytes)
|
||||
;; 256: "{}" (2 bytes)
|
||||
(data (i32.const 0) "sensor.test_temp")
|
||||
(data (i32.const 64) "binary_sensor.test_alert")
|
||||
(data (i32.const 128) "on")
|
||||
(data (i32.const 192) "off")
|
||||
(data (i32.const 256) "{}")
|
||||
|
||||
(func (export "alloc") (param $size i32) (result i32)
|
||||
(local $ptr i32)
|
||||
(local.set $ptr (global.get $bump))
|
||||
(global.set $bump (i32.add (global.get $bump) (local.get $size)))
|
||||
(local.get $ptr)
|
||||
)
|
||||
(func (export "dealloc") (param i32 i32))
|
||||
|
||||
;; plugin_setup: subscribe to sensor.test_temp
|
||||
(func (export "plugin_setup") (param i32 i32) (result i32)
|
||||
(call $hc_state_subscribe (i32.const 0) (i32.const 16))
|
||||
drop
|
||||
(i32.const 0)
|
||||
)
|
||||
|
||||
;; plugin_handle_state_changed(ptr, len) → i32
|
||||
;;
|
||||
;; The host passes a JSON string. We scan for "\"new_state\":\"" and read
|
||||
;; one or two ASCII digit bytes to determine if temp > 25 or < 20.
|
||||
;; The test values are "26" (above 25) and "19" (below 20), so we read
|
||||
;; the first two digits after the marker and compare numerically.
|
||||
;;
|
||||
;; Scan strategy: find byte sequence for "new_state":"
|
||||
;; Then read the decimal integer that follows until '"'.
|
||||
;;
|
||||
;; We implement a simple integer parser inline in WAT.
|
||||
(func (export "plugin_handle_state_changed") (param $ptr i32) (param $len i32) (result i32)
|
||||
(local $i i32) ;; scan index into the event buffer
|
||||
(local $end i32) ;; ptr + len
|
||||
(local $num i32) ;; parsed integer temperature
|
||||
(local $neg i32) ;; 1 if negative
|
||||
(local $ch i32) ;; current character
|
||||
(local $found i32) ;; 1 if marker found
|
||||
|
||||
;; We look for the 13-byte sequence: "new_state":"
|
||||
;; Simplified: scan for byte 'n','e','w' consecutively to find the field.
|
||||
;; Full marker: "new_state":" (len=13 including both quotes and colon)
|
||||
;; Bytes: 22 6e 65 77 5f 73 74 61 74 65 22 3a 22
|
||||
;; " n e w _ s t a t e " : "
|
||||
|
||||
(local.set $end (i32.add (local.get $ptr) (local.get $len)))
|
||||
(local.set $i (local.get $ptr))
|
||||
(local.set $found (i32.const 0))
|
||||
|
||||
;; Scan for '"new_state":"'
|
||||
(block $done
|
||||
(loop $scan
|
||||
;; Bounds check
|
||||
(br_if $done (i32.ge_u (i32.add (local.get $i) (i32.const 13)) (local.get $end)))
|
||||
;; Check 13-byte marker
|
||||
(if
|
||||
(i32.and
|
||||
(i32.and
|
||||
(i32.eq (i32.load8_u (local.get $i)) (i32.const 0x22)) ;; "
|
||||
(i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 1))) (i32.const 0x6e)) ;; n
|
||||
)
|
||||
(i32.and
|
||||
(i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 11))) (i32.const 0x3a)) ;; :
|
||||
(i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 12))) (i32.const 0x22)) ;; "
|
||||
)
|
||||
)
|
||||
(then
|
||||
;; Advance past marker to the value start
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 13)))
|
||||
(local.set $found (i32.const 1))
|
||||
(br $done)
|
||||
)
|
||||
)
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
(br $scan)
|
||||
)
|
||||
)
|
||||
|
||||
;; If not found or null value, return 0 (no-op).
|
||||
(if (i32.eqz (local.get $found)) (then (return (i32.const 0))))
|
||||
|
||||
;; Parse integer from current position.
|
||||
(local.set $num (i32.const 0))
|
||||
(local.set $neg (i32.const 0))
|
||||
|
||||
;; Check for minus sign.
|
||||
(if (i32.lt_u (local.get $i) (local.get $end))
|
||||
(then
|
||||
(local.set $ch (i32.load8_u (local.get $i)))
|
||||
(if (i32.eq (local.get $ch) (i32.const 0x2d)) ;; '-'
|
||||
(then
|
||||
(local.set $neg (i32.const 1))
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
;; Parse digits.
|
||||
(block $numDone
|
||||
(loop $digits
|
||||
(br_if $numDone (i32.ge_u (local.get $i) (local.get $end)))
|
||||
(local.set $ch (i32.load8_u (local.get $i)))
|
||||
;; Stop at non-digit or dot (we ignore decimals for integer comparison)
|
||||
(br_if $numDone (i32.lt_u (local.get $ch) (i32.const 0x30))) ;; < '0'
|
||||
(br_if $numDone (i32.gt_u (local.get $ch) (i32.const 0x39))) ;; > '9'
|
||||
(local.set $num
|
||||
(i32.add
|
||||
(i32.mul (local.get $num) (i32.const 10))
|
||||
(i32.sub (local.get $ch) (i32.const 0x30))
|
||||
)
|
||||
)
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
(br $digits)
|
||||
)
|
||||
)
|
||||
|
||||
;; Apply negative sign.
|
||||
(if (local.get $neg)
|
||||
(then (local.set $num (i32.sub (i32.const 0) (local.get $num))))
|
||||
)
|
||||
|
||||
;; Apply threshold: > 25 → set alert ON; < 20 → set alert OFF.
|
||||
(if (i32.gt_s (local.get $num) (i32.const 25))
|
||||
(then
|
||||
(call $hc_state_set
|
||||
(i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert"
|
||||
(i32.const 128) (i32.const 2) ;; state: "on"
|
||||
(i32.const 256) (i32.const 2) ;; attrs: "{}"
|
||||
)
|
||||
drop
|
||||
)
|
||||
)
|
||||
(if (i32.lt_s (local.get $num) (i32.const 20))
|
||||
(then
|
||||
(call $hc_state_set
|
||||
(i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert"
|
||||
(i32.const 192) (i32.const 3) ;; state: "off"
|
||||
(i32.const 256) (i32.const 2) ;; attrs: "{}"
|
||||
)
|
||||
drop
|
||||
)
|
||||
)
|
||||
(i32.const 0)
|
||||
)
|
||||
)
|
||||
"#;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_rt_and_hc() -> (WasmtimeRuntime, HomeCore) {
|
||||
(
|
||||
WasmtimeRuntime::new().expect("WasmtimeRuntime::new"),
|
||||
HomeCore::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn state_changed_event(entity_id: &str, new_state: &str) -> StateChangedEventJson {
|
||||
StateChangedEventJson::state_changed(
|
||||
entity_id,
|
||||
Some(new_state),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_alert_state(hc: &HomeCore, expected: &str) {
|
||||
let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap();
|
||||
let state = hc
|
||||
.states()
|
||||
.get(&eid)
|
||||
.unwrap_or_else(|| panic!("binary_sensor.test_alert not found in state machine"));
|
||||
assert_eq!(
|
||||
state.state, expected,
|
||||
"binary_sensor.test_alert should be '{expected}' but was '{}'",
|
||||
state.state
|
||||
);
|
||||
}
|
||||
|
||||
// ── Primary test: compiled .wasm binary ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn wasm_plugin_temp_threshold_compiled_binary() {
|
||||
let wasm_path = std::path::Path::new(EXAMPLE_WASM_PATH);
|
||||
if !wasm_path.exists() {
|
||||
eprintln!(
|
||||
"[SKIP] {EXAMPLE_WASM_PATH} not found. \
|
||||
Build the example first:\n \
|
||||
cd v2/crates/homecore-plugin-example && \
|
||||
cargo build --target wasm32-unknown-unknown --release"
|
||||
);
|
||||
return; // skip — binary not built yet
|
||||
}
|
||||
|
||||
let wasm_bytes = std::fs::read(wasm_path)
|
||||
.expect("failed to read homecore_plugin_example.wasm");
|
||||
|
||||
let (rt, hc) = build_rt_and_hc();
|
||||
let plugin = rt
|
||||
.load_wasm(&wasm_bytes, hc.clone())
|
||||
.expect("load_wasm should succeed");
|
||||
|
||||
// Call plugin_setup — should subscribe to sensor.test_temp.
|
||||
let setup_result = plugin
|
||||
.call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#)
|
||||
.expect("plugin_setup should not trap");
|
||||
assert_eq!(setup_result, 0, "plugin_setup should return 0");
|
||||
|
||||
// Verify subscription was recorded.
|
||||
assert!(
|
||||
plugin.subscriptions().contains(&"sensor.test_temp".to_owned()),
|
||||
"plugin should have subscribed to sensor.test_temp"
|
||||
);
|
||||
|
||||
// ── Scenario 1: temp = 26.0 → alert ON ──────────────────────────────
|
||||
let event_hot = state_changed_event("sensor.test_temp", "26.0");
|
||||
plugin
|
||||
.call_state_changed(&event_hot)
|
||||
.expect("state_changed should not trap");
|
||||
assert_alert_state(&hc, "on");
|
||||
|
||||
// ── Scenario 2: temp = 19.0 → alert OFF ─────────────────────────────
|
||||
let event_cold = state_changed_event("sensor.test_temp", "19.0");
|
||||
plugin
|
||||
.call_state_changed(&event_cold)
|
||||
.expect("state_changed should not trap");
|
||||
assert_alert_state(&hc, "off");
|
||||
}
|
||||
|
||||
// ── Fallback test: inline WAT (always runs) ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn wasm_plugin_temp_threshold_wat_fallback() {
|
||||
let wasm_bytes = wat::parse_str(THRESHOLD_WAT).expect("WAT should parse");
|
||||
|
||||
let (rt, hc) = build_rt_and_hc();
|
||||
let plugin = rt
|
||||
.load_wasm(&wasm_bytes, hc.clone())
|
||||
.expect("load_wasm should succeed for WAT");
|
||||
|
||||
// plugin_setup → subscribes
|
||||
let r = plugin.call_setup("{}").expect("setup");
|
||||
assert_eq!(r, 0);
|
||||
|
||||
// ── Scenario 1: temp = 26 → alert ON ───────────────────────────────
|
||||
let hot_event = StateChangedEventJson::state_changed(
|
||||
"sensor.test_temp",
|
||||
Some("26"),
|
||||
serde_json::json!({}),
|
||||
);
|
||||
plugin
|
||||
.call_state_changed(&hot_event)
|
||||
.expect("state_changed should not trap");
|
||||
assert_alert_state(&hc, "on");
|
||||
|
||||
// ── Scenario 2: temp = 19 → alert OFF ──────────────────────────────
|
||||
let cold_event = StateChangedEventJson::state_changed(
|
||||
"sensor.test_temp",
|
||||
Some("19"),
|
||||
serde_json::json!({}),
|
||||
);
|
||||
plugin
|
||||
.call_state_changed(&cold_event)
|
||||
.expect("state_changed should not trap");
|
||||
assert_alert_state(&hc, "off");
|
||||
}
|
||||
|
||||
// ── Linker smoke test ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn wasmtime_linker_wires_all_four_host_imports() {
|
||||
// A minimal WAT that calls all 4 host imports once and returns 0.
|
||||
const SMOKE_WAT: &str = r#"
|
||||
(module
|
||||
(import "env" "hc_state_get" (func (param i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_set" (func (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_subscribe" (func (param i32 i32) (result i32)))
|
||||
(import "env" "hc_log" (func (param i32 i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(global $bump (mut i32) (i32.const 512))
|
||||
(func (export "alloc") (param i32) (result i32)
|
||||
(local $p i32)
|
||||
(local.set $p (global.get $bump))
|
||||
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
|
||||
(local.get $p))
|
||||
(func (export "dealloc") (param i32 i32))
|
||||
(func (export "plugin_setup") (param i32 i32) (result i32) (i32.const 0))
|
||||
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
|
||||
)
|
||||
"#;
|
||||
let wasm_bytes = wat::parse_str(SMOKE_WAT).expect("WAT");
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
let hc = HomeCore::new();
|
||||
let plugin = rt.load_wasm(&wasm_bytes, hc).expect("instantiate");
|
||||
let r = plugin.call_setup("{}").expect("setup");
|
||||
assert_eq!(r, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# homecore-recorder — SQLite state history + semantic search (ADR-132)
|
||||
#
|
||||
# P1 ships: SQLite structural persistence with HA-compat schema.
|
||||
# P2 ships: ruvector-backed SemanticIndex — hash embeddings, HNSW search (ADR-132 P2, Iter-6 track C).
|
||||
# P3 plan: replace hash embeddings with ruvector-attention sentence embeddings (dim → 384).
|
||||
#
|
||||
# Build: cargo build -p homecore-recorder --features ruvector
|
||||
# Test (P2): cargo test -p homecore-recorder --features ruvector (20 tests)
|
||||
# Test (P1): cargo test -p homecore-recorder --no-default-features (14 tests)
|
||||
|
||||
[package]
|
||||
name = "homecore-recorder"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "SQLite state-history recorder for HOMECORE — HA-compat schema + ruvector semantic search (ADR-132)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_recorder"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ruvector = ["dep:ruvector-core", "dep:sha2"]
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
|
||||
# SQLite via sqlx — only the lite feature set; no postgres, no tls
|
||||
sqlx = { version = "0.8.1", default-features = false, features = [
|
||||
"runtime-tokio-native-tls",
|
||||
"sqlite",
|
||||
"chrono",
|
||||
"uuid",
|
||||
] }
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
# Structured logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Trait objects for SemanticIndex
|
||||
async-trait = "0.1"
|
||||
|
||||
# P2: ruvector-core HNSW index + sha2 for hash-based embeddings (ruvector feature)
|
||||
ruvector-core = { version = "2.2.0", optional = true, default-features = false }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
|
@ -0,0 +1,562 @@
|
|||
//! `Recorder` — SQLite write path + query path.
|
||||
//!
|
||||
//! Wraps an `SqlitePool` and exposes three operations:
|
||||
//! - [`Recorder::open`] — open (or create) the DB and apply schema.
|
||||
//! - [`Recorder::record_state`] — persist a `StateChangedEvent`.
|
||||
//! - [`Recorder::record_event`] — persist a `DomainEvent`.
|
||||
//! - [`Recorder::get_state_history`] — read back rows in time order.
|
||||
//!
|
||||
//! State attributes are deduped via `fnv64a_hash` (see [`crate::dedup`]):
|
||||
//! if an identical attributes blob was previously written its
|
||||
//! `attributes_id` is reused and no new row is inserted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::debug;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::{DomainEvent, StateChangedEvent};
|
||||
|
||||
use crate::dedup::fnv64a_hash;
|
||||
use crate::schema::ALL_DDL;
|
||||
|
||||
/// Errors returned by `Recorder` operations.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecorderError {
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
#[error("serialisation error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("URL parse error: {0}")]
|
||||
UrlParse(String),
|
||||
}
|
||||
|
||||
/// Trait for pluggable semantic (vector) indexing of state writes.
|
||||
///
|
||||
/// The no-op [`NullSemanticIndex`] is used in P1. P2 ships a ruvector-backed
|
||||
/// implementation behind the `ruvector` feature flag.
|
||||
///
|
||||
/// ## P2 API change
|
||||
///
|
||||
/// The `insert_state` method now accepts a `state_id` (SQLite rowid) so the
|
||||
/// HNSW index can map vector results back to SQLite rows. `search` embeds a
|
||||
/// free-text query and returns `(state_id, score)` pairs.
|
||||
#[async_trait]
|
||||
pub trait SemanticIndex: Send + Sync {
|
||||
/// Insert an embedding for `state` keyed by its SQLite `state_id`.
|
||||
/// Called after the SQLite insert succeeds. Must not propagate errors
|
||||
/// back to the recorder — failure is logged, not fatal.
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
state_id: i64,
|
||||
state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Search for the `k` nearest states to the free-text `query`.
|
||||
/// Returns `(state_id, score)` pairs sorted by ascending distance.
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
/// No-op `SemanticIndex`. Used by default when the `ruvector` feature is off.
|
||||
pub struct NullSemanticIndex;
|
||||
|
||||
#[async_trait]
|
||||
impl SemanticIndex for NullSemanticIndex {
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
_state_id: i64,
|
||||
_state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
_query: &str,
|
||||
_k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorder. Cheap to clone (Arc-backed pool). Pass copies to the
|
||||
/// `RecorderListener` and the API history handler.
|
||||
///
|
||||
/// The `semantic` field is wrapped in `Arc<RwLock<...>>` so that
|
||||
/// `insert_state` (which takes `&mut self` on the trait) can be called
|
||||
/// without requiring `&mut Recorder` from callers.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
pool: SqlitePool,
|
||||
semantic: Arc<RwLock<dyn SemanticIndex>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Open (or create) the SQLite database at `path` and apply the schema.
|
||||
///
|
||||
/// Pass `"sqlite::memory:"` for an in-memory database (tests).
|
||||
///
|
||||
/// The schema DDL uses `CREATE TABLE IF NOT EXISTS` so calling this on an
|
||||
/// existing database is safe.
|
||||
pub async fn open(path: &str) -> Result<Self, RecorderError> {
|
||||
Self::open_with_index(path, Arc::new(RwLock::new(NullSemanticIndex))).await
|
||||
}
|
||||
|
||||
/// Open with a custom `SemanticIndex` (P2 entry point).
|
||||
pub async fn open_with_index(
|
||||
path: &str,
|
||||
semantic: Arc<RwLock<dyn SemanticIndex>>,
|
||||
) -> Result<Self, RecorderError> {
|
||||
let options = path
|
||||
.parse::<SqliteConnectOptions>()
|
||||
.map_err(|e| RecorderError::UrlParse(e.to_string()))?
|
||||
.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
let recorder = Self { pool, semantic };
|
||||
recorder.apply_schema().await?;
|
||||
Ok(recorder)
|
||||
}
|
||||
|
||||
/// Apply all DDL statements. Idempotent.
|
||||
async fn apply_schema(&self) -> Result<(), RecorderError> {
|
||||
for ddl in ALL_DDL {
|
||||
// Each DDL block may contain multiple statements separated by `;`.
|
||||
// sqlx::query does not support multi-statement strings directly,
|
||||
// so we split on the statement boundary and execute individually.
|
||||
for stmt in split_statements(ddl) {
|
||||
let stmt = stmt.trim();
|
||||
if !stmt.is_empty() {
|
||||
sqlx::query(stmt).execute(&self.pool).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a `StateChangedEvent`. Inserts into `states` and dedupes into
|
||||
/// `state_attributes`. Returns the `state_id` of the new row.
|
||||
pub async fn record_state(
|
||||
&self,
|
||||
event: &StateChangedEvent,
|
||||
) -> Result<Option<i64>, RecorderError> {
|
||||
let new_state = match &event.new_state {
|
||||
Some(s) => s,
|
||||
None => return Ok(None), // removal event — no row to insert
|
||||
};
|
||||
|
||||
let attrs_json = serde_json::to_string(&new_state.attributes)?;
|
||||
let hash = fnv64a_hash(&attrs_json);
|
||||
|
||||
// Upsert into state_attributes (dedup by hash).
|
||||
let attributes_id: i64 = {
|
||||
// Try to find an existing row first.
|
||||
let existing: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT attributes_id FROM state_attributes WHERE hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some((id,)) = existing {
|
||||
debug!(hash, id, "reusing existing state_attributes row");
|
||||
id
|
||||
} else {
|
||||
let result =
|
||||
sqlx::query("INSERT INTO state_attributes (shared_attrs, hash) VALUES (?, ?)")
|
||||
.bind(&attrs_json)
|
||||
.bind(hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
result.last_insert_rowid()
|
||||
}
|
||||
};
|
||||
|
||||
let context_id = new_state.context.id.to_string();
|
||||
let last_changed_ts = new_state.last_changed.timestamp_micros() as f64 / 1_000_000.0;
|
||||
let last_updated_ts = new_state.last_updated.timestamp_micros() as f64 / 1_000_000.0;
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO states \
|
||||
(entity_id, state, attributes_id, last_changed_ts, last_updated_ts, context_id) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(new_state.entity_id.as_str())
|
||||
.bind(&new_state.state)
|
||||
.bind(attributes_id)
|
||||
.bind(last_changed_ts)
|
||||
.bind(last_updated_ts)
|
||||
.bind(&context_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let state_id = result.last_insert_rowid();
|
||||
|
||||
// Best-effort semantic indexing — failure is logged, not propagated.
|
||||
if let Err(e) = self
|
||||
.semantic
|
||||
.write()
|
||||
.await
|
||||
.insert_state(state_id, new_state)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
entity_id = %new_state.entity_id,
|
||||
"semantic indexing failed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(state_id))
|
||||
}
|
||||
|
||||
/// Search for state history rows that semantically match `query`.
|
||||
///
|
||||
/// Uses the HNSW index to find the top-`k` nearest state embeddings,
|
||||
/// then fetches the full `StateRow` from SQLite for each result.
|
||||
/// Returns rows in ascending score (distance) order.
|
||||
///
|
||||
/// With the default `NullSemanticIndex` (no `ruvector` feature) this
|
||||
/// always returns an empty `Vec`.
|
||||
pub async fn search_semantic(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<StateRow>, RecorderError> {
|
||||
let hits = self
|
||||
.semantic
|
||||
.read()
|
||||
.await
|
||||
.search(query, k)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut rows = Vec::with_capacity(hits.len());
|
||||
for (state_id, _score) in hits {
|
||||
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT s.entity_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
|
||||
WHERE s.state_id = ?",
|
||||
)
|
||||
.bind(state_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some((entity_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)) = row {
|
||||
let eid = EntityId::parse(&entity_id)
|
||||
.unwrap_or_else(|_| EntityId::parse("unknown.unknown").unwrap());
|
||||
let attributes = shared_attrs
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()?
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
rows.push(StateRow {
|
||||
state_id,
|
||||
entity_id: eid,
|
||||
state,
|
||||
attributes,
|
||||
last_changed_ts,
|
||||
last_updated_ts,
|
||||
context_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Persist a `DomainEvent`. Returns the `event_id`.
|
||||
pub async fn record_event(&self, event: &DomainEvent) -> Result<i64, RecorderError> {
|
||||
let data_json = serde_json::to_string(&event.event_data)?;
|
||||
let time_fired_ts = event.fired_at.timestamp_micros() as f64 / 1_000_000.0;
|
||||
let context_id = event.context.id.to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO events (event_type, event_data, time_fired_ts, context_id) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&event.event_type)
|
||||
.bind(&data_json)
|
||||
.bind(time_fired_ts)
|
||||
.bind(&context_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Query state history for `entity_id` between `since` and `until`.
|
||||
/// Returns state snapshots in ascending `last_updated_ts` order.
|
||||
pub async fn get_state_history(
|
||||
&self,
|
||||
entity_id: &EntityId,
|
||||
since: DateTime<Utc>,
|
||||
until: DateTime<Utc>,
|
||||
) -> Result<Vec<StateRow>, RecorderError> {
|
||||
let since_ts = since.timestamp_micros() as f64 / 1_000_000.0;
|
||||
let until_ts = until.timestamp_micros() as f64 / 1_000_000.0;
|
||||
|
||||
let rows: Vec<(i64, String, Option<String>, f64, f64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT s.state_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
|
||||
WHERE s.entity_id = ? \
|
||||
AND s.last_updated_ts >= ? \
|
||||
AND s.last_updated_ts <= ? \
|
||||
ORDER BY s.last_updated_ts ASC",
|
||||
)
|
||||
.bind(entity_id.as_str())
|
||||
.bind(since_ts)
|
||||
.bind(until_ts)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(state_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)| {
|
||||
let attributes = shared_attrs
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()?
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
Ok(StateRow {
|
||||
state_id,
|
||||
entity_id: entity_id.clone(),
|
||||
state,
|
||||
attributes,
|
||||
last_changed_ts,
|
||||
last_updated_ts,
|
||||
context_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A state row returned from `get_state_history`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateRow {
|
||||
pub state_id: i64,
|
||||
pub entity_id: EntityId,
|
||||
pub state: String,
|
||||
pub attributes: serde_json::Value,
|
||||
/// Unix timestamp (seconds, fractional) when the state string last changed.
|
||||
pub last_changed_ts: f64,
|
||||
/// Unix timestamp (seconds, fractional) when this snapshot was written.
|
||||
pub last_updated_ts: f64,
|
||||
pub context_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Split a multi-statement DDL string on `;` boundaries.
|
||||
/// Trims whitespace; skips empty fragments.
|
||||
fn split_statements(ddl: &str) -> impl Iterator<Item = &str> {
|
||||
ddl.split(';').map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::{Context, DomainEvent, StateChangedEvent};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn open_memory() -> Recorder {
|
||||
Recorder::open("sqlite::memory:").await.expect("open in-memory DB")
|
||||
}
|
||||
|
||||
fn entity(s: &str) -> EntityId {
|
||||
EntityId::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn make_state_event(entity_id: &str, state_val: &str, attrs: serde_json::Value) -> StateChangedEvent {
|
||||
let eid = entity(entity_id);
|
||||
let ctx = Context::new();
|
||||
let s = Arc::new(State::new(eid.clone(), state_val, attrs, ctx));
|
||||
StateChangedEvent {
|
||||
entity_id: eid,
|
||||
old_state: None,
|
||||
new_state: Some(s),
|
||||
fired_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── schema ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_applies_on_fresh_db() {
|
||||
let recorder = open_memory().await;
|
||||
// Verify all four tables exist by querying sqlite_master.
|
||||
let tables: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.fetch_all(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<&str> = tables.iter().map(|(n,)| n.as_str()).collect();
|
||||
assert!(names.contains(&"state_attributes"), "missing state_attributes");
|
||||
assert!(names.contains(&"states"), "missing states");
|
||||
assert!(names.contains(&"events"), "missing events");
|
||||
assert!(names.contains(&"recorder_runs"), "missing recorder_runs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_idempotent_double_open() {
|
||||
// Applying schema twice (on the same pool) must not panic or error.
|
||||
let recorder = open_memory().await;
|
||||
recorder.apply_schema().await.expect("second apply_schema must be a no-op");
|
||||
}
|
||||
|
||||
// ── record_state ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_state_inserts_row() {
|
||||
let recorder = open_memory().await;
|
||||
let event = make_state_event("light.kitchen", "on", serde_json::json!({"brightness": 200}));
|
||||
|
||||
let state_id = recorder.record_state(&event).await.unwrap();
|
||||
assert!(state_id.is_some(), "expected a state_id");
|
||||
|
||||
let count: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM states WHERE entity_id = 'light.kitchen'")
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removal_event_returns_none() {
|
||||
let recorder = open_memory().await;
|
||||
let event = StateChangedEvent {
|
||||
entity_id: entity("light.kitchen"),
|
||||
old_state: None,
|
||||
new_state: None, // removal
|
||||
fired_at: Utc::now(),
|
||||
};
|
||||
let result = recorder.record_state(&event).await.unwrap();
|
||||
assert!(result.is_none(), "removal event should yield None state_id");
|
||||
}
|
||||
|
||||
// ── attribute deduplication ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_attrs_dedup_to_one_row() {
|
||||
let recorder = open_memory().await;
|
||||
let attrs = serde_json::json!({"brightness": 200, "color_temp": 4000});
|
||||
|
||||
let e1 = make_state_event("light.a", "on", attrs.clone());
|
||||
let e2 = make_state_event("light.b", "on", attrs.clone());
|
||||
|
||||
recorder.record_state(&e1).await.unwrap();
|
||||
recorder.record_state(&e2).await.unwrap();
|
||||
|
||||
let attr_count: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// Both events share identical attrs → only one state_attributes row.
|
||||
assert_eq!(attr_count.0, 1, "identical attrs must share one state_attributes row");
|
||||
|
||||
let state_count: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM states")
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state_count.0, 2, "two states rows expected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_attrs_each_get_own_row() {
|
||||
let recorder = open_memory().await;
|
||||
let e1 = make_state_event("sensor.a", "20", serde_json::json!({"unit": "C"}));
|
||||
let e2 = make_state_event("sensor.b", "20", serde_json::json!({"unit": "F"}));
|
||||
|
||||
recorder.record_state(&e1).await.unwrap();
|
||||
recorder.record_state(&e2).await.unwrap();
|
||||
|
||||
let attr_count: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(attr_count.0, 2);
|
||||
}
|
||||
|
||||
// ── get_state_history ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_returns_rows_in_time_order() {
|
||||
let recorder = open_memory().await;
|
||||
let eid = entity("sensor.temp");
|
||||
|
||||
// Insert three states with slightly different timestamps by sleeping.
|
||||
for val in &["20.0", "21.0", "22.0"] {
|
||||
let e = make_state_event("sensor.temp", val, serde_json::json!({}));
|
||||
recorder.record_state(&e).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
|
||||
let since = Utc::now() - chrono::Duration::seconds(10);
|
||||
let until = Utc::now() + chrono::Duration::seconds(10);
|
||||
let rows = recorder.get_state_history(&eid, since, until).await.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 3, "expected 3 history rows");
|
||||
// Verify ascending order by last_updated_ts.
|
||||
for w in rows.windows(2) {
|
||||
assert!(
|
||||
w[0].last_updated_ts <= w[1].last_updated_ts,
|
||||
"rows must be in ascending time order"
|
||||
);
|
||||
}
|
||||
assert_eq!(rows[0].state, "20.0");
|
||||
assert_eq!(rows[2].state, "22.0");
|
||||
}
|
||||
|
||||
// ── record_event ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_event_round_trips() {
|
||||
let recorder = open_memory().await;
|
||||
let ctx = Context::new();
|
||||
let event = DomainEvent::new(
|
||||
"call_service",
|
||||
serde_json::json!({"domain": "light", "service": "turn_on"}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let event_id = recorder.record_event(&event).await.unwrap();
|
||||
assert!(event_id > 0);
|
||||
|
||||
let row: (String, String) =
|
||||
sqlx::query_as("SELECT event_type, event_data FROM events WHERE event_id = ?")
|
||||
.bind(event_id)
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(row.0, "call_service");
|
||||
let data: serde_json::Value = serde_json::from_str(&row.1).unwrap();
|
||||
assert_eq!(data["domain"], "light");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
//! FNV-1a 64-bit hash for state-attribute deduplication.
|
||||
//!
|
||||
//! Matches Home Assistant's `db_schema.py` `fnv64a` function used to
|
||||
//! fingerprint shared attribute blobs. Two state writes with identical
|
||||
//! attributes share a single `state_attributes` row, reducing I/O by
|
||||
//! ~80% for high-frequency polling sensors.
|
||||
//!
|
||||
//! ## FNV-1a 64 spec
|
||||
//!
|
||||
//! - Offset basis: 0xcbf29ce484222325
|
||||
//! - Prime: 0x100000001b3
|
||||
//! - Per byte: `hash = (hash XOR byte) * prime`
|
||||
//!
|
||||
//! Reference values (computed from the spec + verified against HA source):
|
||||
//! - `""` (empty string) → signed i64: -3750763034362895579
|
||||
//! - `"a"` → signed i64: -5808556873153909620
|
||||
//! - `{"state": "on"}` → signed i64: 3947789143477681127
|
||||
|
||||
const FNV_OFFSET_BASIS_64: u64 = 0xcbf29ce484222325;
|
||||
const FNV_PRIME_64: u64 = 0x100000001b3;
|
||||
|
||||
/// Compute FNV-1a 64-bit hash of `data` bytes, returned as a signed `i64`
|
||||
/// suitable for direct storage in SQLite's INTEGER column.
|
||||
///
|
||||
/// The cast to `i64` is a bit-reinterpret, not a value conversion — the
|
||||
/// same pattern HA uses in `db_schema.py`.
|
||||
#[inline]
|
||||
pub fn fnv64a_bytes(data: &[u8]) -> i64 {
|
||||
let mut hash: u64 = FNV_OFFSET_BASIS_64;
|
||||
for &byte in data {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(FNV_PRIME_64);
|
||||
}
|
||||
hash as i64
|
||||
}
|
||||
|
||||
/// Hash a UTF-8 string. Convenience wrapper over [`fnv64a_bytes`].
|
||||
#[inline]
|
||||
pub fn fnv64a_hash(s: &str) -> i64 {
|
||||
fnv64a_bytes(s.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// HA reference: `fnv64a(b"")` → 0xcbf29ce484222325 (unsigned)
|
||||
/// As signed i64: -3750763034362895579
|
||||
#[test]
|
||||
fn hash_empty_string() {
|
||||
assert_eq!(fnv64a_hash(""), -3750763034362895579_i64);
|
||||
}
|
||||
|
||||
/// HA reference: `fnv64a(b"a")` → 0xaf63dc4c8601ec8c (unsigned)
|
||||
/// As signed i64: -5808556873153909620
|
||||
#[test]
|
||||
fn hash_single_char_a() {
|
||||
assert_eq!(fnv64a_hash("a"), -5808556873153909620_i64);
|
||||
}
|
||||
|
||||
/// Smoke-test a realistic JSON attribute blob.
|
||||
/// `{"state": "on"}` → signed i64: 3947789143477681127
|
||||
#[test]
|
||||
fn hash_json_blob() {
|
||||
assert_eq!(fnv64a_hash(r#"{"state": "on"}"#), 3947789143477681127_i64);
|
||||
}
|
||||
|
||||
/// Different strings must produce different hashes (basic collision check).
|
||||
#[test]
|
||||
fn distinct_strings_differ() {
|
||||
assert_ne!(fnv64a_hash("on"), fnv64a_hash("off"));
|
||||
assert_ne!(fnv64a_hash("{\"brightness\":100}"), fnv64a_hash("{\"brightness\":200}"));
|
||||
}
|
||||
|
||||
/// Deterministic: same input always gives same output.
|
||||
#[test]
|
||||
fn deterministic() {
|
||||
let s = r#"{"unit": "C", "value": 22.5}"#;
|
||||
assert_eq!(fnv64a_hash(s), fnv64a_hash(s));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
//! homecore-recorder — SQLite state history + semantic search.
|
||||
//!
|
||||
//! Implements ADR-132: dual-write architecture. P1 ships SQLite structural
|
||||
//! persistence with an HA-compatible schema (mirrors HA recorder schema v48).
|
||||
//! P2 (feature `ruvector`) adds a `SemanticIndex` backed by ruvector
|
||||
//! embeddings for natural-language state queries.
|
||||
//!
|
||||
//! ## P1 architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! StateMachine ──broadcast──► RecorderListener ──► Recorder
|
||||
//! │
|
||||
//! ┌───────┴──────────┐
|
||||
//! states state_attributes
|
||||
//! events recorder_runs
|
||||
//! ```
|
||||
//!
|
||||
//! ## P2 hand-off (ruvector feature)
|
||||
//!
|
||||
//! When the `ruvector` feature is enabled, the `Recorder` additionally
|
||||
//! calls a `SemanticIndex` implementation that embeds state attributes and
|
||||
//! stores vectors in ruvector for k-NN semantic search. See [`semantic`].
|
||||
|
||||
pub mod db;
|
||||
pub mod dedup;
|
||||
pub mod listener;
|
||||
pub mod schema;
|
||||
|
||||
#[cfg(feature = "ruvector")]
|
||||
pub mod semantic;
|
||||
|
||||
// Re-export the primary public API surface.
|
||||
pub use db::{Recorder, RecorderError};
|
||||
pub use listener::RecorderListener;
|
||||
|
||||
/// Null semantic index used when the `ruvector` feature is off.
|
||||
/// Satisfies the [`db::SemanticIndex`] trait bound without any allocation.
|
||||
pub use db::NullSemanticIndex;
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
//! `RecorderListener` — subscribes to `StateMachine` broadcasts and writes
|
||||
//! every `StateChangedEvent` to the `Recorder`.
|
||||
//!
|
||||
//! Spawned via `tokio::spawn`. Runs until the broadcast sender is dropped
|
||||
//! (i.e. the `StateMachine` is shut down) or until a `Lagged` error occurs
|
||||
//! (subscriber fell more than 4,096 events behind).
|
||||
//!
|
||||
//! On `Lagged`, the listener logs a warning and reconnects; it does not crash
|
||||
//! because dropping a listener would silently stop persistence.
|
||||
//!
|
||||
//! ## Subscription ordering
|
||||
//!
|
||||
//! The `broadcast::Receiver` is created inside `new()` (not inside the spawned
|
||||
//! task), so any events fired between `new()` and `spawn()` are enqueued in
|
||||
//! the receiver buffer and will be drained when the task starts.
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use homecore::event::StateChangedEvent;
|
||||
use homecore::state::StateMachine;
|
||||
|
||||
use crate::db::Recorder;
|
||||
|
||||
/// A background task that records every state change.
|
||||
///
|
||||
/// Call [`RecorderListener::new`] then [`RecorderListener::spawn`].
|
||||
/// The subscription starts at construction time so no events are missed
|
||||
/// between `new()` and `spawn()`.
|
||||
pub struct RecorderListener {
|
||||
recorder: Recorder,
|
||||
rx: broadcast::Receiver<StateChangedEvent>,
|
||||
}
|
||||
|
||||
impl RecorderListener {
|
||||
/// Create a listener. Subscribes to the broadcast channel immediately so
|
||||
/// events fired before `spawn()` are buffered in the receiver.
|
||||
pub fn new(state_machine: &StateMachine, recorder: Recorder) -> Self {
|
||||
let rx = state_machine.subscribe();
|
||||
Self { recorder, rx }
|
||||
}
|
||||
|
||||
/// Spawn the listener onto the Tokio runtime.
|
||||
///
|
||||
/// Returns a `JoinHandle`. Abort it on graceful shutdown:
|
||||
/// ```ignore
|
||||
/// let handle = listener.spawn();
|
||||
/// // … on shutdown:
|
||||
/// handle.abort();
|
||||
/// ```
|
||||
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move { self.run().await })
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
loop {
|
||||
match self.rx.recv().await {
|
||||
Ok(event) => {
|
||||
debug!(entity_id = %event.entity_id, "recording state change");
|
||||
if let Err(e) = self.recorder.record_state(&event).await {
|
||||
warn!(error = %e, "failed to record state change");
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(
|
||||
lagged_by = n,
|
||||
"recorder listener lagged — some state changes were not persisted"
|
||||
);
|
||||
// Continue processing from the next available event.
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("state machine shut down; recorder listener exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use homecore::entity::EntityId;
|
||||
use homecore::event::Context;
|
||||
|
||||
fn eid(s: &str) -> EntityId {
|
||||
EntityId::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listener_records_state_changes() {
|
||||
let sm = StateMachine::new();
|
||||
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
|
||||
|
||||
let listener = RecorderListener::new(&sm, recorder.clone());
|
||||
let _handle = listener.spawn();
|
||||
|
||||
// Fire two state changes.
|
||||
sm.set(eid("light.hall"), "on", serde_json::json!({}), Context::new());
|
||||
sm.set(eid("light.hall"), "off", serde_json::json!({}), Context::new());
|
||||
|
||||
// Give the background task a moment to flush.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let since = chrono::Utc::now() - chrono::Duration::seconds(10);
|
||||
let until = chrono::Utc::now() + chrono::Duration::seconds(10);
|
||||
let rows = recorder
|
||||
.get_state_history(&eid("light.hall"), since, until)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 2, "listener must have persisted both events");
|
||||
assert_eq!(rows[0].state, "on");
|
||||
assert_eq!(rows[1].state, "off");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
//! SQL DDL for the HA-compatible recorder schema (ADR-132).
|
||||
//!
|
||||
//! Schema mirrors Home Assistant recorder schema v48 (HA 2025.1):
|
||||
//! - `states` — one row per state write (entity_id, state, attrs)
|
||||
//! - `state_attributes` — shared attribute blobs, deduped by fnv64a hash
|
||||
//! - `events` — domain events fired by integrations
|
||||
//! - `recorder_runs` — boot/shutdown bookends for gap detection
|
||||
//!
|
||||
//! All DDL strings use `CREATE TABLE IF NOT EXISTS` so `apply_schema` is
|
||||
//! idempotent and safe to call on every startup.
|
||||
|
||||
/// Create `state_attributes` table.
|
||||
///
|
||||
/// `shared_attrs` is stored as TEXT (JSON blob). `hash` is the FNV-1a 64-bit
|
||||
/// hash of `shared_attrs` encoded as a signed i64 — matches HA's dedup key.
|
||||
pub const CREATE_STATE_ATTRIBUTES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS state_attributes (
|
||||
attributes_id INTEGER PRIMARY KEY NOT NULL,
|
||||
shared_attrs TEXT NOT NULL,
|
||||
hash INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_state_attributes_hash
|
||||
ON state_attributes (hash);
|
||||
";
|
||||
|
||||
/// Create `states` table.
|
||||
///
|
||||
/// `state_id` — auto-increment primary key
|
||||
/// `entity_id` — validated `domain.name` string
|
||||
/// `state` — state value string (\"on\", \"off\", \"20.5\", …)
|
||||
/// `attributes_id` — FK → state_attributes (nullable for HA compat)
|
||||
/// `last_changed_ts` — Unix timestamp seconds (float, UTC)
|
||||
/// `last_updated_ts` — Unix timestamp seconds (float, UTC)
|
||||
/// `context_id` — UUID as TEXT; links to the causality chain
|
||||
pub const CREATE_STATES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS states (
|
||||
state_id INTEGER PRIMARY KEY NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
state TEXT,
|
||||
attributes_id INTEGER,
|
||||
last_changed_ts REAL,
|
||||
last_updated_ts REAL NOT NULL,
|
||||
context_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_states_entity_id_last_updated_ts
|
||||
ON states (entity_id, last_updated_ts);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_states_last_updated_ts
|
||||
ON states (last_updated_ts);
|
||||
";
|
||||
|
||||
/// Create `events` table.
|
||||
///
|
||||
/// `event_type` — string key (e.g. \"state_changed\", \"call_service\")
|
||||
/// `event_data` — JSON blob
|
||||
/// `time_fired_ts` — Unix timestamp seconds (float, UTC)
|
||||
/// `context_id` — UUID as TEXT
|
||||
pub const CREATE_EVENTS: &str = "
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id INTEGER PRIMARY KEY NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_data TEXT,
|
||||
time_fired_ts REAL NOT NULL,
|
||||
context_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_events_event_type_time_fired_ts
|
||||
ON events (event_type, time_fired_ts);
|
||||
";
|
||||
|
||||
/// Create `recorder_runs` table.
|
||||
///
|
||||
/// Records each start/stop pair so the history API can annotate gaps.
|
||||
pub const CREATE_RECORDER_RUNS: &str = "
|
||||
CREATE TABLE IF NOT EXISTS recorder_runs (
|
||||
run_id INTEGER PRIMARY KEY NOT NULL,
|
||||
start_ts REAL NOT NULL,
|
||||
end_ts REAL
|
||||
);
|
||||
";
|
||||
|
||||
/// All DDL statements in dependency order.
|
||||
pub const ALL_DDL: &[&str] = &[
|
||||
CREATE_STATE_ATTRIBUTES,
|
||||
CREATE_STATES,
|
||||
CREATE_EVENTS,
|
||||
CREATE_RECORDER_RUNS,
|
||||
];
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
//! Ruvector-backed semantic index — ADR-132 P2.
|
||||
//!
|
||||
//! ## Embedding strategy (P2 — hash-based)
|
||||
//!
|
||||
//! To keep the recorder self-contained and avoid an ML model dependency at P2,
|
||||
//! state attributes are embedded by a deterministic SHA-256 hash procedure:
|
||||
//!
|
||||
//! 1. Canonicalise the state as `"{entity_id}={state}|{attributes_json}"`.
|
||||
//! 2. SHA-256 hash → 32 bytes.
|
||||
//! 3. Interpret the 32 bytes as 8 × `i32` (big-endian), cast to `f32`.
|
||||
//! 4. L2-normalise the resulting 8-element vector.
|
||||
//!
|
||||
//! This gives stable, reproducible 8-dimensional unit vectors suitable for
|
||||
//! cosine-distance HNSW search. Semantic similarity is **not** captured (two
|
||||
//! states with the same value but different entity IDs will differ). P3 will
|
||||
//! replace this with a learned sentence-embedding via `ruvector-attention`.
|
||||
//!
|
||||
//! ## P3 plan
|
||||
//!
|
||||
//! Replace `embed_bytes` with a call to
|
||||
//! `ruvector_attention::SentenceEmbedding::encode(&text)` for true semantic
|
||||
//! similarity. Increase `EMBEDDING_DIM` to 384 at that point.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use homecore::entity::State;
|
||||
use ruvector_core::{
|
||||
types::{DbOptions, DistanceMetric, HnswConfig, SearchQuery, VectorEntry},
|
||||
VectorDB,
|
||||
};
|
||||
|
||||
use crate::db::SemanticIndex;
|
||||
|
||||
/// Dimensionality of the hash-based embedding vectors.
|
||||
///
|
||||
/// 8 dimensions: each SHA-256 chunk of 4 bytes becomes one `f32` component.
|
||||
/// Increase to 384 in P3 when switching to learned embeddings.
|
||||
pub const EMBEDDING_DIM: usize = 8;
|
||||
|
||||
/// Ruvector-backed `SemanticIndex` using in-memory HNSW and hash embeddings.
|
||||
///
|
||||
/// The index lives entirely in process memory. A restart clears it; P3 will
|
||||
/// add persistence via `ruvector-core`'s `storage` feature.
|
||||
pub struct RuvectorSemanticIndex {
|
||||
db: VectorDB,
|
||||
}
|
||||
|
||||
impl RuvectorSemanticIndex {
|
||||
/// Create a new in-memory HNSW index with the given `max_elements` capacity.
|
||||
///
|
||||
/// Uses cosine distance to match the unit-normalised hash embeddings.
|
||||
pub fn new(max_elements: usize) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let options = DbOptions {
|
||||
dimensions: EMBEDDING_DIM,
|
||||
distance_metric: DistanceMetric::Cosine,
|
||||
// storage path is ignored when the `storage` feature is off
|
||||
storage_path: ":memory:".to_string(),
|
||||
hnsw_config: Some(HnswConfig {
|
||||
m: 16,
|
||||
ef_construction: 100,
|
||||
ef_search: 50,
|
||||
max_elements,
|
||||
}),
|
||||
quantization: None,
|
||||
};
|
||||
let db = VectorDB::new(options)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
/// Embed a `State` to a deterministic 8-dimensional unit vector.
|
||||
///
|
||||
/// Canonical form: `"{entity_id}={state}|{attributes_json}"`
|
||||
/// The attributes JSON is sorted-key (via `serde_json`'s default ordering
|
||||
/// of `Map`, which preserves insertion order). For strict canonicalisation
|
||||
/// at P3, sort keys explicitly.
|
||||
pub fn embed_state(state: &State) -> Vec<f32> {
|
||||
let attrs = state.attributes.to_string();
|
||||
let input = format!("{}={}|{}", state.entity_id, state.state, attrs);
|
||||
Self::embed_str(&input)
|
||||
}
|
||||
|
||||
/// Embed an arbitrary string to a deterministic 8-dimensional unit vector.
|
||||
pub fn embed_str(input: &str) -> Vec<f32> {
|
||||
embed_bytes(input.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 → 8 × f32 unit vector.
|
||||
///
|
||||
/// Split the 32-byte digest into 8 chunks of 4 bytes. Interpret each chunk
|
||||
/// as a big-endian `i32`, cast to `f32`, then L2-normalise.
|
||||
fn embed_bytes(data: &[u8]) -> Vec<f32> {
|
||||
let digest = Sha256::digest(data);
|
||||
let mut raw: Vec<f32> = digest
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| {
|
||||
let bytes: [u8; 4] = chunk.try_into().expect("chunk is exactly 4 bytes");
|
||||
i32::from_be_bytes(bytes) as f32
|
||||
})
|
||||
.collect();
|
||||
|
||||
// L2-normalise
|
||||
let norm = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for v in &mut raw {
|
||||
*v /= norm;
|
||||
}
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SemanticIndex for RuvectorSemanticIndex {
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
state_id: i64,
|
||||
state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let vector = Self::embed_state(state);
|
||||
let entry = VectorEntry {
|
||||
id: Some(state_id.to_string()),
|
||||
vector,
|
||||
metadata: None,
|
||||
};
|
||||
self.db.insert(entry)?;
|
||||
tracing::debug!(state_id, entity_id = %state.entity_id, "semantic index: inserted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let vector = Self::embed_str(query);
|
||||
let results = self.db.search(SearchQuery {
|
||||
vector,
|
||||
k,
|
||||
filter: None,
|
||||
ef_search: None,
|
||||
})?;
|
||||
let hits = results
|
||||
.into_iter()
|
||||
.filter_map(|r| r.id.parse::<i64>().ok().map(|id| (id, r.score)))
|
||||
.collect();
|
||||
Ok(hits)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
use super::*;
|
||||
use crate::db::{Recorder, SemanticIndex};
|
||||
|
||||
fn make_state(entity_id: &str, state_val: &str, attrs: serde_json::Value) -> State {
|
||||
let eid = EntityId::parse(entity_id).unwrap();
|
||||
let ctx = Context::new();
|
||||
State::new(eid, state_val, attrs, ctx)
|
||||
}
|
||||
|
||||
// ── embed_state ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn embed_state_is_deterministic() {
|
||||
let s = make_state("light.kitchen", "on", serde_json::json!({"brightness": 200}));
|
||||
let v1 = RuvectorSemanticIndex::embed_state(&s);
|
||||
let v2 = RuvectorSemanticIndex::embed_state(&s);
|
||||
assert_eq!(v1, v2, "same input must produce identical embedding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_state_is_unit_norm() {
|
||||
let s = make_state("sensor.temp", "22.5", serde_json::json!({"unit": "C"}));
|
||||
let v = RuvectorSemanticIndex::embed_state(&s);
|
||||
let norm_sq: f32 = v.iter().map(|x| x * x).sum();
|
||||
assert!(
|
||||
(norm_sq - 1.0).abs() < 1e-5,
|
||||
"embedding must be unit-norm, got norm^2={norm_sq}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_state_dim_is_correct() {
|
||||
let s = make_state("binary_sensor.door", "off", serde_json::json!({}));
|
||||
let v = RuvectorSemanticIndex::embed_state(&s);
|
||||
assert_eq!(v.len(), EMBEDDING_DIM);
|
||||
}
|
||||
|
||||
// ── RuvectorSemanticIndex insert + search ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_then_search_finds_state() {
|
||||
let mut idx = RuvectorSemanticIndex::new(1000).unwrap();
|
||||
let state = make_state("light.living_room", "on", serde_json::json!({"brightness": 255}));
|
||||
idx.insert_state(42, &state).await.unwrap();
|
||||
|
||||
// Query the same canonical string used by embed_state
|
||||
let query = format!(
|
||||
"{}={}|{}",
|
||||
state.entity_id, state.state, state.attributes
|
||||
);
|
||||
let hits = idx.search(&query, 5).await.unwrap();
|
||||
assert!(!hits.is_empty(), "search must return at least one hit");
|
||||
assert_eq!(hits[0].0, 42, "top hit must be the inserted state_id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_ordering_closer_entity_ranks_first() {
|
||||
let mut idx = RuvectorSemanticIndex::new(1000).unwrap();
|
||||
|
||||
let s_a = make_state("light.office", "on", serde_json::json!({"brightness": 100}));
|
||||
let s_b = make_state("switch.fan", "off", serde_json::json!({}));
|
||||
|
||||
idx.insert_state(1, &s_a).await.unwrap();
|
||||
idx.insert_state(2, &s_b).await.unwrap();
|
||||
|
||||
// Query identical to s_a's canonical form → s_a must rank first
|
||||
let query_a = format!("{}={}|{}", s_a.entity_id, s_a.state, s_a.attributes);
|
||||
let hits = idx.search(&query_a, 2).await.unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(
|
||||
hits[0].0, 1,
|
||||
"state matching the query must rank first; got {:?}",
|
||||
hits
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recorder end-to-end with RuvectorSemanticIndex ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorder_search_semantic_returns_recorded_state() {
|
||||
use homecore::event::StateChangedEvent;
|
||||
use chrono::Utc;
|
||||
|
||||
let idx = Arc::new(RwLock::new(
|
||||
RuvectorSemanticIndex::new(1000).unwrap(),
|
||||
));
|
||||
let semantic: Arc<RwLock<dyn SemanticIndex>> = idx;
|
||||
let recorder = Recorder::open_with_index("sqlite::memory:", semantic)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = Arc::new(make_state(
|
||||
"sensor.humidity",
|
||||
"65",
|
||||
serde_json::json!({"unit": "%"}),
|
||||
));
|
||||
let event = StateChangedEvent {
|
||||
entity_id: state.entity_id.clone(),
|
||||
old_state: None,
|
||||
new_state: Some(state.clone()),
|
||||
fired_at: Utc::now(),
|
||||
};
|
||||
let state_id = recorder.record_state(&event).await.unwrap().unwrap();
|
||||
|
||||
// Query using the entity prefix — close enough embedding to find it
|
||||
let query = format!("{}={}|{}", state.entity_id, state.state, state.attributes);
|
||||
let rows = recorder.search_semantic(&query, 5).await.unwrap();
|
||||
assert!(!rows.is_empty(), "search_semantic must return at least one row");
|
||||
assert_eq!(
|
||||
rows[0].state_id, state_id,
|
||||
"returned row must match the recorded state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# HOMECORE-SERVER — the integration binary that ties every HOMECORE
|
||||
# crate together into one process.
|
||||
#
|
||||
# Boots a HomeCore runtime, opens the SQLite recorder, mounts the
|
||||
# REST + WS API on :8123, initializes the plugin runtime, spins up
|
||||
# the automation engine subscribed to the state machine, and starts
|
||||
# the assist pipeline + HAP bridge surface.
|
||||
|
||||
[package]
|
||||
name = "homecore-server"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "HOMECORE integration server — wires HomeCore + API + Recorder + Plugins + Automation + Assist + HAP into one process"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[[bin]]
|
||||
name = "homecore-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The 8 HOMECORE crates this binary integrates
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
homecore-api = { path = "../homecore-api", version = "0.1.0-alpha.0" }
|
||||
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0" }
|
||||
homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0" }
|
||||
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
|
||||
homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" }
|
||||
homecore-assist = { path = "../homecore-assist", version = "0.1.0-alpha.0" }
|
||||
# Migration crate is CLI-only; not linked here.
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Pull in ruvector-backed semantic memory.
|
||||
ruvector = ["homecore-recorder/ruvector"]
|
||||
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
|
||||
wasmtime = ["homecore-plugins/wasmtime"]
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
//! `homecore-server` — the HOMECORE integration binary.
|
||||
//!
|
||||
//! Boots one process that exposes the full HA-compat surface:
|
||||
//!
|
||||
//! - HomeCore runtime (state machine + event bus + service registry)
|
||||
//! - SQLite recorder writing every state_changed event
|
||||
//! - REST + WebSocket API on :8123 (HA wire-compat)
|
||||
//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime)
|
||||
//! - Automation engine subscribed to the state machine
|
||||
//! - Assist pipeline (intent recognizer + handler set)
|
||||
//! - HAP bridge surface (accessories registered via the API)
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
//! cargo run -p homecore-server --bin homecore-server -- --bind 0.0.0.0:8123
|
||||
//!
|
||||
//! All-feature build with ruvector + wasmtime:
|
||||
//!
|
||||
//! cargo run -p homecore-server --features ruvector,wasmtime -- ...
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
||||
use homecore_assist::pipeline::default_pipeline;
|
||||
use homecore_assist::RegexIntentRecognizer;
|
||||
use homecore_automation::AutomationEngine;
|
||||
use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
|
||||
use homecore_plugins::{InProcessRuntime, PluginRegistry};
|
||||
use homecore_recorder::Recorder;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "homecore-server", version)]
|
||||
struct Cli {
|
||||
/// Bind address for the HA-compat REST + WS API.
|
||||
#[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
|
||||
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
|
||||
db: String,
|
||||
|
||||
/// Friendly location name surfaced via `/api/config`.
|
||||
#[arg(long, env = "HOMECORE_LOCATION", default_value = "Home")]
|
||||
location_name: String,
|
||||
|
||||
/// Disable the SQLite recorder for low-resource deployments.
|
||||
#[arg(long)]
|
||||
no_recorder: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing();
|
||||
let cli = Cli::parse();
|
||||
|
||||
info!("HOMECORE booting — bind={}, db={}, location={:?}",
|
||||
cli.bind, cli.db, cli.location_name);
|
||||
|
||||
// ── 1. HomeCore runtime ─────────────────────────────────────────
|
||||
let hc = HomeCore::new();
|
||||
info!("HomeCore state machine + event bus + service registry online");
|
||||
|
||||
// ── 2. Recorder (optional) ──────────────────────────────────────
|
||||
if !cli.no_recorder {
|
||||
match Recorder::open(&cli.db).await {
|
||||
Ok(recorder) => {
|
||||
let recorder = Arc::new(recorder);
|
||||
let rec_clone = Arc::clone(&recorder);
|
||||
let mut state_rx = hc.states().subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = state_rx.recv().await {
|
||||
if let Err(e) = rec_clone.record_state(&event).await {
|
||||
warn!("recorder write failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
info!("Recorder open at {} — state_changed events being persisted", cli.db);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Recorder failed to open ({e}) — continuing without persistence");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Recorder disabled by --no-recorder");
|
||||
}
|
||||
|
||||
// ── 3. Plugin runtime ───────────────────────────────────────────
|
||||
let plugin_runtime = InProcessRuntime;
|
||||
let plugin_registry: PluginRegistry<InProcessRuntime> = PluginRegistry::new(plugin_runtime);
|
||||
info!("Plugin registry ready (runtime: InProcess; Wasmtime available with --features wasmtime)");
|
||||
let _ = plugin_registry; // wired-but-empty at boot; integrations register here
|
||||
|
||||
// ── 4. Automation engine ────────────────────────────────────────
|
||||
let _automation_engine = AutomationEngine::new(hc.clone());
|
||||
info!("Automation engine ready (no automations loaded yet)");
|
||||
|
||||
// ── 5. Assist pipeline ──────────────────────────────────────────
|
||||
let recognizer = RegexIntentRecognizer::new();
|
||||
let pipeline = default_pipeline(recognizer);
|
||||
info!("Assist pipeline ready (5 built-in intent handlers via default_pipeline)");
|
||||
let _ = pipeline; // wired-but-idle at boot; voice WS plugs in here
|
||||
|
||||
// ── 6. HAP bridge surface ───────────────────────────────────────
|
||||
let hap_record = HapServiceRecord {
|
||||
instance_name: "HOMECORE".to_string(),
|
||||
port: 51826,
|
||||
setup_code: "123-45-678".to_string(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".to_string(),
|
||||
};
|
||||
let hap_bridge = HapBridge::new(hap_record);
|
||||
info!("HAP bridge surface ready ({} accessories registered)", hap_bridge.running_accessories().len());
|
||||
let _ = hap_bridge;
|
||||
|
||||
// ── 7. REST + WS API ────────────────────────────────────────────
|
||||
// Token provisioning closes audit findings HC-01/HC-02. If
|
||||
// HOMECORE_TOKENS is set in the env, populate the store from
|
||||
// its comma-separated list. Otherwise fall back to DEV mode
|
||||
// (warn-on-each-request) so existing smoke tests still work.
|
||||
let tokens = if std::env::var("HOMECORE_TOKENS").map(|v| !v.trim().is_empty()).unwrap_or(false) {
|
||||
let s = LongLivedTokenStore::from_env();
|
||||
let n = s.len().await;
|
||||
info!("LongLivedTokenStore provisioned with {} bearer token(s) from HOMECORE_TOKENS", n);
|
||||
s
|
||||
} else {
|
||||
warn!("HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer accepted). Provision real tokens before exposing to the network.");
|
||||
LongLivedTokenStore::allow_any_non_empty()
|
||||
};
|
||||
let api_state = SharedState::with_tokens(
|
||||
hc.clone(),
|
||||
cli.location_name,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
tokens,
|
||||
);
|
||||
let app = router(api_state);
|
||||
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
||||
|
||||
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# HOMECORE — Rust state machine, event bus, service registry, entity registry.
|
||||
# Implements ADR-127 (HOMECORE-CORE), the foundation of the HOMECORE Home
|
||||
# Assistant port (ADR-126 master + ADR-128/129/130/131/132/133/134 sub-ADRs).
|
||||
#
|
||||
# P1 scaffold (this commit): public types + DashMap-backed state machine +
|
||||
# Tokio broadcast event bus + minimal entity registry. Persistence and the
|
||||
# full HA-compat serde schema land in P2.
|
||||
|
||||
[package]
|
||||
name = "homecore"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Rust state machine + event bus + service registry — the foundation of the HOMECORE Home Assistant port (ADR-127)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Core async runtime — matches the rest of the v2/ workspace (sensing-server etc.)
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
# DashMap for the concurrent state store — ADR-127 §2.1.
|
||||
dashmap = "6"
|
||||
# Typed event channels + service handler boxing
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
# Time types matched to HA's UTC datetime usage
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
# Schema validation replacement for voluptuous (ADR-127 §3)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Unique IDs (Context, ConfigEntryId, DeviceId)
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
# Read-only static catalogs (event type names etc.)
|
||||
once_cell = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "state_machine"
|
||||
harness = false
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
//! Criterion benchmarks for the HOMECORE state-machine hot paths.
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
//! cargo bench -p homecore --bench state_machine
|
||||
//!
|
||||
//! Hot paths covered:
|
||||
//! - `set` first-time-write (cold path: insert + allocate + broadcast)
|
||||
//! - `set` repeat-write (warm path: same entity, fires broadcast)
|
||||
//! - `set` no-op (suppress path: same state + same attrs, no broadcast)
|
||||
//! - `get` (zero-copy Arc<State> clone)
|
||||
//! - `all` snapshot (allocates Vec; REST GET /api/states path)
|
||||
//! - `all_by_domain` filter
|
||||
//! - Broadcast fan-out: 1 sender + N subscribers
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use homecore::{Context, EntityId, StateMachine};
|
||||
|
||||
fn bench_set_first_write(c: &mut Criterion) {
|
||||
let mut g = c.benchmark_group("set");
|
||||
g.throughput(Throughput::Elements(1));
|
||||
g.bench_function("first_write", |b| {
|
||||
b.iter_with_setup(
|
||||
|| (StateMachine::new(), EntityId::parse("light.benchmark").unwrap()),
|
||||
|(sm, id)| {
|
||||
sm.set(
|
||||
id,
|
||||
black_box("on"),
|
||||
black_box(serde_json::json!({"brightness": 200})),
|
||||
Context::new(),
|
||||
)
|
||||
},
|
||||
)
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_set_warm_write(c: &mut Criterion) {
|
||||
let sm = StateMachine::new();
|
||||
let id = EntityId::parse("light.benchmark").unwrap();
|
||||
// Prime the entry
|
||||
sm.set(id.clone(), "off", serde_json::json!({}), Context::new());
|
||||
|
||||
let mut g = c.benchmark_group("set");
|
||||
g.throughput(Throughput::Elements(1));
|
||||
g.bench_function("warm_write_state_change", |b| {
|
||||
let mut toggle = false;
|
||||
b.iter(|| {
|
||||
toggle = !toggle;
|
||||
let v = if toggle { "on" } else { "off" };
|
||||
sm.set(
|
||||
id.clone(),
|
||||
black_box(v),
|
||||
black_box(serde_json::json!({"toggle": toggle})),
|
||||
Context::new(),
|
||||
)
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_set_noop(c: &mut Criterion) {
|
||||
let sm = StateMachine::new();
|
||||
let id = EntityId::parse("light.benchmark").unwrap();
|
||||
sm.set(id.clone(), "on", serde_json::json!({"brightness": 200}), Context::new());
|
||||
|
||||
let mut g = c.benchmark_group("set");
|
||||
g.throughput(Throughput::Elements(1));
|
||||
g.bench_function("noop_suppressed", |b| {
|
||||
b.iter(|| {
|
||||
sm.set(
|
||||
id.clone(),
|
||||
black_box("on"),
|
||||
black_box(serde_json::json!({"brightness": 200})),
|
||||
Context::new(),
|
||||
)
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_get(c: &mut Criterion) {
|
||||
let sm = StateMachine::new();
|
||||
let id = EntityId::parse("sensor.temperature").unwrap();
|
||||
sm.set(id.clone(), "20.5", serde_json::json!({"unit": "C"}), Context::new());
|
||||
|
||||
let mut g = c.benchmark_group("get");
|
||||
g.throughput(Throughput::Elements(1));
|
||||
g.bench_function("hit", |b| {
|
||||
b.iter(|| {
|
||||
let _ = black_box(sm.get(&id));
|
||||
});
|
||||
});
|
||||
g.bench_function("miss", |b| {
|
||||
let missing = EntityId::parse("sensor.missing").unwrap();
|
||||
b.iter(|| {
|
||||
let _ = black_box(sm.get(&missing));
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_all_snapshot(c: &mut Criterion) {
|
||||
let mut g = c.benchmark_group("all_snapshot");
|
||||
for n_entities in [10, 100, 1000].iter() {
|
||||
let sm = StateMachine::new();
|
||||
for i in 0..*n_entities {
|
||||
let id = EntityId::parse(format!("sensor.entity_{}", i)).unwrap();
|
||||
sm.set(id, "on", serde_json::json!({"i": i}), Context::new());
|
||||
}
|
||||
g.throughput(Throughput::Elements(*n_entities as u64));
|
||||
g.bench_with_input(
|
||||
BenchmarkId::from_parameter(n_entities),
|
||||
n_entities,
|
||||
|b, _| {
|
||||
b.iter(|| black_box(sm.all()));
|
||||
},
|
||||
);
|
||||
}
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_all_by_domain(c: &mut Criterion) {
|
||||
let sm = StateMachine::new();
|
||||
// 100 entities split across 5 domains
|
||||
for i in 0..100 {
|
||||
let domain = match i % 5 {
|
||||
0 => "light",
|
||||
1 => "sensor",
|
||||
2 => "switch",
|
||||
3 => "binary_sensor",
|
||||
_ => "automation",
|
||||
};
|
||||
let id = EntityId::parse(format!("{}.e_{}", domain, i)).unwrap();
|
||||
sm.set(id, "on", serde_json::json!({}), Context::new());
|
||||
}
|
||||
|
||||
c.bench_function("all_by_domain_light_20_of_100", |b| {
|
||||
b.iter(|| black_box(sm.all_by_domain("light")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_broadcast_fan_out(c: &mut Criterion) {
|
||||
let rt = Runtime::new().unwrap();
|
||||
let mut g = c.benchmark_group("broadcast_fan_out");
|
||||
for n_subscribers in [1, 4, 16, 64].iter() {
|
||||
g.throughput(Throughput::Elements(*n_subscribers as u64));
|
||||
g.bench_with_input(
|
||||
BenchmarkId::from_parameter(n_subscribers),
|
||||
n_subscribers,
|
||||
|b, &n| {
|
||||
b.iter_custom(|iters| {
|
||||
rt.block_on(async {
|
||||
let sm = StateMachine::new();
|
||||
let id = Arc::new(EntityId::parse("light.fanout").unwrap());
|
||||
|
||||
// Spawn N subscribers
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..n {
|
||||
let mut rx = sm.subscribe();
|
||||
handles.push(tokio::spawn(async move {
|
||||
for _ in 0..iters {
|
||||
let _ = rx.recv().await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
for i in 0..iters {
|
||||
let v = if i % 2 == 0 { "on" } else { "off" };
|
||||
sm.set(
|
||||
(*id).clone(),
|
||||
v,
|
||||
serde_json::json!({"i": i}),
|
||||
Context::new(),
|
||||
);
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
start.elapsed()
|
||||
})
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = state_machine;
|
||||
config = Criterion::default().sample_size(20);
|
||||
targets = bench_set_first_write,
|
||||
bench_set_warm_write,
|
||||
bench_set_noop,
|
||||
bench_get,
|
||||
bench_all_snapshot,
|
||||
bench_all_by_domain,
|
||||
bench_broadcast_fan_out
|
||||
}
|
||||
criterion_main!(state_machine);
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
//! Event bus — typed system events + untyped domain events.
|
||||
//!
|
||||
//! ADR-127 §2.2: HA's single dict-typed event channel becomes two:
|
||||
//! - typed `SystemEvent` channel for known shapes (recorder, automation)
|
||||
//! - untyped `DomainEvent` channel for arbitrary integration events
|
||||
//!
|
||||
//! Capacity 4,096 on both. Lagged receivers must re-sync (recorder
|
||||
//! re-reads current state; automation re-evaluates triggers).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::event::{DomainEvent, SystemEvent};
|
||||
|
||||
pub const EVENT_CHANNEL_CAPACITY: usize = 4096;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EventBus {
|
||||
inner: Arc<EventBusInner>,
|
||||
}
|
||||
|
||||
struct EventBusInner {
|
||||
system_tx: broadcast::Sender<SystemEvent>,
|
||||
domain_tx: broadcast::Sender<DomainEvent>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn new() -> Self {
|
||||
let (system_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
let (domain_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
inner: Arc::new(EventBusInner { system_tx, domain_tx }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe_system(&self) -> broadcast::Receiver<SystemEvent> {
|
||||
self.inner.system_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn subscribe_domain(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
self.inner.domain_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Fire a typed system event. Returns the number of active
|
||||
/// receivers (zero is fine).
|
||||
pub fn fire_system(&self, event: SystemEvent) -> usize {
|
||||
self.inner.system_tx.send(event).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Fire an untyped domain event. Mirrors `hass.bus.async_fire`.
|
||||
pub fn fire_domain(&self, event: DomainEvent) -> usize {
|
||||
self.inner.domain_tx.send(event).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::event::Context;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fire_system_reaches_subscriber() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe_system();
|
||||
bus.fire_system(SystemEvent::HomeCoreStarted);
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert!(matches!(event, SystemEvent::HomeCoreStarted));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fire_domain_reaches_subscriber() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe_domain();
|
||||
bus.fire_domain(DomainEvent::new(
|
||||
"ruview_csi_frame",
|
||||
serde_json::json!({"frame_id": 42}),
|
||||
Context::new(),
|
||||
));
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(event.event_type, "ruview_csi_frame");
|
||||
assert_eq!(event.event_data["frame_id"], 42);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
//! Entity ID newtype + immutable state snapshot type.
|
||||
//!
|
||||
//! Mirrors `homeassistant/core.py` `State` and the `entity_id` string
|
||||
//! validation that every public HA call performs.
|
||||
//!
|
||||
//! ## EntityId validation (ADR-127 §2.1 + Q1)
|
||||
//!
|
||||
//! HA accepts unicode entity IDs since 2024.3. HOMECORE P1 accepts the
|
||||
//! ASCII subset `[a-z0-9_]+\.[a-z0-9_]+` and rejects everything else
|
||||
//! with a clear error. Unicode acceptance is deferred to P2 once the
|
||||
//! Q1 strictness decision is made (see ADR-127 §8).
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::event::Context;
|
||||
|
||||
/// Validated `domain.name` entity identifier.
|
||||
///
|
||||
/// Construct via [`EntityId::parse`] or [`EntityId::new`]; both validate
|
||||
/// against the format `[a-z0-9_]+\.[a-z0-9_]+`. Custom `Serialize` /
|
||||
/// `Deserialize` round-trips as a plain JSON string (matching HA's wire
|
||||
/// format) and re-validates on deserialize so invalid IDs from disk
|
||||
/// fail at load time rather than at first use.
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub struct EntityId(Arc<str>);
|
||||
|
||||
impl Serialize for EntityId {
|
||||
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
|
||||
ser.serialize_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EntityId {
|
||||
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(de)?;
|
||||
EntityId::parse(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityId {
|
||||
/// Validates and constructs an `EntityId`. Returns
|
||||
/// [`EntityIdError`] if the input is not `domain.name` shape with
|
||||
/// ASCII lowercase / digits / underscore in each segment.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, EntityIdError> {
|
||||
let s: String = s.into();
|
||||
let (domain, name) = s
|
||||
.split_once('.')
|
||||
.ok_or_else(|| EntityIdError::MissingDot(s.clone()))?;
|
||||
if domain.is_empty() {
|
||||
return Err(EntityIdError::EmptyDomain(s));
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err(EntityIdError::EmptyName(s));
|
||||
}
|
||||
for ch in domain.chars().chain(name.chars()) {
|
||||
if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') {
|
||||
return Err(EntityIdError::InvalidChar { entity_id: s, ch });
|
||||
}
|
||||
}
|
||||
Ok(Self(Arc::from(s)))
|
||||
}
|
||||
|
||||
/// Same as [`Self::parse`] but takes a `&str` and returns
|
||||
/// `Result<&'static EntityId, ...>` for constant entity IDs known
|
||||
/// at compile time. Used by ADR-128 plugins to register fixed-name
|
||||
/// services like `homeassistant.restart`.
|
||||
pub fn new(s: &str) -> Result<Self, EntityIdError> {
|
||||
Self::parse(s.to_owned())
|
||||
}
|
||||
|
||||
/// Returns the `domain` part (everything before the first `.`).
|
||||
pub fn domain(&self) -> &str {
|
||||
self.0.split_once('.').map(|(d, _)| d).unwrap_or(&self.0)
|
||||
}
|
||||
|
||||
/// Returns the `name` part (everything after the first `.`).
|
||||
pub fn name(&self) -> &str {
|
||||
self.0.split_once('.').map(|(_, n)| n).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Underlying string view.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EntityId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "EntityId({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EntityId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Clone, Eq, PartialEq)]
|
||||
pub enum EntityIdError {
|
||||
#[error("entity_id {0:?} is missing the required '.' between domain and name")]
|
||||
MissingDot(String),
|
||||
#[error("entity_id {0:?} has an empty domain segment")]
|
||||
EmptyDomain(String),
|
||||
#[error("entity_id {0:?} has an empty name segment")]
|
||||
EmptyName(String),
|
||||
#[error("entity_id {entity_id:?} contains invalid character {ch:?} — only [a-z0-9_] allowed (HA-compat ASCII subset; see ADR-127 §Q1)")]
|
||||
InvalidChar { entity_id: String, ch: char },
|
||||
}
|
||||
|
||||
/// Immutable state snapshot for one entity at one moment in time.
|
||||
///
|
||||
/// Mirrors `homeassistant.core.State`. Reader-cloneable via `Arc<State>`;
|
||||
/// writers atomically replace the entry in the `DashMap` so observers
|
||||
/// never see a partial mutation.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub entity_id: EntityId,
|
||||
pub state: String,
|
||||
/// Attribute bag — accepts whatever JSON the integration emits.
|
||||
/// Mirrors HA's `Dict[str, Any]` attribute model.
|
||||
pub attributes: serde_json::Value,
|
||||
/// When the `state` field last changed value. Only bumped if the
|
||||
/// new state string differs from the old; attribute-only updates
|
||||
/// preserve this timestamp.
|
||||
pub last_changed: DateTime<Utc>,
|
||||
/// When this snapshot was written. Bumped on every `set` call,
|
||||
/// including attribute-only updates.
|
||||
pub last_updated: DateTime<Utc>,
|
||||
/// Causality context — links state changes to the user / automation
|
||||
/// / service call that originated them. Mirrors HA's `Context`.
|
||||
pub context: Context,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Construct a fresh state snapshot at `now`.
|
||||
pub fn new(
|
||||
entity_id: EntityId,
|
||||
state: impl Into<String>,
|
||||
attributes: serde_json::Value,
|
||||
context: Context,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
entity_id,
|
||||
state: state.into(),
|
||||
attributes,
|
||||
last_changed: now,
|
||||
last_updated: now,
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the next state snapshot. If the new `state` string
|
||||
/// equals the prior `state`, `last_changed` is preserved.
|
||||
pub fn next(
|
||||
&self,
|
||||
new_state: impl Into<String>,
|
||||
new_attributes: serde_json::Value,
|
||||
context: Context,
|
||||
) -> Self {
|
||||
let new_state = new_state.into();
|
||||
let now = Utc::now();
|
||||
let last_changed = if new_state == self.state {
|
||||
self.last_changed
|
||||
} else {
|
||||
now
|
||||
};
|
||||
Self {
|
||||
entity_id: self.entity_id.clone(),
|
||||
state: new_state,
|
||||
attributes: new_attributes,
|
||||
last_changed,
|
||||
last_updated: now,
|
||||
context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entity_id_parses_valid() {
|
||||
let e = EntityId::parse("light.living_room").unwrap();
|
||||
assert_eq!(e.domain(), "light");
|
||||
assert_eq!(e.name(), "living_room");
|
||||
assert_eq!(e.as_str(), "light.living_room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_id_rejects_missing_dot() {
|
||||
assert!(matches!(
|
||||
EntityId::parse("light_living_room"),
|
||||
Err(EntityIdError::MissingDot(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_id_rejects_uppercase() {
|
||||
let err = EntityId::parse("light.LivingRoom").unwrap_err();
|
||||
match err {
|
||||
EntityIdError::InvalidChar { ch, .. } => assert_eq!(ch, 'L'),
|
||||
other => panic!("expected InvalidChar, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_id_rejects_unicode() {
|
||||
// ADR-127 §Q1 — P1 is strict ASCII. Unicode acceptance deferred.
|
||||
assert!(EntityId::parse("light.küche").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_next_preserves_last_changed_when_state_unchanged() {
|
||||
let id = EntityId::parse("sensor.temp").unwrap();
|
||||
let s1 = State::new(id.clone(), "20.0", serde_json::json!({}), Context::default());
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
let s2 = s1.next("20.0", serde_json::json!({"updated": true}), Context::default());
|
||||
assert_eq!(s1.last_changed, s2.last_changed);
|
||||
assert!(s2.last_updated > s1.last_updated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_next_bumps_last_changed_when_state_changes() {
|
||||
let id = EntityId::parse("sensor.temp").unwrap();
|
||||
let s1 = State::new(id, "20.0", serde_json::json!({}), Context::default());
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
let s2 = s1.next("21.0", serde_json::json!({}), Context::default());
|
||||
assert!(s2.last_changed > s1.last_changed);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
//! Typed system events + untyped domain events + Context.
|
||||
//!
|
||||
//! Mirrors `homeassistant.core.EventBus` + `homeassistant.const.EVENT_*`
|
||||
//! constants. ADR-127 §2.2 splits HA's single dict-typed event channel
|
||||
//! into two: a typed system channel (zero-allocation read path) and a
|
||||
//! json-blob domain channel (for arbitrary integration-fired events).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::{EntityId, State};
|
||||
|
||||
/// Well-known HA event-type string constants.
|
||||
///
|
||||
/// Mirrors `homeassistant/const.py` `EVENT_*` constants. Used by
|
||||
/// integrations that fire untyped [`DomainEvent`]s.
|
||||
#[non_exhaustive]
|
||||
pub struct EventType;
|
||||
|
||||
impl EventType {
|
||||
pub const STATE_CHANGED: &'static str = "state_changed";
|
||||
pub const SERVICE_REGISTERED: &'static str = "service_registered";
|
||||
pub const SERVICE_REMOVED: &'static str = "service_removed";
|
||||
pub const CALL_SERVICE: &'static str = "call_service";
|
||||
pub const COMPONENT_LOADED: &'static str = "component_loaded";
|
||||
pub const PLATFORM_DISCOVERED: &'static str = "platform_discovered";
|
||||
pub const HOMEASSISTANT_START: &'static str = "homeassistant_start";
|
||||
pub const HOMEASSISTANT_STARTED: &'static str = "homeassistant_started";
|
||||
pub const HOMEASSISTANT_STOP: &'static str = "homeassistant_stop";
|
||||
pub const HOMEASSISTANT_FINAL_WRITE: &'static str = "homeassistant_final_write";
|
||||
pub const HOMEASSISTANT_CLOSE: &'static str = "homeassistant_close";
|
||||
}
|
||||
|
||||
/// Causality context for a state change or service call.
|
||||
///
|
||||
/// Mirrors `homeassistant.core.Context`. Used by automations to detect
|
||||
/// loops ("don't re-fire on a state change my own automation caused")
|
||||
/// and by the recorder (ADR-132) to attribute changes to users.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub struct Context {
|
||||
pub id: Uuid,
|
||||
pub user_id: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_user(user_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: Some(user_id.into()),
|
||||
parent_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn child_of(parent: &Context) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: parent.user_id.clone(),
|
||||
parent_id: Some(parent.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Context {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: None,
|
||||
parent_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed enum of system events. Subscribers that only care about a
|
||||
/// specific shape (the recorder, the websocket subscriber) can match on
|
||||
/// the variant without going through `serde_json::Value`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SystemEvent {
|
||||
StateChanged(StateChangedEvent),
|
||||
ServiceRegistered { domain: String, service: String },
|
||||
ServiceRemoved { domain: String, service: String },
|
||||
ComponentLoaded { component: String },
|
||||
HomeCoreStart,
|
||||
HomeCoreStarted,
|
||||
HomeCoreStop,
|
||||
}
|
||||
|
||||
/// State-change event payload. Carries the old and new snapshots so a
|
||||
/// subscriber doesn't need to read the state machine again to learn
|
||||
/// what changed.
|
||||
///
|
||||
/// Mirrors HA's event_data `{ entity_id, old_state, new_state }`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StateChangedEvent {
|
||||
pub entity_id: EntityId,
|
||||
pub old_state: Option<Arc<State>>,
|
||||
pub new_state: Option<Arc<State>>,
|
||||
pub fired_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Untyped event fired by integrations. Mirrors HA's
|
||||
/// `EventBus.async_fire(event_type, event_data)`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DomainEvent {
|
||||
pub event_type: String,
|
||||
pub event_data: serde_json::Value,
|
||||
pub origin: EventOrigin,
|
||||
pub context: Context,
|
||||
pub fired_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Where an event originated. Mirrors HA's `EventOrigin` enum (`local`
|
||||
/// vs `remote`).
|
||||
#[derive(Clone, Debug, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EventOrigin {
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl DomainEvent {
|
||||
pub fn new(
|
||||
event_type: impl Into<String>,
|
||||
event_data: serde_json::Value,
|
||||
context: Context,
|
||||
) -> Self {
|
||||
Self {
|
||||
event_type: event_type.into(),
|
||||
event_data,
|
||||
origin: EventOrigin::Local,
|
||||
context,
|
||||
fired_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn context_child_inherits_user_id() {
|
||||
let parent = Context::with_user("alice");
|
||||
let child = Context::child_of(&parent);
|
||||
assert_eq!(child.user_id.as_deref(), Some("alice"));
|
||||
assert_eq!(child.parent_id, Some(parent.id));
|
||||
assert_ne!(child.id, parent.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_type_constants_match_ha_names() {
|
||||
// These string values are wire-format with HA — must match
|
||||
// exactly so ADR-130 can serve a wire-compat WebSocket API.
|
||||
assert_eq!(EventType::STATE_CHANGED, "state_changed");
|
||||
assert_eq!(EventType::HOMEASSISTANT_START, "homeassistant_start");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue