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:
Generated
+1
@@ -3681,6 +3681,7 @@ dependencies = [
|
||||
"homecore-api",
|
||||
"homecore-assist",
|
||||
"homecore-automation",
|
||||
"homecore-hap",
|
||||
"homecore-migrate",
|
||||
"homecore-plugins",
|
||||
"homecore-recorder",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user