* 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.
15 KiB
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:
HomeAssistant— the runtime coordinator, event loop holder, and service locator. Containsbus(EventBus),states(StateMachine),services(ServiceRegistry),config(Config),components(loaded component set).EventBus— publish/subscribe event dispatch.async_fire(event_type, event_data)dispatches to all registered listeners. Listener registration isasync_listen(event_type, callback). Wildcard listener isMATCH_ALL. Event data is a plain Python dict.StateMachine— an in-memory dictionary fromentity_id(str) toState.async_set(entity_id, new_state, attributes)writes and firesstate_changed.get(entity_id)reads.async_remove(entity_id)firesstate_removed. States are immutable snapshots withlast_changed,last_updated,context.ServiceRegistry— maps(domain, service_name)→ async handler function.async_call(domain, service, data)fires acall_serviceevent, waits for the registered handler.async_register(domain, service, handler, schema)registers a handler with optional voluptuous schema validation.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:
EntityIdis a validated newtype aroundString(validated format:domain.name)Stateis 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:
- System events (typed):
StateChanged,ServiceCall,ComponentLoaded,PlatformDiscovered,HomeAssistantStart,HomeAssistantStop. These use Tokio typed broadcast channels with zero allocation on the read path. - Integration events (untyped): integrations fire arbitrary event types (
event_type: String,event_data: serde_json::Value). These use a singlebroadcast::Sender<DomainEvent>whereDomainEventcarries the type string and data blob. This mirrors HA'sEventBus.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: EntityIdunique_id: Option<String>platform: Stringname: 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 withCargo.toml. - Define
State,EntityId,Domain,ServiceName,Context,DomainEventtypes. 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:
serdewith#[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)
HomeCoreEntitytrait:entity_id(),unique_id(),name(),device_info(),state(),attributes(),async_write_ha_state(&hass).Platformtrait:async_setup_entry(hass, config_entry) -> Result<()>.ConfigEntrystruct mirroring HA'sConfigEntryfields.- 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::Laggedhandling. - 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 1–800),EventBus(lines 800–1100),ServiceRegistry(lines 1100–1500),Config(lines 1500–2000)homeassistant/helpers/entity_registry.py—EntityRegistry,RegistryEntry(all ~1,900 lines); schema version constantSTORAGE_VERSIONhomeassistant/helpers/device_registry.py—DeviceRegistry,DeviceEntry; schema versionhomeassistant/helpers/entity.py—Entitybase class;async_write_ha_state; entity lifecycle hookshomeassistant/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 stackdocs/adr/ADR-126-ruview-native-ha-port-master.md— HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICYdocs/adr/ADR-028-esp32-capability-audit.md— witness chain pattern (Ed25519 per state transition)