From fbd5cfa2420d4a6142efbd17fd802e83a0c8e424 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 27 Jul 2026 14:41:25 -0400 Subject: [PATCH] feat(homecore-server): wire HAP runtime and registry APIs --- v2/Cargo.lock | 1 + v2/crates/homecore-api/src/rest.rs | 7 +- v2/crates/homecore-api/src/ws.rs | 18 +++ v2/crates/homecore-api/tests/ws_handshake.rs | 49 ++++++ v2/crates/homecore-server/Cargo.toml | 3 + v2/crates/homecore-server/src/hap.rs | 160 +++++++++++++++++++ v2/crates/homecore-server/src/main.rs | 48 ++++++ v2/docs/homecore-capabilities.md | 81 ++++++---- 8 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 v2/crates/homecore-server/src/hap.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 76f3e3c8..cb875c09 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -3681,6 +3681,7 @@ dependencies = [ "homecore-api", "homecore-assist", "homecore-automation", + "homecore-hap", "homecore-migrate", "homecore-plugins", "homecore-recorder", diff --git a/v2/crates/homecore-api/src/rest.rs b/v2/crates/homecore-api/src/rest.rs index 5f60576e..e576415e 100644 --- a/v2/crates/homecore-api/src/rest.rs +++ b/v2/crates/homecore-api/src/rest.rs @@ -382,7 +382,12 @@ pub async fn compatibility( "events": "implemented", "render_template": "implemented", "feature_negotiation_and_panels": "implemented", - "registries": "requires_registry_backend", + "registry_lists": { + "entity": "implemented", + "device": "implemented", + "area": "implemented_empty", + "mutations": "requires_persistent_registry_backend" + }, "lovelace_media": "integration_dependent" } }))) diff --git a/v2/crates/homecore-api/src/ws.rs b/v2/crates/homecore-api/src/ws.rs index b1c3c2b0..38cf3df7 100644 --- a/v2/crates/homecore-api/src/ws.rs +++ b/v2/crates/homecore-api/src/ws.rs @@ -284,6 +284,24 @@ impl Connection { let payload = serde_json::to_value(by_domain).unwrap(); self.ack(tx, cmd.id, true, Some(payload)); } + "config/entity_registry/list" | "get_entity_registry" => { + let entries = self.state.homecore().entities().all().await; + let payload = + serde_json::to_value(entries).unwrap_or_else(|_| serde_json::json!([])); + self.ack(tx, cmd.id, true, Some(payload)); + } + "config/device_registry/list" | "get_device_registry" => { + let entries = self.state.homecore().devices().all().await; + let payload = + serde_json::to_value(entries).unwrap_or_else(|_| serde_json::json!([])); + self.ack(tx, cmd.id, true, Some(payload)); + } + "config/area_registry/list" | "get_area_registry" => { + // HOMECORE does not yet model named areas. Returning the valid + // empty-list shape lets clients distinguish that from an + // unsupported command. + self.ack(tx, cmd.id, true, Some(serde_json::json!([]))); + } "call_service" => { let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else { diff --git a/v2/crates/homecore-api/tests/ws_handshake.rs b/v2/crates/homecore-api/tests/ws_handshake.rs index bdbaf399..24a6b372 100644 --- a/v2/crates/homecore-api/tests/ws_handshake.rs +++ b/v2/crates/homecore-api/tests/ws_handshake.rs @@ -338,3 +338,52 @@ async fn real_state_change_uses_client_subscription_id() { assert_eq!(unsub["id"], 42); assert_eq!(unsub["success"], true); } + +#[tokio::test] +async fn registry_list_commands_return_ha_result_shapes() { + use homecore::{EntityEntry, EntityId}; + + let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await; + hc.entities() + .register(EntityEntry { + entity_id: EntityId::parse("light.registry_probe").unwrap(), + unique_id: Some("probe-1".into()), + platform: "test".into(), + name: Some("Registry probe".into()), + disabled_by: None, + area_id: None, + device_id: None, + entity_category: None, + config_entry_id: None, + }) + .await; + + let url = format!("ws://{addr}/api/websocket"); + let (mut ws, _response) = connect_async(&url).await.unwrap(); + let _ = next_json(&mut ws).await; + ws.send(Message::Text( + serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(), + )) + .await + .unwrap(); + let _ = next_json(&mut ws).await; + + for (id, command) in [ + (71, "config/entity_registry/list"), + (72, "config/device_registry/list"), + (73, "config/area_registry/list"), + ] { + ws.send(Message::Text( + serde_json::json!({"id": id, "type": command}).to_string(), + )) + .await + .unwrap(); + let reply = next_json(&mut ws).await; + assert_eq!(reply["id"], id); + assert_eq!(reply["success"], true); + assert!(reply["result"].is_array()); + if command == "config/entity_registry/list" { + assert_eq!(reply["result"][0]["entity_id"], "light.registry_probe"); + } + } +} diff --git a/v2/crates/homecore-server/Cargo.toml b/v2/crates/homecore-server/Cargo.toml index 0bb62d61..47fe8bb2 100644 --- a/v2/crates/homecore-server/Cargo.toml +++ b/v2/crates/homecore-server/Cargo.toml @@ -29,6 +29,7 @@ homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" homecore-migrate = { path = "../homecore-migrate", version = "0.1.0-alpha.0" } homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" } homecore-assist = { path = "../homecore-assist", version = "0.1.0-alpha.0" } +homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0", optional = true } tokio = { version = "1", features = ["full"] } tracing = "0.1" @@ -66,3 +67,5 @@ default = [] ruvector = ["homecore-recorder/ruvector"] # Pull in real Wasmtime plugin runtime (vs InProcessRuntime). wasmtime = ["homecore-plugins/wasmtime"] +# Bind the HAP TCP listener and publish `_hap._tcp` over mDNS. +hap-server = ["dep:homecore-hap", "homecore-hap/hap-server"] diff --git a/v2/crates/homecore-server/src/hap.rs b/v2/crates/homecore-server/src/hap.rs new file mode 100644 index 00000000..61df950e --- /dev/null +++ b/v2/crates/homecore-server/src/hap.rs @@ -0,0 +1,160 @@ +//! Optional network HomeKit Accessory Protocol lifecycle. + +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; + +use anyhow::Result; +use homecore::HomeCore; + +#[derive(Debug, Clone)] +#[cfg_attr(not(feature = "hap-server"), allow(dead_code))] +pub(crate) struct HapRuntimeConfig { + pub bind_addr: Option, + pub device_id: Option, + pub advertise_addr: Option, + pub hostname: String, + pub instance_name: String, + pub pairing_store: PathBuf, +} + +pub(crate) struct HapRuntime { + #[cfg(feature = "hap-server")] + handle: Option, + #[cfg(feature = "hap-server")] + state_task: Option>, +} + +impl HapRuntime { + pub(crate) async fn shutdown(self) -> Result<()> { + #[cfg(feature = "hap-server")] + { + let mut runtime = self; + if let Some(task) = runtime.state_task.take() { + task.abort(); + let _ = task.await; + } + if let Some(handle) = runtime.handle.take() { + handle.shutdown().await?; + } + } + Ok(()) + } +} + +pub(crate) async fn start(hc: &HomeCore, config: HapRuntimeConfig) -> Result { + let Some(bind_addr) = config.bind_addr else { + tracing::info!("HAP network server disabled"); + return Ok(HapRuntime { + #[cfg(feature = "hap-server")] + handle: None, + #[cfg(feature = "hap-server")] + state_task: None, + }); + }; + + #[cfg(not(feature = "hap-server"))] + { + let _ = (hc, bind_addr); + anyhow::bail!( + "HAP was requested but this binary was built without the `hap-server` feature" + ); + } + + #[cfg(feature = "hap-server")] + { + use std::sync::Arc; + + use homecore_hap::{ + start_server, HapBridge, HapServerConfig, HapServiceRecord, MdnsSdAdvertiser, + PairingStore, + }; + + let device_id = config + .device_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--hap-device-id is required when HAP is enabled"))?; + let advertise_addr = config.advertise_addr.ok_or_else(|| { + anyhow::anyhow!("--hap-advertise-addr is required when HAP is enabled") + })?; + if let Some(parent) = config.pairing_store.parent() { + std::fs::create_dir_all(parent)?; + } + + let advertiser = Arc::new(MdnsSdAdvertiser::new( + config.hostname.clone(), + advertise_addr, + )?); + let pairings = Arc::new(PairingStore::open(&config.pairing_store)?); + let record = + HapServiceRecord::bridge(config.instance_name.clone(), bind_addr.port(), device_id); + let bridge = HapBridge::new(record); + + let mut state_rx = hc.states().subscribe(); + synchronize(&bridge, hc); + let sync_bridge = bridge.clone(); + let sync_hc = hc.clone(); + let state_task = tokio::spawn(async move { + loop { + match state_rx.recv().await { + Ok(change) => match change.new_state { + Some(state) => { + if sync_bridge + .update_accessory(&change.entity_id, &state) + .is_err() + { + let _ = sync_bridge.add_accessory(&change.entity_id, &state); + } + } + None => { + let _ = sync_bridge.remove_accessory(&change.entity_id); + } + }, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!( + skipped, + "HAP state listener lagged; rebuilding accessory snapshot" + ); + synchronize(&sync_bridge, &sync_hc); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + let handle = start_server( + HapServerConfig { + bind_addr, + ..HapServerConfig::default() + }, + bridge, + pairings, + advertiser, + ) + .await?; + tracing::info!( + address = %handle.local_addr(), + pairing_store = %config.pairing_store.display(), + "HAP network server and mDNS advertisement started" + ); + Ok(HapRuntime { + handle: Some(handle), + state_task: Some(state_task), + }) + } +} + +#[cfg(feature = "hap-server")] +fn synchronize(bridge: &homecore_hap::HapBridge, hc: &HomeCore) { + for accessory in bridge.running_accessories() { + let _ = bridge.remove_accessory(&accessory.entity_id); + } + for state in hc.states().all() { + if let Err(error) = bridge.add_accessory(&state.entity_id, &state) { + tracing::debug!( + entity = %state.entity_id, + %error, + "entity is not exposed through HAP" + ); + } + } +} diff --git a/v2/crates/homecore-server/src/main.rs b/v2/crates/homecore-server/src/main.rs index 0f79faea..f5037fec 100644 --- a/v2/crates/homecore-server/src/main.rs +++ b/v2/crates/homecore-server/src/main.rs @@ -37,6 +37,7 @@ use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; mod gateway; +mod hap; mod plugins; mod restore; use gateway::{GatewayConfig, GatewayState}; @@ -137,6 +138,40 @@ struct Cli { /// Permit unsigned WebAssembly plugins. Unsafe; development only. #[arg(long, env = "HOMECORE_PLUGIN_ALLOW_UNSIGNED", default_value_t = false)] plugin_allow_unsigned: bool, + + /// Bind address for the optional HomeKit Accessory Protocol server. + /// The server remains disabled unless this option is supplied. + #[arg(long, env = "HOMECORE_HAP_BIND")] + hap_bind: Option, + + /// Stable six-octet HAP accessory identifier (for example + /// `AA:BB:CC:DD:EE:FF`). Required when HAP is enabled. + #[arg(long, env = "HOMECORE_HAP_DEVICE_ID")] + hap_device_id: Option, + + /// LAN address published in the HAP mDNS record. Required when HAP is enabled. + #[arg(long, env = "HOMECORE_HAP_ADVERTISE_ADDR")] + hap_advertise_addr: Option, + + /// DNS hostname published by mDNS for the HAP bridge. + #[arg(long, env = "HOMECORE_HAP_HOSTNAME", default_value = "homecore")] + hap_hostname: String, + + /// HAP discovery instance shown to controller applications. + #[arg( + long, + env = "HOMECORE_HAP_INSTANCE_NAME", + default_value = "HOMECORE Bridge" + )] + hap_instance_name: String, + + /// Durable controller pairing database. + #[arg( + long, + env = "HOMECORE_HAP_PAIRING_STORE", + default_value = ".homecore/hap/pairings.json" + )] + hap_pairing_store: std::path::PathBuf, } #[tokio::main] @@ -284,6 +319,18 @@ async fn main() -> Result<()> { "Assist intent endpoint ready with {} handlers", assist.handler_count() ); + let hap_runtime = hap::start( + &hc, + hap::HapRuntimeConfig { + bind_addr: cli.hap_bind, + device_id: cli.hap_device_id.clone(), + advertise_addr: cli.hap_advertise_addr, + hostname: cli.hap_hostname.clone(), + instance_name: cli.hap_instance_name.clone(), + pairing_store: cli.hap_pairing_store.clone(), + }, + ) + .await?; let gw = GatewayState::with_assist( api_state.clone(), GatewayConfig { @@ -334,6 +381,7 @@ async fn main() -> Result<()> { info!("Shutdown requested; draining active HTTP connections"); }) .await?; + hap_runtime.shutdown().await?; server_plugins.shutdown().await; Ok(()) } diff --git a/v2/docs/homecore-capabilities.md b/v2/docs/homecore-capabilities.md index 14de8f0a..ae6daa4e 100644 --- a/v2/docs/homecore-capabilities.md +++ b/v2/docs/homecore-capabilities.md @@ -1,50 +1,73 @@ # HOMECORE capability status -This document describes the implemented runtime surface as of the current -alpha. “Implemented” means wired into `homecore-server` and covered by tests; -workspace libraries alone are not presented as server capabilities. +This document describes the current alpha runtime. “Implemented” means the +capability is wired into `homecore-server` or its public protocol crate and is +covered by tests. It does not imply compatibility with every Home Assistant +integration or every Apple Home controller. -## Implemented +## Runtime capabilities -| Area | Runtime behavior | +| Area | Current behavior | |---|---| -| Core | Concurrent entity state machine, entity registry API, shared system/domain event bus, service registry, contexts | -| Event flow | Committed state changes reach state subscribers and the shared event bus; service calls emit system events | -| REST | API root, config, list/get/set/delete state, list/call service | -| WebSocket | Auth handshake, ping, config/states/services queries, service calls, event subscribe/unsubscribe | -| Backpressure | Per-connection WebSocket output is bounded to 256 messages; slow clients are disconnected from overflowing subscriptions | -| Authentication | Token allowlist from `HOMECORE_TOKENS`; missing tokens fail startup unless insecure development mode is explicitly enabled | -| Recorder | SQLite state history listener; broadcast lag is recovered by resynchronizing current state; optional ruvector semantic index | +| Core | Concurrent entity state machine; entity and device registries; system/domain event buses; service registry; causal contexts | +| Restore | Entity registry, device registry, and the most recent recorder state are restored in dependency order at startup; malformed rows are isolated and bounded | +| Recorder | SQLite state history listener; deterministic latest-state queries; broadcast lag recovery; optional ruvector semantic index | +| Migration | Entity/device registries and config entries are parsed with version checks, unknown forward-compatible fields are preserved, and output is written atomically without overwriting existing storage | +| Plugins | Compiled-in native plugins use an explicit registry. Packaged Wasm plugins are discovered only in configured directories, bounded and path-checked, signature-verified against configured Ed25519 publishers, and run through Wasmtime when the `wasmtime` feature is enabled | +| Plugin lifecycle | Plugin setup runs after restoration and built-in service registration; state changes are dispatched through a bounded queue; teardown runs during graceful shutdown | | Automation | State, numeric, event, and time triggers; optional YAML loading at server boot | -| Assist | Authenticated `POST /api/intent/handle` with bounded regex recognition and five local handlers | -| Built-in services | Real state mutation for `homeassistant`, `light`, and `switch` on/off/toggle; ping and state snapshot | -| Migration | HA entity registry v1/minor 1–13 parsing and atomic, no-overwrite persistence into a HOMECORE storage directory | +| Voice | Bounded PCM16 audio types, async STT/TTS provider contracts, an STT → intent → TTS pipeline, and an authenticated transport-independent satellite session protocol | +| Assist | Authenticated local intent endpoint with bounded regex recognition and service-backed handlers | +| HAP network | With `homecore-server --features hap-server`, an explicitly configured bounded TCP listener, persisted pairing records, live entity/accessory synchronization, and `_hap._tcp` mDNS lifecycle are wired into the server | | Dashboard/BFF | Static UI, calibration proxy, rooms, COG list, appliance metrics, and typed unavailable responses for absent upstreams | -## Exact HA-style HTTP surface +## Home Assistant-compatible REST surface - `GET /api/` - `GET /api/config` +- `GET /api/components` - `GET /api/states` - `GET|POST|DELETE /api/states/:entity_id` - `GET /api/services` - `POST /api/services/:domain/:service` +- `GET /api/events` +- `POST /api/events/:event_type` +- `POST /api/template` +- `POST /api/config/core/check_config` +- `GET /api/error_log` - `GET /api/websocket` - `POST /api/intent/handle` +- `GET /api/homecore/compatibility` -This is a compatible subset, not full Home Assistant parity. +The WebSocket server implements authentication, feature negotiation, ping, +configuration/state/service queries, service calls, event firing, +subscribe/unsubscribe, template rendering, panels, and entity/device/area +registry list commands. Per-connection output is bounded; lagging event +subscribers resynchronize. -## Explicitly deferred +`GET /api/homecore/compatibility` is the machine-readable support matrix. +HOMECORE implements the core contract above, not every endpoint contributed by +Home Assistant integrations. History needs an attached recorder query surface; +camera, calendar, media, and Lovelace behavior remain integration-dependent. +Registry mutations require a persistent registry mutation backend. The area +registry currently returns a valid empty list. -- Loading native or Wasmtime plugins from `homecore-server` -- A network HomeKit Accessory Protocol server, pairing, and mDNS advertisement -- Device-registry persistence and full config-entry conversion -- Full HA event/history/template/config/check endpoints -- Restore-state on server startup -- STT/TTS and satellite voice protocols -- SEED/federation/witness/privacy upstream services when their daemons are absent -- Claiming Home Assistant integration parity or production-scale performance +## Security and deployment constraints -Unavailable BFF upstreams return typed `503 upstream_unavailable`. Unsupported -services are not registered. Synthetic biometric/demo entities are opt-in and -must not be interpreted as live sensing data. +- `HOMECORE_TOKENS` is required unless insecure development authentication is + explicitly enabled. +- Unsigned Wasm packages are rejected unless the development-only override is + explicitly supplied. Arbitrary native dynamic libraries are never loaded; + native plugins must be linked into the binary and registered in code. +- HAP is disabled by default. Enabling it requires an explicit bind address, + stable six-octet device identifier, advertised LAN address, hostname, and + durable pairing-store path. +- The HAP listener fails closed for cryptographic phases that are unavailable + in the selected build. Do not claim Apple Home interoperability unless the + Pair-Setup, Pair-Verify, and encrypted transport conformance tests for that + build pass. +- STT and TTS are provider contracts; a deployment must supply real providers. + The built-in disabled providers return typed errors rather than fabricated + speech results. +- Synthetic biometric/demo entities are opt-in and must not be interpreted as + live sensing data.