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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs ADR-125 §2.1.c.

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

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

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

Response shape:

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

Live response from real C6 (node_id=12):

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

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

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

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

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

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

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

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

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

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

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

12 MCP tools auto-discovered:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

194 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-127: HOMECORE-CORE — Rust state machine, entity registry, event bus, service registry
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-CORE** |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (RUVIEW-POLICY) |
| **Tracking issue** | TBD |
---
## 1. Context
`homeassistant/core.py` is the 3,200-line heart of Python Home Assistant. It defines five objects that every other HA component depends on:
1. **`HomeAssistant`** — the runtime coordinator, event loop holder, and service locator. Contains `bus` (EventBus), `states` (StateMachine), `services` (ServiceRegistry), `config` (Config), `components` (loaded component set).
2. **`EventBus`** — publish/subscribe event dispatch. `async_fire(event_type, event_data)` dispatches to all registered listeners. Listener registration is `async_listen(event_type, callback)`. Wildcard listener is `MATCH_ALL`. Event data is a plain Python dict.
3. **`StateMachine`** — an in-memory dictionary from `entity_id` (str) to `State`. `async_set(entity_id, new_state, attributes)` writes and fires `state_changed`. `get(entity_id)` reads. `async_remove(entity_id)` fires `state_removed`. States are immutable snapshots with `last_changed`, `last_updated`, `context`.
4. **`ServiceRegistry`** — maps `(domain, service_name)` → async handler function. `async_call(domain, service, data)` fires a `call_service` event, waits for the registered handler. `async_register(domain, service, handler, schema)` registers a handler with optional voluptuous schema validation.
5. **`EntityRegistry`** (`homeassistant/helpers/entity_registry.py`) — persists metadata (enabled/disabled, name override, area assignment, device ID, unique ID, entity category) across restarts. Stored in `.storage/core.entity_registry`. Loaded at startup; written on every change.
The **DeviceRegistry** (`homeassistant/helpers/device_registry.py`, stored in `.storage/core.device_registry`) tracks physical devices that entities belong to. Entities link to devices via `device_id`; devices link to config entries via `config_entry_id`.
### 1.1 Why these specific files matter
Python HA's `core.py` is a single-process Python 3.12 module that:
- Holds the asyncio event loop directly
- Serialises all state-changed writes through `asyncio.Lock`
- Fires event listeners in the same event loop iteration that fired the event (listeners cannot block)
- Is single-threaded by design — concurrent writes to the state machine are impossible without explicit async primitives
For HOMECORE the same semantic requirements apply, but the implementation must support:
- **Concurrent reads** from dozens of integration WASM sandboxes polling current state
- **High-frequency writes** from the RuView sensing stack (CSI at 100 Hz; state updates at up to 20 Hz per entity)
- **Ordered delivery** of state_changed events to automation triggers (ADR-129) and recorder (ADR-132) subscribers
- **Zero-copy reads** where possible for the REST API (ADR-130) path
---
## 2. Decision
Implement the `homecore` Rust crate at `v2/crates/homecore/` with the following design.
### 2.1 State machine: `DashMap` + Tokio broadcast
The primary state store is a `DashMap<EntityId, Arc<State>>` where:
- `EntityId` is a validated newtype around `String` (validated format: `domain.name`)
- `State` is a frozen struct: `entity_id`, `state` (String), `attributes` (serde_json::Value), `last_changed` (DateTime<Utc>), `last_updated` (DateTime<Utc>), `context` (Context)
- `Arc<State>` allows zero-copy cloning for readers while the writer atomically replaces the map entry
State changes are published to a `tokio::sync::broadcast::Sender<StateChangedEvent>` channel (capacity: 4,096 events). Any number of receivers subscribe — the recorder, automation engine, WebSocket subscriber handler, and ruvector dual-write task all hold independent receivers. Slow receivers that fall behind by 4,096 events receive a `RecvError::Lagged` and must re-sync from the current state map.
### 2.2 Event bus: typed + untyped channels
HOMECORE distinguishes two event categories:
1. **System events** (typed): `StateChanged`, `ServiceCall`, `ComponentLoaded`, `PlatformDiscovered`, `HomeAssistantStart`, `HomeAssistantStop`. These use Tokio typed broadcast channels with zero allocation on the read path.
2. **Integration events** (untyped): integrations fire arbitrary event types (`event_type: String`, `event_data: serde_json::Value`). These use a single `broadcast::Sender<DomainEvent>` where `DomainEvent` carries the type string and data blob. This mirrors HA's `EventBus.async_fire()`.
### 2.3 Service registry: `HashMap` + mpsc dispatch
Services are registered as `(Domain, ServiceName) → ServiceHandler` where `ServiceHandler` is a `Box<dyn Fn(ServiceCall) -> BoxFuture<ServiceResponse> + Send + Sync>`. The registry lives in a `tokio::sync::RwLock<HashMap<(Domain, ServiceName), ServiceHandler>>`. Service calls go through the event bus (fire `call_service`) and are dispatched to the handler by an internal router task. This matches HA's indirection: `hass.services.async_call(domain, service, data)` does not call the handler directly; it fires an event.
### 2.4 Entity registry: persisted metadata sidecar
The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an async JSON writer that flushes to `.homecore/storage/core.entity_registry` on every write. The schema matches HA's `core.entity_registry` schema (version 13 as of HA 2025.1) so ADR-134 migration can read both formats interchangeably.
`EntityEntry` fields mirrored from HA:
- `entity_id: EntityId`
- `unique_id: Option<String>`
- `platform: String`
- `name: Option<String>` (user override)
- `disabled_by: Option<DisabledBy>` (user, integration, config_entry)
- `area_id: Option<AreaId>`
- `device_id: Option<DeviceId>`
- `entity_category: Option<EntityCategory>` (config, diagnostic)
- `config_entry_id: Option<ConfigEntryId>`
### 2.5 Device registry: parallel sidecar
`DeviceRegistry` mirrors HA's `core.device_registry` schema (version 13). Devices are identified by a set of `(id_type, id_value)` tuples (the `identifiers` field), which matches HA's pattern of accepting multiple identifier types per device (MAC address, serial number, integration-specific ID).
---
## 3. HA-side reference table
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
| `homeassistant/core.py` `StateMachine` | In-memory state store, fire `state_changed` | Same semantics: immutable snapshots, `last_changed`, `last_updated`, `context` | `DashMap` instead of asyncio-locked `dict`; `broadcast::Sender` instead of asyncio callbacks | Python asyncio coupling |
| `homeassistant/core.py` `EventBus` | Pub/sub event dispatch | `MATCH_ALL` listener; per-type listener; event data dict | Typed system events + untyped domain events; no Python dict — use `serde_json::Value` | `@callback` decorator, HassJob abstraction |
| `homeassistant/core.py` `ServiceRegistry` | Register/call services | Same `(domain, service)` key structure; schema validation | Schema validation via `serde` `Deserialize` trait instead of voluptuous | voluptuous, Python type coercions |
| `homeassistant/core.py` `HomeAssistant` | Runtime coordinator / service locator | State machine + event bus + services accessible on one struct | Struct with `Arc<HomeCoreInner>` for cheap cloning across tasks | asyncio event loop holder, Python executor |
| `homeassistant/helpers/entity_registry.py` | Persist entity metadata | All fields listed in §2.4; file format compatible | Async tokio I/O; no Python pickle | Python-specific persistence helpers |
| `homeassistant/helpers/device_registry.py` | Persist device metadata | `identifiers`, `connections`, `manufacturer`, `model`, `name`, `via_device_id` | Async tokio I/O | — |
| `homeassistant/helpers/entity.py` | Entity base class | `entity_id`, `state`, `attributes`, `unique_id`, `device_info`, async_write_ha_state semantics | Trait `HomeCoreEntity` instead of class | Python MRO, `@property` decorators |
| `homeassistant/helpers/event.py` | Convenience event helpers | `async_track_state_change`, `async_track_time_interval` (as Rust timer tasks) | Rust closures / async tasks | Python asyncio task wrappers |
---
## 4. Public API parity table
| HA Python surface | HOMECORE Rust equivalent |
|---|---|
| `hass.states.get(entity_id)` | `hass.states.get(&entity_id) -> Option<Arc<State>>` |
| `hass.states.async_set(entity_id, state, attributes)` | `hass.states.set(entity_id, state, attributes).await` |
| `hass.states.async_remove(entity_id)` | `hass.states.remove(&entity_id).await` |
| `hass.states.async_all(domain_filter)` | `hass.states.all(domain_filter) -> Vec<Arc<State>>` |
| `hass.bus.async_fire(event_type, data)` | `hass.bus.fire(event_type, data).await` |
| `hass.bus.async_listen(event_type, callback)` | `hass.bus.subscribe(event_type) -> broadcast::Receiver<DomainEvent>` |
| `hass.services.async_call(domain, service, data)` | `hass.services.call(domain, service, data).await -> ServiceResponse` |
| `hass.services.async_register(domain, service, handler, schema)` | `hass.services.register(domain, service, handler)` |
| `hass.services.has_service(domain, service)` | `hass.services.has(domain, service) -> bool` |
| `entity_registry.async_get(entity_id)` | `entity_registry.get(&entity_id) -> Option<&EntityEntry>` |
| `entity_registry.async_update_entity(entity_id, **kwargs)` | `entity_registry.update(entity_id, patch).await` |
| `device_registry.async_get_device(identifiers)` | `device_registry.get_by_identifiers(identifiers) -> Option<&DeviceEntry>` |
| `Context(user_id, parent_id)` | `Context { id: Uuid, parent_id: Option<Uuid>, user_id: Option<UserId> }` |
---
## 5. Phased implementation plan
### P1 — Skeleton (2 weeks)
- [ ] Create `v2/crates/homecore/` workspace member with `Cargo.toml`.
- [ ] Define `State`, `EntityId`, `Domain`, `ServiceName`, `Context`, `DomainEvent` types.
- [ ] `StateMachine`: `DashMap` + broadcast channel; `set()`, `get()`, `remove()`, `all()`.
- [ ] `EventBus`: typed broadcast for system events + untyped broadcast for domain events.
- [ ] Unit tests: 50 state writes/reads with concurrent readers; verify broadcast delivery.
### P2 — Service registry + entity registry (2 weeks)
- [ ] `ServiceRegistry`: `RwLock<HashMap>` + mpsc dispatch task.
- [ ] `EntityRegistry`: in-memory + JSON async writer to `.homecore/storage/core.entity_registry`.
- [ ] `DeviceRegistry`: in-memory + JSON async writer to `.homecore/storage/core.device_registry`.
- [ ] Serialization: `serde` with `#[serde(rename_all = "snake_case")]`; schema version 13 header written to match HA format.
- [ ] Unit tests: register service, call service, verify handler invoked; persist and reload entity registry.
### P3 — Trait surface for integrations (1 week)
- [ ] `HomeCoreEntity` trait: `entity_id()`, `unique_id()`, `name()`, `device_info()`, `state()`, `attributes()`, `async_write_ha_state(&hass)`.
- [ ] `Platform` trait: `async_setup_entry(hass, config_entry) -> Result<()>`.
- [ ] `ConfigEntry` struct mirroring HA's `ConfigEntry` fields.
- [ ] Integration test: a minimal test integration registers an entity, writes a state, reads it back from the state machine.
### P4 — Performance validation (1 week)
- [ ] Benchmark: 1,000 state writes/s on Pi 5; measure latency at p50/p95/p99.
- [ ] Benchmark: 100 concurrent WS subscribers each receiving all state_changed events; measure delivery lag.
- [ ] Benchmark: broadcast channel saturation test at 4,096 capacity; verify `RecvError::Lagged` handling.
- [ ] Acceptance criterion: p99 state write latency < 1 ms on Pi 5 (8 GB, 4 cores).
---
## 6. Risks
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| **Broadcast channel lag** — a slow subscriber (e.g. ruvector recorder write) lags behind and drops events | Medium | High | Give recorder its own channel separate from WS subscribers; recorder is the hot path, give it highest priority | ADR-132: recorder write path must be designed to keep up with 100 Hz state writes |
| **DashMap contention** — shard count default (16) may be too low for 100 Hz writes on a single entity | Low | Medium | Increase DashMap shard count to 64; benchmark before ADR-130 integration | ADR-130: REST API reads state directly from DashMap — must be lock-free |
| **Entity registry format drift** — HA updates `.storage/core.entity_registry` schema; HOMECORE falls behind | Medium | Medium | Pin to schema version 13; version-check on load; fail loudly on unknown version | ADR-134: migration tool reads HA entity registry — must support the same schema version |
| **Context propagation** — HA's `Context` is used for audit trails (which automation triggered which service call). HOMECORE must propagate it correctly or automation audits break | High | Low | Derive `Context` from source event at every service call; thread through `ServiceCall.context` field | ADR-129: automation engine must supply context when calling services |
---
## 7. Open questions
**Q1**: Should `EntityId` validation be strict (reject anything that doesn't match `[a-z0-9_]+\.[a-z0-9_]+`) or lenient (accept any UTF-8 string)? HA itself accepts unicode entity IDs since 2024.3. Strict validation simplifies routing; lenient matches HA's actual behaviour.
**Q2**: The `broadcast::Sender` capacity of 4,096 is chosen based on a worst-case of 100 state writes/s × 40 s of acceptable lag before a slow receiver is declared dead. Is 40 s the right threshold, or should it be configurable per receiver?
**Q3**: Should the `HomeCoreEntity` trait be object-safe (enabling `Vec<Box<dyn HomeCoreEntity>>`) or use associated types (enabling monomorphisation)? Object safety is required for the WASM plugin boundary (ADR-128); monomorphisation is faster for built-in integrations.
**Q4**: HA's `State.context` carries a `user_id` that traces which user or automation initiated a state change. HOMECORE uses `UserId` from the auth layer (ADR-130). Is the auth layer a dependency of the core state machine, or should `user_id` be an optional opaque string to avoid circular deps?
---
## 8. References
### HA upstream
- `homeassistant/core.py``HomeAssistant`, `StateMachine` (lines 1800), `EventBus` (lines 8001100), `ServiceRegistry` (lines 11001500), `Config` (lines 15002000)
- `homeassistant/helpers/entity_registry.py``EntityRegistry`, `RegistryEntry` (all ~1,900 lines); schema version constant `STORAGE_VERSION`
- `homeassistant/helpers/device_registry.py``DeviceRegistry`, `DeviceEntry`; schema version
- `homeassistant/helpers/entity.py``Entity` base class; `async_write_ha_state`; entity lifecycle hooks
- `homeassistant/helpers/event.py``async_track_state_change`, `async_track_time_interval`
### This repo
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)