diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 814fa09c..a4350686 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -3674,13 +3674,13 @@ dependencies = [ "homecore-api", "homecore-assist", "homecore-automation", - "homecore-hap", "homecore-plugins", "homecore-recorder", "http-body-util", "reqwest 0.12.28", "serde", "serde_json", + "serde_yaml", "tokio", "tower 0.5.3", "tower-http", diff --git a/v2/crates/homecore-api/src/bin/server.rs b/v2/crates/homecore-api/src/bin/server.rs index 49778840..d1e95d9c 100644 --- a/v2/crates/homecore-api/src/bin/server.rs +++ b/v2/crates/homecore-api/src/bin/server.rs @@ -40,13 +40,22 @@ async fn main() -> Result<(), Box> { // Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env // whitelist; fall back to DEV mode (warn-logged) only when unset. - let tokens = if std::env::var("HOMECORE_TOKENS") + let has_tokens = std::env::var("HOMECORE_TOKENS") .map(|v| !v.trim().is_empty()) - .unwrap_or(false) - { + .unwrap_or(false); + let insecure_dev_auth = std::env::var("HOMECORE_INSECURE_DEV_AUTH").as_deref() == Ok("1"); + if !has_tokens && !insecure_dev_auth { + return Err( + "HOMECORE_TOKENS is required (or set HOMECORE_INSECURE_DEV_AUTH=1 for loopback-only development)" + .into(), + ); + } + let tokens = if has_tokens { let s = LongLivedTokenStore::from_env(); let n = s.len().await; - tracing::info!("LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS"); + tracing::info!( + "LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS" + ); s } else { tracing::warn!( diff --git a/v2/crates/homecore-api/src/rest.rs b/v2/crates/homecore-api/src/rest.rs index ba5f5dbf..c7ba1c3e 100644 --- a/v2/crates/homecore-api/src/rest.rs +++ b/v2/crates/homecore-api/src/rest.rs @@ -93,7 +93,7 @@ pub async fn get_state( ) -> ApiResult> { let _ = BearerAuth::from_headers(&headers, s.tokens()).await?; let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?; - let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?; + let st = s.homecore().states().get(&id).ok_or(ApiError::NotFound(entity_id))?; Ok(Json(StateView::from_state(&st))) } diff --git a/v2/crates/homecore-api/src/state.rs b/v2/crates/homecore-api/src/state.rs index 47819c93..0ec6b041 100644 --- a/v2/crates/homecore-api/src/state.rs +++ b/v2/crates/homecore-api/src/state.rs @@ -1,5 +1,5 @@ -use std::sync::Arc; use homecore::HomeCore; +use std::sync::Arc; use crate::tokens::LongLivedTokenStore; @@ -28,15 +28,13 @@ impl SharedState { location_name: impl Into, homecore_version: impl Into, ) -> Self { - // P2 default: dev-mode token store (accepts any non-empty - // bearer) so existing smoke tests still work; the - // `homecore-server` binary uses with_tokens() to provision a - // real store at boot. + // Fail closed by default. Tests and explicitly insecure local + // development must opt into `allow_any_non_empty()` themselves. Self::with_tokens( homecore, location_name, homecore_version, - LongLivedTokenStore::allow_any_non_empty(), + LongLivedTokenStore::empty(), ) } @@ -56,8 +54,16 @@ impl SharedState { } } - pub fn homecore(&self) -> &HomeCore { &self.inner.homecore } - pub fn version(&self) -> &str { &self.inner.homecore_version } - pub fn location_name(&self) -> &str { &self.inner.location_name } - pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens } + pub fn homecore(&self) -> &HomeCore { + &self.inner.homecore + } + pub fn version(&self) -> &str { + &self.inner.homecore_version + } + pub fn location_name(&self) -> &str { + &self.inner.location_name + } + pub fn tokens(&self) -> &LongLivedTokenStore { + &self.inner.tokens + } } diff --git a/v2/crates/homecore-api/src/tokens.rs b/v2/crates/homecore-api/src/tokens.rs index ed9dd97d..7f8b2a75 100644 --- a/v2/crates/homecore-api/src/tokens.rs +++ b/v2/crates/homecore-api/src/tokens.rs @@ -127,6 +127,10 @@ impl LongLivedTokenStore { self.inner.read().await.tokens.len() } + pub async fn is_empty(&self) -> bool { + self.inner.read().await.tokens.is_empty() + } + /// Is the store accepting any non-empty bearer (DEV mode)? pub async fn is_dev_mode(&self) -> bool { self.inner.read().await.allow_any diff --git a/v2/crates/homecore-api/src/ws.rs b/v2/crates/homecore-api/src/ws.rs index 352cbec1..73ea8f31 100644 --- a/v2/crates/homecore-api/src/ws.rs +++ b/v2/crates/homecore-api/src/ws.rs @@ -20,7 +20,6 @@ //! drains the response channel onto the socket (HC-WS-02 closed the prior //! reply-theater where responses were logged and discarded). -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; @@ -28,6 +27,10 @@ use axum::extract::State; use axum::response::IntoResponse; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; + +/// Per-connection outbound queue. A bounded queue prevents a client that +/// stops reading from turning event fan-out into unbounded process memory. +const OUTBOUND_QUEUE_CAPACITY: usize = 256; use tracing::warn; use homecore::{Context, ServiceCall, ServiceName, SystemEvent}; @@ -49,7 +52,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) { "type": "auth_required", "ha_version": state.version(), }); - if socket.send(Message::Text(auth_req.to_string())).await.is_err() { + if socket + .send(Message::Text(auth_req.to_string())) + .await + .is_err() + { return; } @@ -59,7 +66,8 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) { _ => { let _ = socket .send(Message::Text( - serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(), + serde_json::json!({"type":"auth_invalid","message":"expected auth"}) + .to_string(), )) .await; return; @@ -85,7 +93,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) { return; } let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()}); - if socket.send(Message::Text(auth_ok.to_string())).await.is_err() { + if socket + .send(Message::Text(auth_ok.to_string())) + .await + .is_err() + { return; } @@ -140,7 +152,6 @@ struct ErrorView<'a> { struct Connection { state: SharedState, - next_sub_id: AtomicU64, subs: Arc>, } @@ -152,7 +163,6 @@ impl Connection { fn new(state: SharedState) -> Self { Self { state, - next_sub_id: AtomicU64::new(1), subs: Arc::new(dashmap::DashMap::new()), } } @@ -168,7 +178,7 @@ impl Connection { // DISCARDED every message — so no `result`/`pong`/`event` ever // reached the client. Now `rx` feeds `socket.send`. let (mut sink, mut stream) = socket.split(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let (tx, mut rx) = tokio::sync::mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); // Writer task: drain replies onto the socket. A `__pong:` // sentinel maps to a binary Pong control frame; everything else @@ -205,7 +215,7 @@ impl Connection { conn.handle_cmd(cmd, &reader_tx).await; } Ok(Message::Ping(p)) => { - let _ = reader_tx.send(format!("__pong:{}", p.len())); + let _ = reader_tx.try_send(format!("__pong:{}", p.len())); } Ok(Message::Close(_)) | Err(_) => break, _ => {} @@ -224,15 +234,16 @@ impl Connection { let _ = writer_task.await; } - async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender) { + async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::Sender) { match cmd.kind.as_str() { "ping" => { let msg = serde_json::json!({"id": cmd.id, "type": "pong"}); - let _ = tx.send(msg.to_string()); + let _ = tx.try_send(msg.to_string()); } "get_states" => { let snapshots = self.state.homecore().states().all(); - let views: Vec = snapshots.iter().map(|s| StateView::from_state(s)).collect(); + let views: Vec = + snapshots.iter().map(|s| StateView::from_state(s)).collect(); self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap())); } "get_config" => { @@ -245,17 +256,28 @@ impl Connection { } "get_services" => { let services = self.state.homecore().services().registered_services().await; - let mut by_domain: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut by_domain: std::collections::HashMap< + String, + serde_json::Map, + > = std::collections::HashMap::new(); for s in services { - by_domain.entry(s.domain).or_default().insert(s.service, serde_json::json!({})); + by_domain + .entry(s.domain) + .or_default() + .insert(s.service, serde_json::json!({})); } let payload = serde_json::to_value(by_domain).unwrap(); self.ack(tx, cmd.id, true, Some(payload)); } "call_service" => { - let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else { - self.err(tx, cmd.id, "missing_domain_service", "domain and service are required"); + let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) + else { + self.err( + tx, + cmd.id, + "missing_domain_service", + "domain and service are required", + ); return; }; let call = ServiceCall { @@ -269,7 +291,13 @@ impl Connection { } } "subscribe_events" => { - let sub_id = self.next_sub_id.fetch_add(1, Ordering::Relaxed); + // HA uses the subscribing command ID as the subscription ID + // in every emitted event and in `unsubscribe_events`. + let sub_id = cmd.id; + if self.subs.contains_key(&sub_id) { + self.err(tx, cmd.id, "id_reused", "subscription id is already active"); + return; + } let filter = cmd.event_type.clone(); let tx_clone = tx.clone(); let mut domain_rx = self.state.homecore().bus().subscribe_domain(); @@ -294,7 +322,27 @@ impl Connection { "time_fired": sc.fired_at.to_rfc3339(), } }); - if tx_clone.send(payload.to_string()).is_err() { break; } + if tx_clone.try_send(payload.to_string()).is_err() { break; } + } + } + Ok(SystemEvent::ServiceCalled { domain, service, data, context }) => { + if filter.as_deref() == Some("call_service") || filter.is_none() { + let payload = serde_json::json!({ + "id": sub_id, + "type": "event", + "event": { + "event_type": "call_service", + "data": { + "domain": domain, + "service": service, + "service_data": data, + }, + "origin": "LOCAL", + "time_fired": chrono::Utc::now().to_rfc3339(), + "context": context, + } + }); + if tx_clone.try_send(payload.to_string()).is_err() { break; } } } Ok(_) => {} @@ -323,7 +371,7 @@ impl Connection { "time_fired": de.fired_at.to_rfc3339(), } }); - if tx_clone.send(payload.to_string()).is_err() { break; } + if tx_clone.try_send(payload.to_string()).is_err() { break; } } } // Same recoverable-lag handling as the system arm @@ -353,11 +401,21 @@ impl Connection { self.err(tx, cmd.id, "not_found", "subscription_id not found"); } } else { - self.err(tx, cmd.id, "missing_subscription", "subscription is required"); + self.err( + tx, + cmd.id, + "missing_subscription", + "subscription is required", + ); } } other => { - self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}")); + self.err( + tx, + cmd.id, + "unknown_command", + &format!("unknown ws command: {other}"), + ); } } // entity_id is reserved for future per-entity subscribes @@ -366,7 +424,7 @@ impl Connection { fn ack( &self, - tx: &tokio::sync::mpsc::UnboundedSender, + tx: &tokio::sync::mpsc::Sender, id: u64, success: bool, result: Option, @@ -378,10 +436,16 @@ impl Connection { result, error: None, }; - let _ = tx.send(serde_json::to_string(&msg).unwrap()); + let _ = tx.try_send(serde_json::to_string(&msg).unwrap()); } - fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender, id: u64, code: &'static str, message: &str) { + fn err( + &self, + tx: &tokio::sync::mpsc::Sender, + id: u64, + code: &'static str, + message: &str, + ) { let msg = ResultMessage { id, kind: "result", @@ -389,7 +453,7 @@ impl Connection { result: None, error: Some(ErrorView { code, message }), }; - let _ = tx.send(serde_json::to_string(&msg).unwrap()); + let _ = tx.try_send(serde_json::to_string(&msg).unwrap()); } } diff --git a/v2/crates/homecore-api/tests/ws_handshake.rs b/v2/crates/homecore-api/tests/ws_handshake.rs index b629753b..bdbaf399 100644 --- a/v2/crates/homecore-api/tests/ws_handshake.rs +++ b/v2/crates/homecore-api/tests/ws_handshake.rs @@ -80,7 +80,10 @@ async fn wrong_token_is_rejected() { resp["type"], "auth_invalid", "wrong token must be rejected with auth_invalid, got: {resp}" ); - assert_ne!(resp["type"], "auth_ok", "wrong token must NOT receive auth_ok"); + assert_ne!( + resp["type"], "auth_ok", + "wrong token must NOT receive auth_ok" + ); } #[tokio::test] @@ -99,7 +102,10 @@ async fn correct_token_is_accepted() { .unwrap(); let resp = next_json(&mut ws).await; - assert_eq!(resp["type"], "auth_ok", "correct token should be accepted, got: {resp}"); + assert_eq!( + resp["type"], "auth_ok", + "correct token should be accepted, got: {resp}" + ); } #[tokio::test] @@ -133,7 +139,10 @@ async fn result_reply_is_received() { let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws)) .await .expect("did not receive a reply within 5s — reply theater (HC-WS-02)"); - assert_eq!(reply["type"], "result", "expected a result reply, got: {reply}"); + assert_eq!( + reply["type"], "result", + "expected a result reply, got: {reply}" + ); assert_eq!(reply["id"], 1); assert_eq!(reply["success"], true); } @@ -263,3 +272,69 @@ async fn subscription_survives_broadcast_lag() { ); assert_eq!(got["event"]["data"]["marker"], "post-lag"); } + +#[tokio::test] +async fn real_state_change_uses_client_subscription_id() { + use homecore::{Context, EntityId}; + + let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await; + let url = format!("ws://{addr}/api/websocket"); + let (mut ws, _resp) = 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; + + ws.send(Message::Text( + serde_json::json!({ + "id": 41, + "type": "subscribe_events", + "event_type": "state_changed" + }) + .to_string(), + )) + .await + .unwrap(); + let ack = next_json(&mut ws).await; + assert_eq!(ack["id"], 41); + assert_eq!(ack["success"], true); + + hc.states().set( + EntityId::parse("light.integration_probe").unwrap(), + "on", + serde_json::json!({"source":"test"}), + Context::new(), + ); + + let event = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws)) + .await + .expect("state change was not bridged to the WebSocket system-event subscription"); + assert_eq!( + event["id"], 41, + "HA events must use the subscribe command id" + ); + assert_eq!(event["type"], "event"); + assert_eq!(event["event"]["event_type"], "state_changed"); + assert_eq!( + event["event"]["data"]["entity_id"], + "light.integration_probe" + ); + + ws.send(Message::Text( + serde_json::json!({ + "id": 42, + "type": "unsubscribe_events", + "subscription": 41 + }) + .to_string(), + )) + .await + .unwrap(); + let unsub = next_json(&mut ws).await; + assert_eq!(unsub["id"], 42); + assert_eq!(unsub["success"], true); +} diff --git a/v2/crates/homecore-automation/src/condition.rs b/v2/crates/homecore-automation/src/condition.rs index 98d2d635..81a9934d 100644 --- a/v2/crates/homecore-automation/src/condition.rs +++ b/v2/crates/homecore-automation/src/condition.rs @@ -74,7 +74,7 @@ impl Condition { Condition::State { entity_id, state } => { ctx.states .get(entity_id) - .map_or(false, |s| s.state == *state) + .is_some_and(|s| s.state == *state) } Condition::NumericState { entity_id, above, below } => { let value: Option = ctx @@ -84,16 +84,13 @@ impl Condition { match value { None => false, Some(v) => { - above.map_or(true, |a| v > a) && below.map_or(true, |b| v < b) + above.is_none_or(|a| v > a) && below.is_none_or(|b| v < b) } } } Condition::Template { value_template } => { if let Some(env) = &ctx.template_env { - match env.render_bool(value_template) { - Ok(v) => v, - Err(_) => false, - } + env.render_bool(value_template).unwrap_or_default() } else { false } diff --git a/v2/crates/homecore-automation/src/trigger.rs b/v2/crates/homecore-automation/src/trigger.rs index 3d11f156..748a5fee 100644 --- a/v2/crates/homecore-automation/src/trigger.rs +++ b/v2/crates/homecore-automation/src/trigger.rs @@ -106,7 +106,7 @@ impl Trigger { pub fn matches_sync(&self, ctx: &TriggerContext) -> bool { match self { Trigger::State { entity_id, from, to } => { - let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id); + let eid_match = ctx.entity_id.as_ref() == Some(entity_id); if !eid_match { return false; } @@ -125,7 +125,7 @@ impl Trigger { true } Trigger::NumericState { entity_id, above, below } => { - let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id); + let eid_match = ctx.entity_id.as_ref() == Some(entity_id); if !eid_match { return false; } diff --git a/v2/crates/homecore-migrate/README.md b/v2/crates/homecore-migrate/README.md index f3b7df4a..54b98441 100644 --- a/v2/crates/homecore-migrate/README.md +++ b/v2/crates/homecore-migrate/README.md @@ -26,7 +26,8 @@ The tool enforces version schema compatibility: unknown HA schema versions are r ## Features -- **Entity registry import** — `core.entity_registry` → HOMECORE entity definitions (ready for import) +- **Entity registry import** — `core.entity_registry` → an atomically written, + HA-compatible HOMECORE registry file; existing destinations are not overwritten - **Device registry inspection** — read HA device metadata; full conversion deferred to P2 - **Config entries analysis** — list active integrations by domain (enables gap analysis) - **Secrets extraction** — read `secrets.yaml` references for annotation (resolution in P2) diff --git a/v2/crates/homecore-migrate/src/entity_registry.rs b/v2/crates/homecore-migrate/src/entity_registry.rs index 1a92f585..ec5d61c6 100644 --- a/v2/crates/homecore-migrate/src/entity_registry.rs +++ b/v2/crates/homecore-migrate/src/entity_registry.rs @@ -26,17 +26,15 @@ //! } //! ``` -use std::path::Path; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId}; -use crate::{ - storage::read_envelope, - storage_format::v13, - MigrateError, -}; +use crate::{storage::read_envelope, storage_format::v13, MigrateError}; // Key used by `inspect` subcommand when scanning the directory. #[allow(dead_code)] @@ -75,21 +73,21 @@ struct HaEntityRow { // Fields present in v13 that we capture but do not yet map to HOMECORE. // Forwarded as Q5 items. #[serde(default)] - hidden_by: Option, // v13: "user" | "integration" + hidden_by: Option, // v13: "user" | "integration" #[serde(default)] - has_entity_name: Option, // v13: HA naming convention flag + has_entity_name: Option, // v13: HA naming convention flag #[serde(default)] - original_name: Option, // v13: integration-provided default name + original_name: Option, // v13: integration-provided default name #[serde(default)] - icon: Option, // v13: mdi:xxx icon override + icon: Option, // v13: mdi:xxx icon override #[serde(default)] - original_icon: Option, // v13: integration-provided icon + original_icon: Option, // v13: integration-provided icon #[serde(default)] - aliases: Option>, // v13: user-set aliases for voice assist + aliases: Option>, // v13: user-set aliases for voice assist #[serde(default)] capabilities: Option, // v13: integration-specific caps #[serde(default)] - supported_features: Option, // v13: bitmask + supported_features: Option, // v13: bitmask } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -166,6 +164,64 @@ pub fn read_entity_registry(path: &Path) -> Result, MigrateErro Ok(entries) } +/// Persist imported entries using the HA-compatible v13 storage envelope. +/// +/// The destination is created if needed and the file is written through a +/// same-directory temporary file followed by an atomic rename. Existing +/// registries are never overwritten implicitly. +pub fn write_entity_registry( + storage_dir: &Path, + entries: &[EntityEntry], +) -> Result { + fs::create_dir_all(storage_dir).map_err(|source| MigrateError::Io { + path: storage_dir.display().to_string(), + source, + })?; + let target = storage_dir.join(FILE_KEY); + if target.exists() { + return Err(MigrateError::Io { + path: target.display().to_string(), + source: std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "destination exists; refusing to overwrite", + ), + }); + } + let temp = storage_dir.join(format!(".{FILE_KEY}.{}.tmp", std::process::id())); + let payload = serde_json::json!({ + "version": 1, + "minor_version": 13, + "key": FILE_KEY, + "data": { + "entities": entries, + "deleted_entities": [] + } + }); + let bytes = serde_json::to_vec_pretty(&payload).map_err(|source| MigrateError::JsonParse { + path: target.display().to_string(), + source, + })?; + + let result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp)?; + file.write_all(&bytes)?; + file.write_all(b"\n")?; + file.sync_all()?; + fs::rename(&temp, &target) + })(); + if let Err(source) = result { + let _ = fs::remove_file(&temp); + return Err(MigrateError::Io { + path: target.display().to_string(), + source, + }); + } + Ok(target) +} + #[cfg(test)] mod tests { use super::*; @@ -227,7 +283,10 @@ mod tests { fn entity_fields_round_trip_correctly() { let f = write_fixture(FIXTURE_V13); let entries = read_entity_registry(f.path()).unwrap(); - let light = entries.iter().find(|e| e.entity_id.as_str() == "light.kitchen").unwrap(); + let light = entries + .iter() + .find(|e| e.entity_id.as_str() == "light.kitchen") + .unwrap(); assert_eq!(light.unique_id.as_deref(), Some("hue_lamp_42")); assert_eq!(light.platform, "hue"); assert_eq!(light.name.as_deref(), Some("Kitchen lamp")); @@ -238,6 +297,21 @@ mod tests { assert_eq!(light.config_entry_id.as_deref(), Some("ce_001")); } + #[test] + fn writes_atomic_compatible_registry_without_overwrite() { + let source = write_fixture(FIXTURE_V13); + let entries = read_entity_registry(source.path()).unwrap(); + let destination = tempfile::tempdir().unwrap(); + + let path = write_entity_registry(destination.path(), &entries).unwrap(); + let imported = read_entity_registry(&path).unwrap(); + assert_eq!(imported.len(), entries.len()); + assert_eq!(imported[0].entity_id, entries[0].entity_id); + + let second = write_entity_registry(destination.path(), &entries).unwrap_err(); + assert!(second.to_string().contains("refusing to overwrite")); + } + #[test] fn disabled_by_maps_to_homecore() { let f = write_fixture(FIXTURE_V13); @@ -260,7 +334,13 @@ mod tests { let f = write_fixture(json); let err = read_entity_registry(f.path()).unwrap_err(); assert!( - matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }), + matches!( + err, + MigrateError::UnsupportedSchemaVersion { + minor_version: 99, + .. + } + ), "got: {err}" ); let msg = err.to_string(); diff --git a/v2/crates/homecore-migrate/src/main.rs b/v2/crates/homecore-migrate/src/main.rs index 68bf984b..95e2284b 100644 --- a/v2/crates/homecore-migrate/src/main.rs +++ b/v2/crates/homecore-migrate/src/main.rs @@ -44,8 +44,10 @@ fn main() -> anyhow::Result<()> { let entity_path = args.storage.join("core.entity_registry"); let entries = homecore_migrate::entity_registry::read_entity_registry(&entity_path)?; - println!("Imported {} entity entries (P1: in-memory only)", entries.len()); - println!(" Destination: {} (P2 persistence)", args.to.display()); + let destination = + homecore_migrate::entity_registry::write_entity_registry(&args.to, &entries)?; + println!("Imported {} entity entries", entries.len()); + println!(" Destination: {}", destination.display()); for e in &entries { println!( " {} ({}{})", diff --git a/v2/crates/homecore-recorder/src/db.rs b/v2/crates/homecore-recorder/src/db.rs index 3d59f24a..5611e6e1 100644 --- a/v2/crates/homecore-recorder/src/db.rs +++ b/v2/crates/homecore-recorder/src/db.rs @@ -25,6 +25,18 @@ use homecore::event::{DomainEvent, StateChangedEvent}; use crate::dedup::fnv64a_hash; use crate::schema::ALL_DDL; +type SearchStateRecord = ( + i64, + String, + String, + Option, + f64, + f64, + Option, +); +type StateRecord = (String, String, Option, f64, f64, Option); +type HistoryStateRecord = (i64, String, Option, f64, f64, Option); + /// Hard upper bound on rows returned by [`Recorder::get_state_history`]. /// /// Without this cap a wide `[since, until]` window over a high-frequency entity @@ -289,8 +301,7 @@ impl Recorder { .replace('_', "\\_"); let pattern = format!("%{escaped}%"); - let rows: Vec<(i64, String, String, Option, f64, f64, Option)> = - sqlx::query_as( + let rows: Vec = sqlx::query_as( "SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \ s.last_changed_ts, s.last_updated_ts, s.context_id \ FROM states s \ @@ -332,8 +343,7 @@ impl Recorder { /// Fetch a single `StateRow` by its `state_id`, joining attributes. async fn fetch_state_row(&self, state_id: i64) -> Result, RecorderError> { - let row: Option<(String, String, Option, f64, f64, Option)> = - sqlx::query_as( + let row: Option = sqlx::query_as( "SELECT s.entity_id, s.state, sa.shared_attrs, \ s.last_changed_ts, s.last_updated_ts, s.context_id \ FROM states s \ @@ -409,7 +419,7 @@ impl Recorder { let since_ts = since.timestamp_micros() as f64 / 1_000_000.0; let until_ts = until.timestamp_micros() as f64 / 1_000_000.0; - let rows: Vec<(i64, String, Option, f64, f64, Option)> = sqlx::query_as( + let rows: Vec = sqlx::query_as( "SELECT s.state_id, s.state, sa.shared_attrs, \ s.last_changed_ts, s.last_updated_ts, s.context_id \ FROM states s \ @@ -884,7 +894,6 @@ mod tests { // public method (whose cap is MAX_HISTORY_ROWS) and run the *same* SQL // shape with a small bind to demonstrate the LIMIT term is effective — // and separately assert the constant is a sane positive bound. - assert!(MAX_HISTORY_ROWS > 0, "history cap must be positive"); let recorder = open_memory().await; for v in &["1", "2", "3", "4", "5"] { recorder diff --git a/v2/crates/homecore-recorder/src/lib.rs b/v2/crates/homecore-recorder/src/lib.rs index 4173d9a3..9391607f 100644 --- a/v2/crates/homecore-recorder/src/lib.rs +++ b/v2/crates/homecore-recorder/src/lib.rs @@ -30,9 +30,12 @@ pub mod schema; pub mod semantic; // Re-export the primary public API surface. -pub use db::{PurgeStats, Recorder, RecorderError, StateRow, MAX_HISTORY_ROWS}; +pub use db::{PurgeStats, Recorder, RecorderError, SemanticIndex, StateRow, MAX_HISTORY_ROWS}; pub use listener::RecorderListener; /// Null semantic index used when the `ruvector` feature is off. /// Satisfies the [`db::SemanticIndex`] trait bound without any allocation. pub use db::NullSemanticIndex; + +#[cfg(feature = "ruvector")] +pub use semantic::RuvectorSemanticIndex; diff --git a/v2/crates/homecore-server/Cargo.toml b/v2/crates/homecore-server/Cargo.toml index 9ea8c809..b47db9c0 100644 --- a/v2/crates/homecore-server/Cargo.toml +++ b/v2/crates/homecore-server/Cargo.toml @@ -23,8 +23,7 @@ path = "src/main.rs" # The 8 HOMECORE crates this binary integrates homecore = { path = "../homecore", version = "0.1.0-alpha.0" } homecore-api = { path = "../homecore-api", version = "0.1.0-alpha.0" } -homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0" } -homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0" } +homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0", optional = true } homecore-recorder = { path = "../homecore-recorder", 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" } @@ -50,6 +49,7 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors"] } # CHANGELOG / ADR-131 security note). reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" # Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf). futures = "0.3" @@ -63,4 +63,4 @@ default = [] # Pull in ruvector-backed semantic memory. ruvector = ["homecore-recorder/ruvector"] # Pull in real Wasmtime plugin runtime (vs InProcessRuntime). -wasmtime = ["homecore-plugins/wasmtime"] +wasmtime = ["dep:homecore-plugins", "homecore-plugins/wasmtime"] diff --git a/v2/crates/homecore-server/README.md b/v2/crates/homecore-server/README.md index 79ddba33..9784658d 100644 --- a/v2/crates/homecore-server/README.md +++ b/v2/crates/homecore-server/README.md @@ -1,204 +1,81 @@ # homecore-server -Integrated HOMECORE server binary that wires state machine, API, recorder, plugins, automations, intent assistant, and HomeKit bridge into one process. +`homecore-server` is the alpha integration binary for HOMECORE. It runs one +shared state machine, event bus, service registry, REST/WebSocket API, optional +SQLite recorder, automation engine, intent endpoint, BFF gateway, and static +dashboard. -[![Crates.io](https://img.shields.io/badge/crates.io-workspace%20binary-inactive)](.) -![License](https://img.shields.io/badge/license-MIT-blue.svg) -![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg) -[![ADR-126](https://img.shields.io/badge/ADR-126-orange.svg)](../../docs/adr/ADR-126-homecore-home-assistant-port.md) +It is not a drop-in replacement for the complete Home Assistant API. The exact +implemented and deferred surfaces are listed in +[`docs/homecore-capabilities.md`](../../docs/homecore-capabilities.md). -The production-ready HOMECORE binary — boots all 7 subsystems (core, API, recorder, plugins, automation, assist, HAP bridge) in a single process listening on `:8123`. +## Security defaults -## What this crate does - -`homecore-server` is the integration point for the entire HOMECORE ecosystem. It orchestrates: - -1. **HomeCore runtime** — state machine, event bus, service registry -2. **REST + WebSocket API** — Axum server on `:8123` (HA-compatible) -3. **SQLite Recorder** — persists all state changes to disk -4. **Plugin Registry** — loads and manages integrations (InProcessRuntime by default) -5. **Automation Engine** — evaluates triggers, conditions, and actions -6. **Assist Pipeline** — intent recognition and execution -7. **HAP Bridge** — exposes accessories to HomeKit - -All subsystems share the same `HomeCore` instance, so state changes flow through the event bus and trigger automations, record history, and notify WebSocket subscribers in lockstep. - -## Features - -- **Single unified process** — no external microservices; run with `cargo run -p homecore-server` -- **HA-compatible REST API** — drop-in replacement for Home Assistant's `/api/` on `:8123` -- **SQLite state history** — persistent recording of all state changes -- **Automation engine** — YAML-driven trigger→condition→action execution -- **Intent assistant** — regex-based (P1) intent recognition + service calling -- **HomeKit bridge** — exposes HOMECORE entities as HomeKit accessories -- **Plugin system** — load first-party Rust plugins; Wasmtime WASM plugins (P2, `--features wasmtime`) -- **Configurable via CLI + env vars** — no YAML required; sensible defaults -- **Structured logging** — tracing output with `RUST_LOG` filtering -- **Feature-gated subsystems** — disable recorder (`--no-recorder`), enable ruvector/wasmtime as needed - -## Subsystems - -| Subsystem | Crate | Role | Notes | -|-----------|-------|------|-------| -| State Machine | `homecore` | Core domain model | All other subsystems depend on this | -| REST API | `homecore-api` | HTTP boundary | Listens on `:8123`; Axum framework | -| Recorder | `homecore-recorder` | Persistence | SQLite; optional `--no-recorder` | -| Plugins | `homecore-plugins` | Extension system | InProcessRuntime default; Wasmtime w/ feature | -| Automation | `homecore-automation` | Trigger execution | Subscribes to event bus; YAML-driven | -| Assist | `homecore-assist` | Intent pipeline | Regex recognizer (P1); semantic (P2) | -| HAP Bridge | `homecore-hap` | HomeKit export | Accessories + characteristics; mDNS (P2) | - -## Usage - -**Basic startup** (in-memory recorder): +Authentication fails closed. Set one or more comma-separated bearer tokens: ```bash -cargo build -p homecore-server -./target/debug/homecore-server -# Listens on http://localhost:8123 +set HOMECORE_TOKENS=replace-with-a-long-random-token +cargo run -p homecore-server ``` -**With persistent SQLite**: +`--insecure-dev-auth` explicitly allows any non-empty bearer token and must only +be used in an isolated development environment. The browser UI does not contain +a default token. Configure allowed browser origins with +`HOMECORE_CORS_ORIGINS`. + +## Runtime behavior + +- SQLite recording is enabled by default at `sqlite://homecore.db`. +- Synthetic entities are disabled by default; opt in with + `--seed-demo-entities`. +- Automations can be loaded with `--automations ` or + `HOMECORE_AUTOMATIONS`. +- `Ctrl-C` initiates graceful HTTP shutdown and emits `HomeCoreStop`. +- The `ruvector` feature enables the recorder's semantic index. +- Plugin and HAP crates are libraries in this workspace, but this binary does + not claim to load plugins or advertise a HomeKit server yet. + +## API + +All API requests require `Authorization: Bearer `. ```bash -./target/debug/homecore-server \ - --bind 0.0.0.0:8123 \ - --db sqlite:~/.homecore/home.db \ - --location-name "My Home" -``` +curl -H "Authorization: Bearer $HOMECORE_TOKEN" \ + http://127.0.0.1:8123/api/states -**Full feature build** (ruvector semantic search + Wasmtime plugins): - -```bash -cargo build -p homecore-server --features ruvector,wasmtime --release -``` - -**Via Docker** (Dockerfile planned P2): - -```bash -docker run -p 8123:8123 \ - -e HOMECORE_DB=sqlite:///data/home.db \ - -v ~/.homecore:/data \ - homecore-server:latest -``` - -**Test the API**: - -```bash -# List all entities -curl http://localhost:8123/api/states - -# Set a light to "on" curl -X POST \ + -H "Authorization: Bearer $HOMECORE_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"state":"on","attributes":{"brightness":200}}' \ - http://localhost:8123/api/states/light.kitchen + -d '{"entity_id":"light.kitchen"}' \ + http://127.0.0.1:8123/api/services/light/turn_on -# WebSocket subscription (real-time state changes) -wscat -c ws://localhost:8123/api/websocket +curl -X POST \ + -H "Authorization: Bearer $HOMECORE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"utterance":"turn on light.kitchen","language":"en"}' \ + http://127.0.0.1:8123/api/intent/handle ``` -**Configuration via env**: +Built-in executable services are `homecore.ping`, +`homecore.snapshot_state`, and `turn_on`/`turn_off`/`toggle` for the +`homeassistant`, `light`, and `switch` domains. Unsupported services are left +unregistered and return an error rather than a false acknowledgement. -```bash -export HOMECORE_BIND="0.0.0.0:8123" -export HOMECORE_DB="sqlite:~/.homecore/home.db" -export HOMECORE_LOCATION="Living Room" -export RUST_LOG="homecore=debug,homecore_api=info" -./target/debug/homecore-server -``` +## Configuration -## CLI Options +| Flag | Environment | Default | +|---|---|---| +| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` | +| `--db` | `HOMECORE_DB` | `sqlite://homecore.db` | +| `--location-name` | `HOMECORE_LOCATION` | `Home` | +| `--automations` | `HOMECORE_AUTOMATIONS` | unset | +| `--insecure-dev-auth` | `HOMECORE_INSECURE_DEV_AUTH` | `false` | +| `--seed-demo-entities` | — | `false` | +| `--no-recorder` | — | `false` | -| Flag | Env Var | Default | Description | -|------|---------|---------|-------------| -| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` | REST API listen address | -| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) | -| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` | -| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) | -| `--ui-dir` | `HOMECORE_UI_DIR` | `/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount | - -## HOMECORE-UI dashboard (ADR-131) - -This binary also serves the **HOMECORE-UI** — the complete operational dashboard -for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at -`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency, -no-build-step vanilla TS/JS + CSS frontend living in `ui/`: - -```bash -cargo run -p homecore-server # then open http://localhost:8123/homecore/ -``` - -It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels -backed by services not in this binary (SEED HTTPS API, calibration ADR-151, -federation ADR-105) render against a DEMO-flagged contract-conformant mock until -those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain -`node` (no `npm install`): - -```bash -cd ui && npm test # import graph + render-smoke + interaction (24 checks) -cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing -``` - -## Comparison to Home Assistant - -| Aspect | Home Assistant | homecore-server | -|--------|----------------|-----------------| -| Architecture | Python asyncio monolith | Rust async Tokio + component traits | -| API protocol | `/api/` REST (HA wire format) | Identical HA wire format | -| Persistence | SQLite + YAML files | SQLite (P1); Redis (P2) | -| Plugins | Python integrations in `homeassistant/components/` | Rust (P1) + WASM (P2) | -| Automation execution | Python asyncio event loop | Tokio async tasks + trait-based | -| HomeKit bridge | Via `homekit` integration | Built-in `homecore-hap` subsystem | -| CLI | `hass` command with config YAML | `homecore-server` with feature flags | -| Scalability | Single instance (HA Cloud for scale) | Can be load-balanced (future) | -| Binary size | ~200 MB (Python + deps) | ~50 MB (Rust, release build; 200 MB w/ wasmtime) | - -## Performance Targets (unreleased; TBD) - -- **Startup time** — < 2s to listen on `:8123` -- **REST endpoint latency** — p50 < 1 ms; p99 < 10 ms -- **Event bus throughput** — 10,000+ events/sec -- **Automation evaluation** — < 100 μs per trigger -- **Concurrent WebSocket connections** — 10,000+ -- **Memory footprint** — ~100 MB (idle); ~500 MB with 1,000 recorded states - -## Development - -**Run tests**: +## Validation ```bash cargo test -p homecore-server +cargo clippy -p homecore-server --all-targets --all-features -- -D warnings ``` - -**Enable debug logging**: - -```bash -RUST_LOG=debug cargo run -p homecore-server -- --bind 127.0.0.1:8123 -``` - -**Build documentation**: - -```bash -cargo doc -p homecore-server --open -``` - -## Relation to other HOMECORE crates - -``` -homecore-server (orchestration binary) -├── homecore (state machine) -├── homecore-api (REST + WS) -├── homecore-recorder (SQLite persistence) -├── homecore-plugins (extension system) -├── homecore-automation (trigger execution) -├── homecore-assist (intent pipeline) -└── homecore-hap (HomeKit bridge) -``` - -## References - -- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md) -- [README — wifi-densepose](../../../README.md) -- [Dockerfile (planned P2)](Dockerfile.planned) -- [Docker Hub image (planned P2)](https://hub.docker.com/r/ruvnet/homecore-server) diff --git a/v2/crates/homecore-server/src/gateway.rs b/v2/crates/homecore-server/src/gateway.rs index 5d67fad6..2f1f1816 100644 --- a/v2/crates/homecore-server/src/gateway.rs +++ b/v2/crates/homecore-server/src/gateway.rs @@ -29,12 +29,16 @@ use axum::body::Bytes; use axum::extract::{Path, RawQuery, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Json, Router}; +use serde::Deserialize; use serde_json::{json, Value}; use homecore_api::auth::BearerAuth; use homecore_api::SharedState; +use homecore_assist::{AssistPipeline, RegexIntentRecognizer}; +#[cfg(test)] +use homecore_assist::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn}; /// Static gateway configuration (from CLI/env in `main`). pub struct GatewayConfig { @@ -54,15 +58,36 @@ pub struct GatewayState { pub shared: SharedState, pub http: reqwest::Client, pub cfg: Arc, + pub assist: Arc>, } impl GatewayState { + #[cfg(test)] pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self { + let mut assist = AssistPipeline::new(RegexIntentRecognizer::new()); + assist.register_handler(HassTurnOn); + assist.register_handler(HassTurnOff); + assist.register_handler(HassLightSet); + assist.register_handler(HassNevermind); + assist.register_handler(HassCancelAll); + Self::with_assist(shared, cfg, assist) + } + + pub fn with_assist( + shared: SharedState, + cfg: GatewayConfig, + assist: AssistPipeline, + ) -> Self { let http = reqwest::Client::builder() .timeout(cfg.timeout) .build() .unwrap_or_else(|_| reqwest::Client::new()); - Self { shared, http, cfg: Arc::new(cfg) } + Self { + shared, + http, + cfg: Arc::new(cfg), + assist: Arc::new(assist), + } } } @@ -76,6 +101,7 @@ pub fn gateway_router(state: GatewayState) -> Router { .route("/api/homecore/rooms", get(rooms)) .route("/api/homecore/cogs", get(cogs_list)) .route("/api/homecore/appliance", get(appliance)) + .route("/api/intent/handle", post(handle_intent)) // ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ─────── .route("/api/homecore/seeds", get(stub_503)) .route("/api/homecore/seeds/:id", get(stub_503)) @@ -93,6 +119,43 @@ pub fn gateway_router(state: GatewayState) -> Router { .with_state(state) } +#[derive(Deserialize)] +struct IntentRequest { + #[serde(alias = "text")] + utterance: String, + #[serde(default = "default_language")] + language: String, +} + +fn default_language() -> String { + "en".to_owned() +} + +async fn handle_intent( + State(st): State, + headers: HeaderMap, + Json(request): Json, +) -> Response { + if let Err(response) = require_auth(&headers, &st).await { + return response; + } + if request.utterance.trim().is_empty() { + return bad_request("utterance cannot be empty"); + } + match st + .assist + .process(&request.utterance, &request.language, st.shared.homecore()) + .await + { + Ok(response) => Json(response).into_response(), + Err(error) => typed( + StatusCode::UNPROCESSABLE_ENTITY, + "intent_failed", + &error.to_string(), + ), + } +} + // ── auth + typed errors ───────────────────────────────────────────── async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> { @@ -106,7 +169,11 @@ fn typed(status: StatusCode, error: &str, detail: &str) -> Response { (status, Json(json!({ "error": error, "detail": detail }))).into_response() } fn upstream_unavailable(detail: &str) -> Response { - typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail) + typed( + StatusCode::SERVICE_UNAVAILABLE, + "upstream_unavailable", + detail, + ) } fn upstream_timeout(detail: &str) -> Response { typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail) @@ -130,33 +197,32 @@ fn bad_request(detail: &str) -> Response { /// like `%252e` decodes once to `%2e` and is caught here). /// /// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged. -fn validate_proxy_path(path: &str) -> Result<(), Response> { +fn validate_proxy_path(path: &str) -> Result<(), &'static str> { // 1. Reject on the raw form first (cheap; catches backslash + leading `/`). if path.starts_with('/') { - return Err(bad_request("proxied path must be relative (leading '/' not allowed)")); + return Err("proxied path must be relative (leading '/' not allowed)"); } if path.contains('\\') { - return Err(bad_request("proxied path must not contain a backslash")); + return Err("proxied path must not contain a backslash"); } // 2. Percent-decode once and re-check; reject if decoding is invalid. - let decoded = percent_decode_once(path) - .ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?; + let decoded = percent_decode_once(path).ok_or("proxied path has invalid percent-encoding")?; if decoded.starts_with('/') || decoded.contains('\\') { - return Err(bad_request("proxied path resolves to an absolute/traversal path")); + return Err("proxied path resolves to an absolute/traversal path"); } // 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an // encoded `%2e%2e%2f` cannot slip a dot-segment past the split. for form in [path, decoded.as_str()] { for seg in form.split(['/', '\\']) { if seg == "." || seg == ".." { - return Err(bad_request("proxied path must not contain '.' or '..' segments")); + return Err("proxied path must not contain '.' or '..' segments"); } } // Defence in depth: a residual encoded traversal marker survived the // single decode (e.g. originally double-encoded). Reject it outright. let lower = form.to_ascii_lowercase(); if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { - return Err(bad_request("proxied path must not contain encoded traversal markers")); + return Err("proxied path must not contain encoded traversal markers"); } } Ok(()) @@ -194,7 +260,9 @@ async fn stub_503(State(st): State, headers: HeaderMap) -> Respons if let Err(r) = require_auth(&headers, &st).await { return r; } - upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)") + upstream_unavailable( + "endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)", + ) } /// Auth-gated empty-array response (e.g. OTA updates with no feed wired). @@ -216,12 +284,14 @@ async fn cal_proxy_get( if let Err(r) = require_auth(&headers, &st).await { return r; } - if let Err(r) = validate_proxy_path(&path) { - return r; + if let Err(detail) = validate_proxy_path(&path) { + return bad_request(detail); } let base = match &st.cfg.calibration_url { Some(u) => u, - None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"), + None => return upstream_unavailable( + "calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)", + ), }; let qs = q.map(|s| format!("?{s}")).unwrap_or_default(); // The wildcard already carries the `v1/...` segment (the UI calls @@ -239,12 +309,14 @@ async fn cal_proxy_post( if let Err(r) = require_auth(&headers, &st).await { return r; } - if let Err(r) = validate_proxy_path(&path) { - return r; + if let Err(detail) = validate_proxy_path(&path) { + return bad_request(detail); } let base = match &st.cfg.calibration_url { Some(u) => u, - None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"), + None => return upstream_unavailable( + "calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)", + ), }; let url = format!("{}/api/{}", base.trim_end_matches('/'), path); let rb = st @@ -264,7 +336,8 @@ async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response { } match rb.send().await { Ok(resp) => { - let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let ct = resp .headers() .get(reqwest::header::CONTENT_TYPE) @@ -325,7 +398,10 @@ async fn rooms(State(st): State, headers: HeaderMap) -> Response { let base = base.as_str(); async move { let url = format!("{base}/api/v1/room/state?bank={bank}"); - fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v)) + fetch_json(st, &url) + .await + .ok() + .map(|v| adapt_room_state(&bank, &v)) } }); let out: Vec = futures::future::join_all(fetches) @@ -352,10 +428,7 @@ fn bank_names(v: &Value) -> Vec { _ => None, }) .collect(), - Value::Object(o) => o - .get("baselines") - .map(|b| bank_names(b)) - .unwrap_or_default(), + Value::Object(o) => o.get("baselines").map(bank_names).unwrap_or_default(), _ => Vec::new(), } } @@ -456,7 +529,11 @@ async fn cogs_list(State(st): State, headers: HeaderMap) -> Respon } fn read_pid(dir: &std::path::Path, id: &str) -> Option { - for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] { + for name in [ + format!("{id}.pid"), + "pid".to_string(), + "app.pid".to_string(), + ] { if let Ok(s) = std::fs::read_to_string(dir.join(&name)) { if let Ok(p) = s.trim().parse::() { return Some(p); @@ -515,7 +592,9 @@ async fn appliance(State(st): State, headers: HeaderMap) -> Respon } fn read_first_line(path: &str) -> Option { - std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string)) + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.lines().next().map(str::to_string)) } fn uptime_secs() -> Option { @@ -551,7 +630,9 @@ fn cpu_load_pct() -> Option { .next()? .parse::() .ok()?; - let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0); + let ncpu = std::thread::available_parallelism() + .map(|n| n.get() as f64) + .unwrap_or(1.0); Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0) } @@ -606,7 +687,9 @@ mod tests { .await .unwrap(); let status = resp.status(); - let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let b = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); (status, String::from_utf8_lossy(&b).into_owned()) } @@ -614,7 +697,12 @@ mod tests { async fn unauthenticated_is_rejected() { let app = gateway_router(gw()); let resp = app - .oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/api/homecore/cogs") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); @@ -636,7 +724,12 @@ mod tests { #[tokio::test] async fn seed_tier_routes_are_typed_503() { - for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] { + for p in [ + "/api/homecore/seeds", + "/api/homecore/federation", + "/api/homecore/witness", + "/api/events", + ] { let (status, body) = send(gateway_router(gw()), "GET", p).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503"); assert!(body.contains("upstream_unavailable"), "{p} typed body"); @@ -651,6 +744,46 @@ mod tests { assert!(body.contains("\"ram_pct\"")); } + #[tokio::test] + async fn intent_endpoint_executes_a_real_service() { + let state = gw(); + let hc = state.shared.homecore().clone(); + let id = homecore::EntityId::parse("light.kitchen").unwrap(); + hc.states().set( + id.clone(), + "off", + serde_json::json!({}), + homecore::Context::new(), + ); + crate::register_builtin_services(&hc).await; + let assist = crate::build_assist_pipeline().await.unwrap(); + let state = GatewayState::with_assist( + state.shared, + GatewayConfig { + calibration_url: None, + calibration_token: None, + apps_dir: PathBuf::from("/nonexistent-apps-dir"), + timeout: Duration::from_millis(200), + }, + assist, + ); + let response = gateway_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/intent/handle") + .header("authorization", "Bearer dev") + .header("content-type", "application/json") + .body(Body::from(r#"{"utterance":"turn on light.kitchen"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(hc.states().get(&id).unwrap().state, "on"); + } + #[test] fn adapt_room_state_maps_fields_and_preserves_null() { // breathing/heartbeat rename; None → null; anomaly gets a threshold. @@ -668,7 +801,10 @@ mod tests { assert_eq!(ui["stale"], true); assert_eq!(ui["presence"]["value"], "occupied"); assert_eq!(ui["breathing_bpm"]["value"], 12.0); - assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)"); + assert!( + ui["heart_bpm"].is_null(), + "None heartbeat must map to null (not trained)" + ); // §6 invariant 3: upstream RoomState carries no threshold here, so the // adapter must emit null (withheld) — NOT a fabricated constant. assert!( @@ -685,7 +821,10 @@ mod tests { "anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73}, }); let ui = adapt_room_state("bedroom_1", &cal); - assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through"); + assert_eq!( + ui["anomaly"]["threshold"], 0.73, + "real threshold must pass through" + ); } #[test] @@ -731,8 +870,15 @@ mod tests { ("POST", "/api/cal/v1/../../x"), ] { let (status, body) = send(gateway_router(gw()), method, path).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400"); - assert!(body.contains("bad_request"), "{method} {path} typed 400 body"); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "{method} {path} must be 400" + ); + assert!( + body.contains("bad_request"), + "{method} {path} typed 400 body" + ); assert!( !body.contains("upstream_unavailable"), "{method} {path} must NOT reach the upstream-config branch" @@ -746,13 +892,19 @@ mod tests { // "not configured" 503 (proving it was NOT blocked as traversal). let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch"); + assert!( + body.contains("upstream_unavailable"), + "legit path should reach upstream branch" + ); } #[test] fn bank_names_accepts_strings_and_objects() { assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]); - assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]); + assert_eq!( + bank_names(&json!([{"name":"x"}, {"id":"y"}])), + vec!["x", "y"] + ); assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]); } } diff --git a/v2/crates/homecore-server/src/main.rs b/v2/crates/homecore-server/src/main.rs index 9f5e3255..305dc3ae 100644 --- a/v2/crates/homecore-server/src/main.rs +++ b/v2/crates/homecore-server/src/main.rs @@ -5,10 +5,8 @@ //! - HomeCore runtime (state machine + event bus + service registry) //! - SQLite recorder writing every state_changed event //! - REST + WebSocket API on :8123 (HA wire-compat) -//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime) //! - Automation engine subscribed to the state machine //! - Assist pipeline (intent recognizer + handler set) -//! - HAP bridge surface (accessories registered via the API) //! //! Run with: //! @@ -19,21 +17,20 @@ //! cargo run -p homecore-server --features ruvector,wasmtime -- ... use std::net::SocketAddr; -use std::sync::Arc; use anyhow::Result; use clap::Parser; use tracing::{info, warn}; -use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName}; use homecore::service::FnHandler; +use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName}; use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState}; -use homecore_assist::pipeline::default_pipeline; -use homecore_assist::RegexIntentRecognizer; +use homecore_assist::{ + AssistPipeline, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn, + RegexIntentRecognizer, +}; use homecore_automation::AutomationEngine; -use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord}; -use homecore_plugins::{InProcessRuntime, PluginRegistry}; -use homecore_recorder::Recorder; +use homecore_recorder::{Recorder, RecorderListener}; use axum::Router; use tower_http::services::ServeDir; @@ -71,7 +68,11 @@ struct Cli { calibration_token: Option, /// COG install directory the gateway's supervisor reads (ADR-131 §11.6). - #[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")] + #[arg( + long, + env = "HOMECORE_APPS_DIR", + default_value = "/var/lib/cognitum/apps" + )] apps_dir: String, /// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1). @@ -79,7 +80,7 @@ struct Cli { gateway_timeout_ms: u64, /// SQLite recorder DB path. Use `:memory:` for an ephemeral run. - #[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")] + #[arg(long, env = "HOMECORE_DB", default_value = "sqlite://homecore.db")] db: String, /// Friendly location name surfaced via `/api/config`. @@ -90,20 +91,50 @@ struct Cli { #[arg(long)] no_recorder: bool, - /// Skip the boot-time entity seeding (10 demo entities including - /// 4 RuView-derived sensors). Use this when wiring real - /// integrations that will populate the state machine themselves. + /// Explicitly allow any non-empty bearer token. Development only. + #[arg(long, env = "HOMECORE_INSECURE_DEV_AUTH", default_value_t = false)] + insecure_dev_auth: bool, + + /// Seed synthetic demo entities. Disabled by default so simulated + /// biometric readings are never mistaken for live sensor data. #[arg(long)] - no_seed_entities: bool, + seed_demo_entities: bool, + + /// Optional Home Assistant-style automations YAML file to load at boot. + #[arg(long, env = "HOMECORE_AUTOMATIONS")] + automations: Option, } #[tokio::main] async fn main() -> Result<()> { init_tracing(); let cli = Cli::parse(); + let has_tokens = std::env::var("HOMECORE_TOKENS") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + if !has_tokens && !cli.insecure_dev_auth { + anyhow::bail!( + "HOMECORE_TOKENS is required; use --insecure-dev-auth only for isolated development" + ); + } + let tokens = if has_tokens { + let store = LongLivedTokenStore::from_env(); + info!( + "Provisioned {} bearer token(s) from HOMECORE_TOKENS", + store.len().await + ); + store + } else { + warn!( + "Insecure development authentication enabled: any non-empty bearer token is accepted" + ); + LongLivedTokenStore::allow_any_non_empty() + }; - info!("HOMECORE booting — bind={}, db={}, location={:?}", - cli.bind, cli.db, cli.location_name); + info!( + "HOMECORE booting — bind={}, db={}, location={:?}", + cli.bind, cli.db, cli.location_name + ); // ── 1. HomeCore runtime ───────────────────────────────────────── let hc = HomeCore::new(); @@ -114,33 +145,27 @@ async fn main() -> Result<()> { // first boot. These are no-op handlers (they just echo back the // call as JSON for observability) — integrations override them // by registering the same ServiceName later. - seed_default_services(&hc).await; + register_builtin_services(&hc).await; // Seed 10 representative entities so the web UI's Dashboard + // States pages have content out of the box. Operators registering // real integrations / plugins overwrite these by writing the same // entity_id with new values. Opt out with `--no-seed-entities`. - if !cli.no_seed_entities { + if cli.seed_demo_entities { seed_default_entities(&hc); } else { - info!("Entity seeding disabled by --no-seed-entities"); + info!("Synthetic demo entities disabled (use --seed-demo-entities to opt in)"); } // ── 2. Recorder (optional) ────────────────────────────────────── if !cli.no_recorder { - match Recorder::open(&cli.db).await { + match open_recorder(&cli.db).await { Ok(recorder) => { - let recorder = Arc::new(recorder); - let rec_clone = Arc::clone(&recorder); - let mut state_rx = hc.states().subscribe(); - tokio::spawn(async move { - while let Ok(event) = state_rx.recv().await { - if let Err(e) = rec_clone.record_state(&event).await { - warn!("recorder write failed: {e}"); - } - } - }); - info!("Recorder open at {} — state_changed events being persisted", cli.db); + let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn(); + info!( + "Recorder open at {} — state_changed events being persisted", + cli.db + ); } Err(e) => { warn!("Recorder failed to open ({e}) — continuing without persistence"); @@ -151,11 +176,6 @@ async fn main() -> Result<()> { } // ── 3. Plugin runtime ─────────────────────────────────────────── - let plugin_runtime = InProcessRuntime; - let plugin_registry: PluginRegistry = PluginRegistry::new(plugin_runtime); - info!("Plugin registry ready (runtime: InProcess; Wasmtime available with --features wasmtime)"); - let _ = plugin_registry; // wired-but-empty at boot; integrations register here - // ── 4. Automation engine ──────────────────────────────────────── // Construct AND start the engine (HC-WS-03, ADR-161). `start()` // spawns the state-change event loop + the 1 Hz wall-clock timer @@ -166,6 +186,14 @@ async fn main() -> Result<()> { // boot yet (YAML loader is P-next); integrations register via // `engine.register(..)`. let automation_engine = AutomationEngine::new(hc.clone()); + if let Some(path) = &cli.automations { + let raw = tokio::fs::read_to_string(path).await?; + let automations: Vec = serde_yaml::from_str(&raw) + .map_err(|e| anyhow::anyhow!("invalid automations file {}: {e}", path.display()))?; + for automation in automations { + automation_engine.register(automation); + } + } let _automation_task = automation_engine.start(); info!( "Automation engine started ({} automations registered) — \ @@ -174,36 +202,12 @@ async fn main() -> Result<()> { ); // ── 5. Assist pipeline ────────────────────────────────────────── - let recognizer = RegexIntentRecognizer::new(); - let pipeline = default_pipeline(recognizer); - info!("Assist pipeline ready (5 built-in intent handlers via default_pipeline)"); - let _ = pipeline; // wired-but-idle at boot; voice WS plugs in here - // ── 6. HAP bridge surface ─────────────────────────────────────── - let hap_record = HapServiceRecord { - instance_name: "HOMECORE".to_string(), - port: 51826, - setup_code: "123-45-678".to_string(), - device_id: "AA:BB:CC:DD:EE:FF".to_string(), - }; - let hap_bridge = HapBridge::new(hap_record); - info!("HAP bridge surface ready ({} accessories registered)", hap_bridge.running_accessories().len()); - let _ = hap_bridge; - // ── 7. REST + WS API ──────────────────────────────────────────── // Token provisioning closes audit findings HC-01/HC-02. If // HOMECORE_TOKENS is set in the env, populate the store from // its comma-separated list. Otherwise fall back to DEV mode // (warn-on-each-request) so existing smoke tests still work. - let tokens = if std::env::var("HOMECORE_TOKENS").map(|v| !v.trim().is_empty()).unwrap_or(false) { - let s = LongLivedTokenStore::from_env(); - let n = s.len().await; - info!("LongLivedTokenStore provisioned with {} bearer token(s) from HOMECORE_TOKENS", n); - s - } else { - warn!("HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer accepted). Provision real tokens before exposing to the network."); - LongLivedTokenStore::allow_any_non_empty() - }; let api_state = SharedState::with_tokens( hc.clone(), cli.location_name, @@ -213,7 +217,12 @@ async fn main() -> Result<()> { // BFF gateway (ADR-131 §11): single-origin aggregation of the // calibration API + SEED/appliance tiers. Shares the same token store // for auth; upstream credentials stay server-side. - let gw = GatewayState::new( + let assist = build_assist_pipeline().await?; + info!( + "Assist intent endpoint ready with {} handlers", + assist.handler_count() + ); + let gw = GatewayState::with_assist( api_state.clone(), GatewayConfig { calibration_url: cli.calibration_url.clone(), @@ -221,6 +230,7 @@ async fn main() -> Result<()> { apps_dir: std::path::PathBuf::from(&cli.apps_dir), timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms), }, + assist, ); // Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the // audited CORS allowlist + request tracing to the WHOLE surface. The @@ -233,19 +243,35 @@ async fn main() -> Result<()> { .layer(build_cors_layer()) .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(cli.bind).await?; - info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind); + info!( + "HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", + cli.bind + ); info!( "HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})", cli.calibration_url ); if !cli.ui_dir.trim().is_empty() { - info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir); + info!( + "HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", + cli.bind, cli.ui_dir + ); } else { info!("HOMECORE-UI mount disabled (--ui-dir empty)"); } - // Run forever (until SIGINT). axum::serve handles graceful shutdown. - axum::serve(listener, app).await?; + let shutdown_hc = hc.clone(); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + if let Err(error) = tokio::signal::ctrl_c().await { + warn!("failed to install Ctrl-C handler: {error}"); + } + shutdown_hc + .bus() + .fire_system(homecore::SystemEvent::HomeCoreStop); + info!("Shutdown requested; draining active HTTP connections"); + }) + .await?; Ok(()) } @@ -263,11 +289,68 @@ fn build_app(api_state: SharedState, ui_dir: &str) -> Router { app.nest_service("/homecore", ServeDir::new(ui_dir)) } +#[cfg(not(feature = "ruvector"))] +async fn open_recorder(path: &str) -> anyhow::Result { + Ok(Recorder::open(path).await?) +} + +async fn build_assist_pipeline() -> anyhow::Result> { + let recognizer = RegexIntentRecognizer::new(); + recognizer + .register( + "HassTurnOn", + r"^turn on (?:the )?(?P[a-z0-9_]+\.[a-z0-9_]+)$", + "*", + ) + .await?; + recognizer + .register( + "HassTurnOff", + r"^turn off (?:the )?(?P[a-z0-9_]+\.[a-z0-9_]+)$", + "*", + ) + .await?; + recognizer + .register( + "HassLightSet", + r"^set (?Plight\.[a-z0-9_]+) to (?P[0-9]{1,3})$", + "*", + ) + .await?; + recognizer + .register("HassNevermind", r"^(?:never ?mind|cancel that)$", "*") + .await?; + recognizer + .register("HassCancelAll", r"^cancel all automations$", "*") + .await?; + + let mut pipeline = AssistPipeline::new(recognizer); + pipeline.register_handler(HassTurnOn); + pipeline.register_handler(HassTurnOff); + pipeline.register_handler(HassLightSet); + pipeline.register_handler(HassNevermind); + pipeline.register_handler(HassCancelAll); + Ok(pipeline) +} + +#[cfg(feature = "ruvector")] +async fn open_recorder(path: &str) -> anyhow::Result { + use homecore_recorder::{RuvectorSemanticIndex, SemanticIndex}; + use tokio::sync::RwLock; + + let index = RuvectorSemanticIndex::new(100_000) + .map_err(|error| anyhow::anyhow!("failed to initialize ruvector index: {error}"))?; + let semantic: std::sync::Arc> = + std::sync::Arc::new(RwLock::new(index)); + Ok(Recorder::open_with_index(path, semantic).await?) +} + fn init_tracing() { tracing_subscriber::fmt() .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()), + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + "info,homecore=debug,homecore_server=debug,tower_http=info".into() + }), ) .init(); } @@ -281,42 +364,125 @@ fn init_tracing() { /// light / switch / scene / automation domains) plus a `homecore.*` /// domain so operators can see HOMECORE-native services distinguished /// from the HA-compat ones. -async fn seed_default_services(hc: &HomeCore) { - let echo = || FnHandler(|call: ServiceCall| async move { - Ok(serde_json::json!({ - "called": format!("{}.{}", call.name.domain, call.name.service), - "service_data": call.data, - "acknowledged": true, - })) - }); +async fn register_builtin_services(hc: &HomeCore) { + hc.services() + .register( + ServiceName::new("homecore", "ping"), + FnHandler(|_call| async { Ok(serde_json::json!({ "pong": true })) }), + ) + .await; - let svcs = [ - // Conventional HA wire-compat services - ("homeassistant", "restart"), - ("homeassistant", "stop"), - ("homeassistant", "reload_core_config"), - ("light", "turn_on"), - ("light", "turn_off"), - ("light", "toggle"), - ("switch", "turn_on"), - ("switch", "turn_off"), - ("switch", "toggle"), - ("scene", "apply"), - ("automation", "trigger"), - // HOMECORE-native services - ("homecore", "ping"), - ("homecore", "snapshot_state"), - ]; + let snapshot_states = hc.states().clone(); + hc.services() + .register( + ServiceName::new("homecore", "snapshot_state"), + FnHandler(move |_call| { + let states = snapshot_states.clone(); + async move { Ok(serde_json::to_value(states.all()).unwrap_or_default()) } + }), + ) + .await; - for (domain, service) in svcs { - hc.services() - .register(ServiceName::new(domain, service), echo()) - .await; + for domain in ["homeassistant", "light", "switch"] { + for action in ["turn_on", "turn_off", "toggle"] { + let states = hc.states().clone(); + let required_domain = (domain != "homeassistant").then(|| domain.to_owned()); + hc.services() + .register( + ServiceName::new(domain, action), + FnHandler(move |call: ServiceCall| { + let states = states.clone(); + let required_domain = required_domain.clone(); + async move { + let ids = service_entity_ids(&call.data)?; + let mut changed = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(domain) = required_domain.as_deref() { + if id.domain() != domain { + return Err(ServiceError::HandlerFailed(format!( + "{}.{} only accepts {domain} entities", + call.name.domain, call.name.service + ))); + } + } + let current = states.get(&id).ok_or_else(|| { + ServiceError::HandlerFailed(format!( + "entity not found: {}", + id.as_str() + )) + })?; + let next = match call.name.service.as_str() { + "turn_on" => "on", + "turn_off" => "off", + "toggle" if current.state == "on" => "off", + "toggle" => "on", + _ => unreachable!("only registered actions are dispatched"), + }; + let mut attributes = current.attributes.clone(); + if next == "on" { + if let (Some(object), Some(brightness)) = + (attributes.as_object_mut(), call.data.get("brightness")) + { + object.insert("brightness".into(), brightness.clone()); + } + } + states.set( + id.clone(), + next, + attributes, + Context::child_of(&call.context), + ); + changed.push(id.as_str().to_owned()); + } + Ok(serde_json::json!({ "changed": changed })) + } + }), + ) + .await; + } } - let count = hc.services().registered_services().await.len(); - let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() }; - info!("Service registry seeded with {} default service(s)", count); + info!( + "Registered {} executable built-in services", + hc.services().registered_services().await.len() + ); +} + +fn service_entity_ids(data: &serde_json::Value) -> Result, ServiceError> { + let value = data + .get("entity_id") + .or_else(|| { + data.get("target") + .and_then(|target| target.get("entity_id")) + }) + .ok_or_else(|| ServiceError::HandlerFailed("missing entity_id".into()))?; + let raw_ids: Vec<&str> = match value { + serde_json::Value::String(value) => vec![value], + serde_json::Value::Array(values) => values + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + ServiceError::HandlerFailed("entity_id array must contain strings".into()) + }) + }) + .collect::>()?, + _ => { + return Err(ServiceError::HandlerFailed( + "entity_id must be a string or string array".into(), + )); + } + }; + if raw_ids.is_empty() { + return Err(ServiceError::HandlerFailed( + "entity_id array cannot be empty".into(), + )); + } + raw_ids + .into_iter() + .map(|value| { + EntityId::parse(value).map_err(|error| ServiceError::HandlerFailed(error.to_string())) + }) + .collect() } /// Register 10 representative entities so a fresh `--db :memory:` @@ -325,45 +491,85 @@ async fn seed_default_services(hc: &HomeCore) { /// they stay in sync. fn seed_default_entities(hc: &HomeCore) { let entities: Vec<(&str, &str, serde_json::Value)> = vec![ - ("sensor.living_room_presence", "false", serde_json::json!({ - "friendly_name": "Living Room Presence", "device_class": "occupancy", - "source": "RuView ESP32-C6 BFLD" - })), - ("sensor.living_room_motion_score", "0.0", serde_json::json!({ - "friendly_name": "Living Room Motion Score", "unit_of_measurement": "score", - "icon": "mdi:motion-sensor" - })), - ("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({ - "friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM", - "device_class": "frequency", "source": "Seeed MR60BHA2 mmWave" - })), - ("sensor.bedroom_heart_rate", "68.0", serde_json::json!({ - "friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM", - "device_class": "frequency", "source": "Seeed MR60BHA2 mmWave" - })), - ("light.kitchen_ceiling", "on", serde_json::json!({ - "friendly_name": "Kitchen Ceiling", "brightness": 230, - "color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"] - })), - ("light.living_room_lamp", "off", serde_json::json!({ - "friendly_name": "Living Room Lamp", "brightness": 0, - "supported_color_modes": ["brightness"] - })), - ("switch.coffee_maker", "off", serde_json::json!({ - "friendly_name": "Coffee Maker", "device_class": "outlet" - })), - ("binary_sensor.front_door", "off", serde_json::json!({ - "friendly_name": "Front Door", "device_class": "door" - })), - ("climate.thermostat", "heat", serde_json::json!({ - "friendly_name": "Thermostat", "current_temperature": 21.5, - "temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"], - "supported_features": 387 - })), - ("sensor.air_quality_index", "42", serde_json::json!({ - "friendly_name": "Air Quality Index", "unit_of_measurement": "AQI", - "device_class": "aqi" - })), + ( + "sensor.living_room_presence", + "false", + serde_json::json!({ + "friendly_name": "Living Room Presence", "device_class": "occupancy", + "source": "RuView ESP32-C6 BFLD" + }), + ), + ( + "sensor.living_room_motion_score", + "0.0", + serde_json::json!({ + "friendly_name": "Living Room Motion Score", "unit_of_measurement": "score", + "icon": "mdi:motion-sensor" + }), + ), + ( + "sensor.bedroom_breathing_rate", + "14.5", + serde_json::json!({ + "friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM", + "device_class": "frequency", "source": "Seeed MR60BHA2 mmWave" + }), + ), + ( + "sensor.bedroom_heart_rate", + "68.0", + serde_json::json!({ + "friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM", + "device_class": "frequency", "source": "Seeed MR60BHA2 mmWave" + }), + ), + ( + "light.kitchen_ceiling", + "on", + serde_json::json!({ + "friendly_name": "Kitchen Ceiling", "brightness": 230, + "color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"] + }), + ), + ( + "light.living_room_lamp", + "off", + serde_json::json!({ + "friendly_name": "Living Room Lamp", "brightness": 0, + "supported_color_modes": ["brightness"] + }), + ), + ( + "switch.coffee_maker", + "off", + serde_json::json!({ + "friendly_name": "Coffee Maker", "device_class": "outlet" + }), + ), + ( + "binary_sensor.front_door", + "off", + serde_json::json!({ + "friendly_name": "Front Door", "device_class": "door" + }), + ), + ( + "climate.thermostat", + "heat", + serde_json::json!({ + "friendly_name": "Thermostat", "current_temperature": 21.5, + "temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"], + "supported_features": 387 + }), + ), + ( + "sensor.air_quality_index", + "42", + serde_json::json!({ + "friendly_name": "Air Quality Index", "unit_of_measurement": "AQI", + "device_class": "aqi" + }), + ), ]; for (id, state, attrs) in entities { @@ -381,8 +587,11 @@ fn seed_default_entities(hc: &HomeCore) { context: Context::new(), }; let total = hc.states().all().len(); - info!("State machine seeded with {} default entit{}", total, - if total == 1 { "y" } else { "ies" }); + info!( + "State machine seeded with {} default entit{}", + total, + if total == 1 { "y" } else { "ies" } + ); } #[cfg(test)] @@ -419,9 +628,19 @@ mod ui_tests { async fn ui_index_is_served_at_homecore() { let app = build_app(test_state(), DEFAULT_UI_DIR); let (status, body) = get(app, "/homecore/").await; - assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html"); - assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE"); - assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js"); + assert_eq!( + status, + StatusCode::OK, + "GET /homecore/ should serve index.html" + ); + assert!( + body.contains("HOMECORE"), + "index.html should mention HOMECORE" + ); + assert!( + body.contains("./js/app.js"), + "index.html should bootstrap app.js" + ); } #[tokio::test] @@ -437,8 +656,18 @@ mod ui_tests { #[tokio::test] async fn ui_panels_are_served() { let app = build_app(test_state(), DEFAULT_UI_DIR); - for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail", - "entities", "cogs", "events", "audit", "settings"] { + for p in [ + "dashboard", + "rooms", + "calibration", + "fleet", + "seed-detail", + "entities", + "cogs", + "events", + "audit", + "settings", + ] { let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await; assert_eq!(status, StatusCode::OK, "panel {p}.js should be served"); } @@ -459,9 +688,15 @@ mod ui_tests { .await .unwrap(); let status = resp.status(); - let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); let body = String::from_utf8_lossy(&bytes); - assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount"); + assert_eq!( + status, + StatusCode::OK, + "the HA-compat API must coexist with the UI mount" + ); assert!(body.contains("API running")); } @@ -469,12 +704,16 @@ mod ui_tests { async fn ui_mount_can_be_disabled() { let app = build_app(test_state(), ""); let (status, _) = get(app, "/homecore/").await; - assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount"); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "empty --ui-dir disables the mount" + ); } /// Build the SAME merged + layered surface `main()` serves: API + UI mount /// + BFF gateway, with the audited CORS allowlist + tracing applied to the - /// whole thing. Used to prove the gateway routes are CORS-covered. + /// whole thing. Used to prove the gateway routes are CORS-covered. fn full_app(state: SharedState) -> Router { use crate::gateway::{GatewayConfig, GatewayState}; let gw = GatewayState::new( @@ -527,4 +766,47 @@ mod ui_tests { "gateway route must echo the allowlisted dev origin" ); } + + #[tokio::test] + async fn builtin_light_service_changes_real_state() { + let hc = HomeCore::new(); + let id = EntityId::parse("light.kitchen").unwrap(); + hc.states().set( + id.clone(), + "off", + serde_json::json!({"friendly_name": "Kitchen"}), + Context::new(), + ); + register_builtin_services(&hc).await; + + let result = hc + .services() + .call(ServiceCall { + name: ServiceName::new("light", "turn_on"), + data: serde_json::json!({"entity_id": "light.kitchen", "brightness": 123}), + context: Context::new(), + }) + .await + .unwrap(); + + assert_eq!(result["changed"][0], "light.kitchen"); + let state = hc.states().get(&id).unwrap(); + assert_eq!(state.state, "on"); + assert_eq!(state.attributes["brightness"], 123); + } + + #[tokio::test] + async fn unsupported_builtin_service_is_not_success_shaped() { + let hc = HomeCore::new(); + register_builtin_services(&hc).await; + let result = hc + .services() + .call(ServiceCall { + name: ServiceName::new("homeassistant", "restart"), + data: serde_json::json!({}), + context: Context::new(), + }) + .await; + assert!(matches!(result, Err(ServiceError::NotRegistered { .. }))); + } } diff --git a/v2/crates/homecore-server/ui/js/api.js b/v2/crates/homecore-server/ui/js/api.js index e53131d0..5cedf3f0 100644 --- a/v2/crates/homecore-server/ui/js/api.js +++ b/v2/crates/homecore-server/ui/js/api.js @@ -31,7 +31,7 @@ export function demoMode() { export const api = { base: '', - token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } }, + token: () => { try { return localStorage.getItem('homecore_token') || ''; } catch { return ''; } }, isDemo: (key) => !!demoFlags[key], anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0, demoMode, diff --git a/v2/crates/homecore/src/event.rs b/v2/crates/homecore/src/event.rs index 7856a1be..8caa3232 100644 --- a/v2/crates/homecore/src/event.rs +++ b/v2/crates/homecore/src/event.rs @@ -84,9 +84,23 @@ impl Default for Context { #[derive(Clone, Debug)] pub enum SystemEvent { StateChanged(StateChangedEvent), - ServiceRegistered { domain: String, service: String }, - ServiceRemoved { domain: String, service: String }, - ComponentLoaded { component: String }, + ServiceCalled { + domain: String, + service: String, + data: serde_json::Value, + context: Context, + }, + ServiceRegistered { + domain: String, + service: String, + }, + ServiceRemoved { + domain: String, + service: String, + }, + ComponentLoaded { + component: String, + }, HomeCoreStart, HomeCoreStarted, HomeCoreStop, diff --git a/v2/crates/homecore/src/homecore.rs b/v2/crates/homecore/src/homecore.rs index c71a8a03..41410ed6 100644 --- a/v2/crates/homecore/src/homecore.rs +++ b/v2/crates/homecore/src/homecore.rs @@ -24,11 +24,12 @@ struct HomeCoreInner { impl HomeCore { pub fn new() -> Self { + let bus = EventBus::new(); Self { inner: Arc::new(HomeCoreInner { - bus: EventBus::new(), - states: StateMachine::new(), - services: ServiceRegistry::new(), + states: StateMachine::with_event_bus(bus.clone()), + services: ServiceRegistry::with_event_bus(bus.clone()), + bus, entities: EntityRegistry::new(), }), } @@ -61,15 +62,76 @@ impl Default for HomeCore { mod tests { use super::*; use crate::entity::EntityId; - use crate::event::Context; + use crate::event::{Context, SystemEvent}; + use crate::service::{FnHandler, ServiceCall, ServiceName}; #[tokio::test] async fn end_to_end_set_then_get() { let hc = HomeCore::new(); let id = EntityId::parse("light.kitchen").unwrap(); - hc.states().set(id.clone(), "on", serde_json::json!({"brightness": 200}), Context::new()); + hc.states().set( + id.clone(), + "on", + serde_json::json!({"brightness": 200}), + Context::new(), + ); let snap = hc.states().get(&id).unwrap(); assert_eq!(snap.state, "on"); assert_eq!(snap.attributes["brightness"], 200); } + + #[tokio::test] + async fn state_changes_are_published_on_shared_system_bus() { + let hc = HomeCore::new(); + let mut rx = hc.bus().subscribe_system(); + let id = EntityId::parse("light.kitchen").unwrap(); + + hc.states() + .set(id.clone(), "on", serde_json::json!({}), Context::new()); + + let event = rx.recv().await.unwrap(); + match event { + SystemEvent::StateChanged(change) => { + assert_eq!(change.entity_id, id); + assert_eq!(change.new_state.unwrap().state, "on"); + } + other => panic!("expected StateChanged, got {other:?}"), + } + } + + #[tokio::test] + async fn service_calls_are_published_on_shared_system_bus() { + let hc = HomeCore::new(); + let service = ServiceName::new("light", "turn_on"); + hc.services() + .register( + service.clone(), + FnHandler(|_| async { Ok(serde_json::json!({})) }), + ) + .await; + let mut rx = hc.bus().subscribe_system(); + + hc.services() + .call(ServiceCall { + name: service, + data: serde_json::json!({"brightness": 42}), + context: Context::new(), + }) + .await + .unwrap(); + + match rx.recv().await.unwrap() { + SystemEvent::ServiceCalled { + domain, + service, + data, + .. + } => { + assert_eq!(domain, "light"); + assert_eq!(service, "turn_on"); + assert_eq!(data["brightness"], 42); + } + other => panic!("expected ServiceCalled, got {other:?}"), + } + } } diff --git a/v2/crates/homecore/src/registry.rs b/v2/crates/homecore/src/registry.rs index 2e637956..a5ffd053 100644 --- a/v2/crates/homecore/src/registry.rs +++ b/v2/crates/homecore/src/registry.rs @@ -56,7 +56,10 @@ impl EntityRegistry { } pub async fn register(&self, entry: EntityEntry) { - self.entries.write().await.insert(entry.entity_id.clone(), entry); + self.entries + .write() + .await + .insert(entry.entity_id.clone(), entry); } pub async fn get(&self, entity_id: &EntityId) -> Option { @@ -74,6 +77,10 @@ impl EntityRegistry { pub async fn len(&self) -> usize { self.entries.read().await.len() } + + pub async fn is_empty(&self) -> bool { + self.entries.read().await.is_empty() + } } impl Default for EntityRegistry { diff --git a/v2/crates/homecore/src/service.rs b/v2/crates/homecore/src/service.rs index ef22ab81..2a47def3 100644 --- a/v2/crates/homecore/src/service.rs +++ b/v2/crates/homecore/src/service.rs @@ -1,4 +1,4 @@ -//! Service registry stub. +//! Concurrent service registry with panic-isolated direct dispatch. //! //! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public //! surface + a simple direct-dispatch `call` so downstream ADRs can @@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::RwLock; -use crate::event::Context; +use crate::bus::EventBus; +use crate::event::{Context, SystemEvent}; /// Service name within a domain. e.g. `light.turn_on` → domain /// `"light"`, service `"turn_on"`. @@ -78,12 +79,22 @@ where #[derive(Clone)] pub struct ServiceRegistry { handlers: Arc>>>, + bus: Option, } impl ServiceRegistry { pub fn new() -> Self { + Self::new_inner(None) + } + + pub fn with_event_bus(bus: EventBus) -> Self { + Self::new_inner(Some(bus)) + } + + fn new_inner(bus: Option) -> Self { Self { handlers: Arc::new(RwLock::new(HashMap::new())), + bus, } } @@ -111,6 +122,14 @@ impl ServiceRegistry { /// that drives the engine. Mirrors HA isolating service-handler /// exceptions. pub async fn call(&self, call: ServiceCall) -> Result { + if let Some(bus) = &self.bus { + bus.fire_system(SystemEvent::ServiceCalled { + domain: call.name.domain.clone(), + service: call.name.service.clone(), + data: call.data.clone(), + context: call.context.clone(), + }); + } let handler = { let guard = self.handlers.read().await; guard.get(&call.name).cloned() diff --git a/v2/crates/homecore/src/state.rs b/v2/crates/homecore/src/state.rs index 275ffa79..44dca460 100644 --- a/v2/crates/homecore/src/state.rs +++ b/v2/crates/homecore/src/state.rs @@ -22,8 +22,9 @@ use chrono::Utc; use dashmap::DashMap; use tokio::sync::broadcast; +use crate::bus::EventBus; use crate::entity::{EntityId, State}; -use crate::event::{Context, StateChangedEvent}; +use crate::event::{Context, StateChangedEvent, SystemEvent}; /// Broadcast channel capacity for state-changed events. 4,096 events /// at 20 Hz per entity covers ~3 minutes of backlog for a single hot @@ -40,15 +41,28 @@ pub struct StateMachine { struct StateMachineInner { states: DashMap>, tx: broadcast::Sender, + bus: Option, } impl StateMachine { pub fn new() -> Self { + Self::new_inner(None) + } + + /// Create a state machine that also publishes every committed change on + /// the shared HOMECORE system event bus. Standalone state machines retain + /// their lightweight private broadcast channel via [`StateMachine::new`]. + pub fn with_event_bus(bus: EventBus) -> Self { + Self::new_inner(Some(bus)) + } + + fn new_inner(bus: Option) -> Self { let (tx, _) = broadcast::channel(STATE_CHANGED_CHANNEL_CAPACITY); Self { inner: Arc::new(StateMachineInner { states: DashMap::with_capacity(256), tx, + bus, }), } } @@ -135,7 +149,10 @@ impl StateMachine { fired_at: Utc::now(), }; // err = no receivers; that's fine, write still committed. - let _ = self.inner.tx.send(event); + let _ = self.inner.tx.send(event.clone()); + if let Some(bus) = &self.inner.bus { + bus.fire_system(SystemEvent::StateChanged(event)); + } } // `_guard` (and the shard lock) drops here, after the event is sent. next @@ -151,7 +168,10 @@ impl StateMachine { new_state: None, fired_at: Utc::now(), }; - let _ = self.inner.tx.send(event); + let _ = self.inner.tx.send(event.clone()); + if let Some(bus) = &self.inner.bus { + bus.fire_system(SystemEvent::StateChanged(event)); + } } removed } @@ -159,7 +179,11 @@ impl StateMachine { /// Snapshot all current states. Allocates a new Vec — useful for /// the REST GET /api/states path (ADR-130). pub fn all(&self) -> Vec> { - self.inner.states.iter().map(|r| Arc::clone(r.value())).collect() + self.inner + .states + .iter() + .map(|r| Arc::clone(r.value())) + .collect() } /// Snapshot all states whose entity_id matches a domain prefix. @@ -201,7 +225,12 @@ mod tests { async fn set_writes_and_fires() { let sm = StateMachine::new(); let mut rx = sm.subscribe(); - sm.set(id("light.kitchen"), "on", serde_json::json!({"brightness": 200}), Context::new()); + sm.set( + id("light.kitchen"), + "on", + serde_json::json!({"brightness": 200}), + Context::new(), + ); let evt = rx.recv().await.unwrap(); assert_eq!(evt.entity_id.as_str(), "light.kitchen"); assert!(evt.old_state.is_none()); @@ -222,9 +251,19 @@ mod tests { #[tokio::test] async fn attribute_only_change_fires_but_preserves_last_changed() { let sm = StateMachine::new(); - let s1 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "C"}), Context::new()); + let s1 = sm.set( + id("sensor.t"), + "20", + serde_json::json!({"unit": "C"}), + Context::new(), + ); tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let s2 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "F"}), Context::new()); + let s2 = sm.set( + id("sensor.t"), + "20", + serde_json::json!({"unit": "F"}), + Context::new(), + ); assert_eq!(s1.last_changed, s2.last_changed); assert!(s2.last_updated > s1.last_updated); } @@ -367,10 +406,7 @@ mod tests { drainer.join().unwrap(); let log = log.lock().unwrap(); - let dup = log - .windows(2) - .filter(|w| w[0] == w[1]) - .count(); + let dup = log.windows(2).filter(|w| w[0] == w[1]).count(); assert_eq!( dup, 0, "{dup} consecutive fired state_changed events carried an \ diff --git a/v2/docs/homecore-capabilities.md b/v2/docs/homecore-capabilities.md new file mode 100644 index 00000000..14de8f0a --- /dev/null +++ b/v2/docs/homecore-capabilities.md @@ -0,0 +1,50 @@ +# 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. + +## Implemented + +| Area | Runtime 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 | +| 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 | +| Dashboard/BFF | Static UI, calibration proxy, rooms, COG list, appliance metrics, and typed unavailable responses for absent upstreams | + +## Exact HA-style HTTP surface + +- `GET /api/` +- `GET /api/config` +- `GET /api/states` +- `GET|POST|DELETE /api/states/:entity_id` +- `GET /api/services` +- `POST /api/services/:domain/:service` +- `GET /api/websocket` +- `POST /api/intent/handle` + +This is a compatible subset, not full Home Assistant parity. + +## Explicitly deferred + +- 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 + +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.