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
+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(())
}