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
+6 -1
View File
@@ -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"
}
})))
+18
View File
@@ -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 {
@@ -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");
}
}
}
+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-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"]
+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;
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<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]
@@ -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(())
}