mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
feat(homecore-server): wire HAP runtime and registry APIs
This commit is contained in:
@@ -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"
|
||||
}
|
||||
})))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user