feat(homecore-server): wire HAP runtime and registry APIs

This commit is contained in:
ruv
2026-07-27 14:41:25 -04:00
parent 5b5c7f323d
commit fbd5cfa242
8 changed files with 337 additions and 30 deletions
Generated
+1
View File
@@ -3681,6 +3681,7 @@ dependencies = [
"homecore-api", "homecore-api",
"homecore-assist", "homecore-assist",
"homecore-automation", "homecore-automation",
"homecore-hap",
"homecore-migrate", "homecore-migrate",
"homecore-plugins", "homecore-plugins",
"homecore-recorder", "homecore-recorder",
+6 -1
View File
@@ -382,7 +382,12 @@ pub async fn compatibility(
"events": "implemented", "events": "implemented",
"render_template": "implemented", "render_template": "implemented",
"feature_negotiation_and_panels": "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" "lovelace_media": "integration_dependent"
} }
}))) })))
+18
View File
@@ -284,6 +284,24 @@ impl Connection {
let payload = serde_json::to_value(by_domain).unwrap(); let payload = serde_json::to_value(by_domain).unwrap();
self.ack(tx, cmd.id, true, Some(payload)); 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" => { "call_service" => {
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone())
else { else {
@@ -338,3 +338,52 @@ async fn real_state_change_uses_client_subscription_id() {
assert_eq!(unsub["id"], 42); assert_eq!(unsub["id"], 42);
assert_eq!(unsub["success"], true); 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");
}
}
}
+3
View File
@@ -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-migrate = { path = "../homecore-migrate", version = "0.1.0-alpha.0" }
homecore-automation = { path = "../homecore-automation", 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-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"] } tokio = { version = "1", features = ["full"] }
tracing = "0.1" tracing = "0.1"
@@ -66,3 +67,5 @@ default = []
ruvector = ["homecore-recorder/ruvector"] ruvector = ["homecore-recorder/ruvector"]
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime). # Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
wasmtime = ["homecore-plugins/wasmtime"] wasmtime = ["homecore-plugins/wasmtime"]
# Bind the HAP TCP listener and publish `_hap._tcp` over mDNS.
hap-server = ["dep:homecore-hap", "homecore-hap/hap-server"]
+160
View File
@@ -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<SocketAddr>,
pub device_id: Option<String>,
pub advertise_addr: Option<IpAddr>,
pub hostname: String,
pub instance_name: String,
pub pairing_store: PathBuf,
}
pub(crate) struct HapRuntime {
#[cfg(feature = "hap-server")]
handle: Option<homecore_hap::HapServerHandle>,
#[cfg(feature = "hap-server")]
state_task: Option<tokio::task::JoinHandle<()>>,
}
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<HapRuntime> {
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"
);
}
}
}
+48
View File
@@ -37,6 +37,7 @@ use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
mod gateway; mod gateway;
mod hap;
mod plugins; mod plugins;
mod restore; mod restore;
use gateway::{GatewayConfig, GatewayState}; use gateway::{GatewayConfig, GatewayState};
@@ -137,6 +138,40 @@ struct Cli {
/// Permit unsigned WebAssembly plugins. Unsafe; development only. /// Permit unsigned WebAssembly plugins. Unsafe; development only.
#[arg(long, env = "HOMECORE_PLUGIN_ALLOW_UNSIGNED", default_value_t = false)] #[arg(long, env = "HOMECORE_PLUGIN_ALLOW_UNSIGNED", default_value_t = false)]
plugin_allow_unsigned: bool, 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<SocketAddr>,
/// 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<String>,
/// LAN address published in the HAP mDNS record. Required when HAP is enabled.
#[arg(long, env = "HOMECORE_HAP_ADVERTISE_ADDR")]
hap_advertise_addr: Option<std::net::IpAddr>,
/// 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] #[tokio::main]
@@ -284,6 +319,18 @@ async fn main() -> Result<()> {
"Assist intent endpoint ready with {} handlers", "Assist intent endpoint ready with {} handlers",
assist.handler_count() 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( let gw = GatewayState::with_assist(
api_state.clone(), api_state.clone(),
GatewayConfig { GatewayConfig {
@@ -334,6 +381,7 @@ async fn main() -> Result<()> {
info!("Shutdown requested; draining active HTTP connections"); info!("Shutdown requested; draining active HTTP connections");
}) })
.await?; .await?;
hap_runtime.shutdown().await?;
server_plugins.shutdown().await; server_plugins.shutdown().await;
Ok(()) Ok(())
} }
+52 -29
View File
@@ -1,50 +1,73 @@
# HOMECORE capability status # HOMECORE capability status
This document describes the implemented runtime surface as of the current This document describes the current alpha runtime. “Implemented” means the
alpha. “Implemented” means wired into `homecore-server` and covered by tests; capability is wired into `homecore-server` or its public protocol crate and is
workspace libraries alone are not presented as server capabilities. 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 | | Core | Concurrent entity state machine; entity and device registries; system/domain event buses; service registry; causal contexts |
| Event flow | Committed state changes reach state subscribers and the shared event bus; service calls emit system events | | Restore | Entity registry, device registry, and the most recent recorder state are restored in dependency order at startup; malformed rows are isolated and bounded |
| REST | API root, config, list/get/set/delete state, list/call service | | Recorder | SQLite state history listener; deterministic latest-state queries; broadcast lag recovery; optional ruvector semantic index |
| WebSocket | Auth handshake, ping, config/states/services queries, service calls, event subscribe/unsubscribe | | 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 |
| Backpressure | Per-connection WebSocket output is bounded to 256 messages; slow clients are disconnected from overflowing subscriptions | | 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 |
| Authentication | Token allowlist from `HOMECORE_TOKENS`; missing tokens fail startup unless insecure development mode is explicitly 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 |
| Recorder | SQLite state history listener; broadcast lag is recovered by resynchronizing current state; optional ruvector semantic index |
| Automation | State, numeric, event, and time triggers; optional YAML loading at server boot | | 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 | | Voice | Bounded PCM16 audio types, async STT/TTS provider contracts, an STT → intent → TTS pipeline, and an authenticated transport-independent satellite session protocol |
| Built-in services | Real state mutation for `homeassistant`, `light`, and `switch` on/off/toggle; ping and state snapshot | | Assist | Authenticated local intent endpoint with bounded regex recognition and service-backed handlers |
| Migration | HA entity registry v1/minor 113 parsing and atomic, no-overwrite persistence into a HOMECORE storage directory | | 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 | | 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/`
- `GET /api/config` - `GET /api/config`
- `GET /api/components`
- `GET /api/states` - `GET /api/states`
- `GET|POST|DELETE /api/states/:entity_id` - `GET|POST|DELETE /api/states/:entity_id`
- `GET /api/services` - `GET /api/services`
- `POST /api/services/:domain/:service` - `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` - `GET /api/websocket`
- `POST /api/intent/handle` - `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` ## Security and deployment constraints
- 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
Unavailable BFF upstreams return typed `503 upstream_unavailable`. Unsupported - `HOMECORE_TOKENS` is required unless insecure development authentication is
services are not registered. Synthetic biometric/demo entities are opt-in and explicitly enabled.
must not be interpreted as live sensing data. - 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.