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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs ADR-125 §2.1.c.

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

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

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

Response shape:

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

Live response from real C6 (node_id=12):

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

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

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

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

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

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

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

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

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

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

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

12 MCP tools auto-discovered:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

15 KiB
Raw Permalink Blame History

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 (HOMECORE master), ADR-028 (witness chain), ADR-124 (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), last_updated (DateTime), 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.pyHomeAssistant, StateMachine (lines 1800), EventBus (lines 8001100), ServiceRegistry (lines 11001500), Config (lines 15002000)
  • homeassistant/helpers/entity_registry.pyEntityRegistry, RegistryEntry (all ~1,900 lines); schema version constant STORAGE_VERSION
  • homeassistant/helpers/device_registry.pyDeviceRegistry, DeviceEntry; schema version
  • homeassistant/helpers/entity.pyEntity base class; async_write_ha_state; entity lifecycle hooks
  • homeassistant/helpers/event.pyasync_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)