fix(homecore): harden runtime and publish truthful capabilities (#1450)

This commit is contained in:
rUv 2026-07-27 10:41:43 -07:00 committed by GitHub
parent 13015c9d36
commit 581af67fbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1218 additions and 469 deletions

2
v2/Cargo.lock generated
View File

@ -3674,13 +3674,13 @@ dependencies = [
"homecore-api", "homecore-api",
"homecore-assist", "homecore-assist",
"homecore-automation", "homecore-automation",
"homecore-hap",
"homecore-plugins", "homecore-plugins",
"homecore-recorder", "homecore-recorder",
"http-body-util", "http-body-util",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tokio", "tokio",
"tower 0.5.3", "tower 0.5.3",
"tower-http", "tower-http",

View File

@ -40,13 +40,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env // Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env
// whitelist; fall back to DEV mode (warn-logged) only when unset. // 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()) .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 s = LongLivedTokenStore::from_env();
let n = s.len().await; 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 s
} else { } else {
tracing::warn!( tracing::warn!(

View File

@ -93,7 +93,7 @@ pub async fn get_state(
) -> ApiResult<Json<StateView>> { ) -> ApiResult<Json<StateView>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?; let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?; 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))) Ok(Json(StateView::from_state(&st)))
} }

View File

@ -1,5 +1,5 @@
use std::sync::Arc;
use homecore::HomeCore; use homecore::HomeCore;
use std::sync::Arc;
use crate::tokens::LongLivedTokenStore; use crate::tokens::LongLivedTokenStore;
@ -28,15 +28,13 @@ impl SharedState {
location_name: impl Into<String>, location_name: impl Into<String>,
homecore_version: impl Into<String>, homecore_version: impl Into<String>,
) -> Self { ) -> Self {
// P2 default: dev-mode token store (accepts any non-empty // Fail closed by default. Tests and explicitly insecure local
// bearer) so existing smoke tests still work; the // development must opt into `allow_any_non_empty()` themselves.
// `homecore-server` binary uses with_tokens() to provision a
// real store at boot.
Self::with_tokens( Self::with_tokens(
homecore, homecore,
location_name, location_name,
homecore_version, 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 homecore(&self) -> &HomeCore {
pub fn version(&self) -> &str { &self.inner.homecore_version } &self.inner.homecore
pub fn location_name(&self) -> &str { &self.inner.location_name } }
pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens } 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
}
} }

View File

@ -127,6 +127,10 @@ impl LongLivedTokenStore {
self.inner.read().await.tokens.len() 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)? /// Is the store accepting any non-empty bearer (DEV mode)?
pub async fn is_dev_mode(&self) -> bool { pub async fn is_dev_mode(&self) -> bool {
self.inner.read().await.allow_any self.inner.read().await.allow_any

View File

@ -20,7 +20,6 @@
//! drains the response channel onto the socket (HC-WS-02 closed the prior //! drains the response channel onto the socket (HC-WS-02 closed the prior
//! reply-theater where responses were logged and discarded). //! reply-theater where responses were logged and discarded).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
@ -28,6 +27,10 @@ use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; 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 tracing::warn;
use homecore::{Context, ServiceCall, ServiceName, SystemEvent}; use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
@ -49,7 +52,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
"type": "auth_required", "type": "auth_required",
"ha_version": state.version(), "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; return;
} }
@ -59,7 +66,8 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
_ => { _ => {
let _ = socket let _ = socket
.send(Message::Text( .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; .await;
return; return;
@ -85,7 +93,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
return; return;
} }
let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()}); 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; return;
} }
@ -140,7 +152,6 @@ struct ErrorView<'a> {
struct Connection { struct Connection {
state: SharedState, state: SharedState,
next_sub_id: AtomicU64,
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>, subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
} }
@ -152,7 +163,6 @@ impl Connection {
fn new(state: SharedState) -> Self { fn new(state: SharedState) -> Self {
Self { Self {
state, state,
next_sub_id: AtomicU64::new(1),
subs: Arc::new(dashmap::DashMap::new()), subs: Arc::new(dashmap::DashMap::new()),
} }
} }
@ -168,7 +178,7 @@ impl Connection {
// DISCARDED every message — so no `result`/`pong`/`event` ever // DISCARDED every message — so no `result`/`pong`/`event` ever
// reached the client. Now `rx` feeds `socket.send`. // reached the client. Now `rx` feeds `socket.send`.
let (mut sink, mut stream) = socket.split(); let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>(); let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(OUTBOUND_QUEUE_CAPACITY);
// Writer task: drain replies onto the socket. A `__pong:<n>` // Writer task: drain replies onto the socket. A `__pong:<n>`
// sentinel maps to a binary Pong control frame; everything else // sentinel maps to a binary Pong control frame; everything else
@ -205,7 +215,7 @@ impl Connection {
conn.handle_cmd(cmd, &reader_tx).await; conn.handle_cmd(cmd, &reader_tx).await;
} }
Ok(Message::Ping(p)) => { 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, Ok(Message::Close(_)) | Err(_) => break,
_ => {} _ => {}
@ -224,15 +234,16 @@ impl Connection {
let _ = writer_task.await; let _ = writer_task.await;
} }
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) { async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::Sender<String>) {
match cmd.kind.as_str() { match cmd.kind.as_str() {
"ping" => { "ping" => {
let msg = serde_json::json!({"id": cmd.id, "type": "pong"}); 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" => { "get_states" => {
let snapshots = self.state.homecore().states().all(); let snapshots = self.state.homecore().states().all();
let views: Vec<StateView> = snapshots.iter().map(|s| StateView::from_state(s)).collect(); let views: Vec<StateView> =
snapshots.iter().map(|s| StateView::from_state(s)).collect();
self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap())); self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap()));
} }
"get_config" => { "get_config" => {
@ -245,17 +256,28 @@ impl Connection {
} }
"get_services" => { "get_services" => {
let services = self.state.homecore().services().registered_services().await; let services = self.state.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> = let mut by_domain: std::collections::HashMap<
std::collections::HashMap::new(); String,
serde_json::Map<String, serde_json::Value>,
> = std::collections::HashMap::new();
for s in services { 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(); let payload = serde_json::to_value(by_domain).unwrap();
self.ack(tx, cmd.id, true, Some(payload)); self.ack(tx, cmd.id, true, Some(payload));
} }
"call_service" => { "call_service" => {
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else { let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone())
self.err(tx, cmd.id, "missing_domain_service", "domain and service are required"); else {
self.err(
tx,
cmd.id,
"missing_domain_service",
"domain and service are required",
);
return; return;
}; };
let call = ServiceCall { let call = ServiceCall {
@ -269,7 +291,13 @@ impl Connection {
} }
} }
"subscribe_events" => { "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 filter = cmd.event_type.clone();
let tx_clone = tx.clone(); let tx_clone = tx.clone();
let mut domain_rx = self.state.homecore().bus().subscribe_domain(); let mut domain_rx = self.state.homecore().bus().subscribe_domain();
@ -294,7 +322,27 @@ impl Connection {
"time_fired": sc.fired_at.to_rfc3339(), "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(_) => {} Ok(_) => {}
@ -323,7 +371,7 @@ impl Connection {
"time_fired": de.fired_at.to_rfc3339(), "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 // 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"); self.err(tx, cmd.id, "not_found", "subscription_id not found");
} }
} else { } else {
self.err(tx, cmd.id, "missing_subscription", "subscription is required"); self.err(
tx,
cmd.id,
"missing_subscription",
"subscription is required",
);
} }
} }
other => { 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 // entity_id is reserved for future per-entity subscribes
@ -366,7 +424,7 @@ impl Connection {
fn ack( fn ack(
&self, &self,
tx: &tokio::sync::mpsc::UnboundedSender<String>, tx: &tokio::sync::mpsc::Sender<String>,
id: u64, id: u64,
success: bool, success: bool,
result: Option<serde_json::Value>, result: Option<serde_json::Value>,
@ -378,10 +436,16 @@ impl Connection {
result, result,
error: None, 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<String>, id: u64, code: &'static str, message: &str) { fn err(
&self,
tx: &tokio::sync::mpsc::Sender<String>,
id: u64,
code: &'static str,
message: &str,
) {
let msg = ResultMessage { let msg = ResultMessage {
id, id,
kind: "result", kind: "result",
@ -389,7 +453,7 @@ impl Connection {
result: None, result: None,
error: Some(ErrorView { code, message }), error: Some(ErrorView { code, message }),
}; };
let _ = tx.send(serde_json::to_string(&msg).unwrap()); let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
} }
} }

View File

@ -80,7 +80,10 @@ async fn wrong_token_is_rejected() {
resp["type"], "auth_invalid", resp["type"], "auth_invalid",
"wrong token must be rejected with auth_invalid, got: {resp}" "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] #[tokio::test]
@ -99,7 +102,10 @@ async fn correct_token_is_accepted() {
.unwrap(); .unwrap();
let resp = next_json(&mut ws).await; 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] #[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)) let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
.await .await
.expect("did not receive a reply within 5s — reply theater (HC-WS-02)"); .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["id"], 1);
assert_eq!(reply["success"], true); assert_eq!(reply["success"], true);
} }
@ -263,3 +272,69 @@ async fn subscription_survives_broadcast_lag() {
); );
assert_eq!(got["event"]["data"]["marker"], "post-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);
}

View File

@ -74,7 +74,7 @@ impl Condition {
Condition::State { entity_id, state } => { Condition::State { entity_id, state } => {
ctx.states ctx.states
.get(entity_id) .get(entity_id)
.map_or(false, |s| s.state == *state) .is_some_and(|s| s.state == *state)
} }
Condition::NumericState { entity_id, above, below } => { Condition::NumericState { entity_id, above, below } => {
let value: Option<f64> = ctx let value: Option<f64> = ctx
@ -84,16 +84,13 @@ impl Condition {
match value { match value {
None => false, None => false,
Some(v) => { 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 } => { Condition::Template { value_template } => {
if let Some(env) = &ctx.template_env { if let Some(env) = &ctx.template_env {
match env.render_bool(value_template) { env.render_bool(value_template).unwrap_or_default()
Ok(v) => v,
Err(_) => false,
}
} else { } else {
false false
} }

View File

@ -106,7 +106,7 @@ impl Trigger {
pub fn matches_sync(&self, ctx: &TriggerContext) -> bool { pub fn matches_sync(&self, ctx: &TriggerContext) -> bool {
match self { match self {
Trigger::State { entity_id, from, to } => { 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 { if !eid_match {
return false; return false;
} }
@ -125,7 +125,7 @@ impl Trigger {
true true
} }
Trigger::NumericState { entity_id, above, below } => { 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 { if !eid_match {
return false; return false;
} }

View File

@ -26,7 +26,8 @@ The tool enforces version schema compatibility: unknown HA schema versions are r
## Features ## 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 - **Device registry inspection** — read HA device metadata; full conversion deferred to P2
- **Config entries analysis** — list active integrations by domain (enables gap analysis) - **Config entries analysis** — list active integrations by domain (enables gap analysis)
- **Secrets extraction** — read `secrets.yaml` references for annotation (resolution in P2) - **Secrets extraction** — read `secrets.yaml` references for annotation (resolution in P2)

View File

@ -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 serde::{Deserialize, Serialize};
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId}; use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
use crate::{ use crate::{storage::read_envelope, storage_format::v13, MigrateError};
storage::read_envelope,
storage_format::v13,
MigrateError,
};
// Key used by `inspect` subcommand when scanning the directory. // Key used by `inspect` subcommand when scanning the directory.
#[allow(dead_code)] #[allow(dead_code)]
@ -75,21 +73,21 @@ struct HaEntityRow {
// Fields present in v13 that we capture but do not yet map to HOMECORE. // Fields present in v13 that we capture but do not yet map to HOMECORE.
// Forwarded as Q5 items. // Forwarded as Q5 items.
#[serde(default)] #[serde(default)]
hidden_by: Option<String>, // v13: "user" | "integration" hidden_by: Option<String>, // v13: "user" | "integration"
#[serde(default)] #[serde(default)]
has_entity_name: Option<bool>, // v13: HA naming convention flag has_entity_name: Option<bool>, // v13: HA naming convention flag
#[serde(default)] #[serde(default)]
original_name: Option<String>, // v13: integration-provided default name original_name: Option<String>, // v13: integration-provided default name
#[serde(default)] #[serde(default)]
icon: Option<String>, // v13: mdi:xxx icon override icon: Option<String>, // v13: mdi:xxx icon override
#[serde(default)] #[serde(default)]
original_icon: Option<String>, // v13: integration-provided icon original_icon: Option<String>, // v13: integration-provided icon
#[serde(default)] #[serde(default)]
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
#[serde(default)] #[serde(default)]
capabilities: Option<serde_json::Value>, // v13: integration-specific caps capabilities: Option<serde_json::Value>, // v13: integration-specific caps
#[serde(default)] #[serde(default)]
supported_features: Option<u64>, // v13: bitmask supported_features: Option<u64>, // v13: bitmask
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -166,6 +164,64 @@ pub fn read_entity_registry(path: &Path) -> Result<Vec<EntityEntry>, MigrateErro
Ok(entries) 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<PathBuf, MigrateError> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -227,7 +283,10 @@ mod tests {
fn entity_fields_round_trip_correctly() { fn entity_fields_round_trip_correctly() {
let f = write_fixture(FIXTURE_V13); let f = write_fixture(FIXTURE_V13);
let entries = read_entity_registry(f.path()).unwrap(); 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.unique_id.as_deref(), Some("hue_lamp_42"));
assert_eq!(light.platform, "hue"); assert_eq!(light.platform, "hue");
assert_eq!(light.name.as_deref(), Some("Kitchen lamp")); 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")); 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] #[test]
fn disabled_by_maps_to_homecore() { fn disabled_by_maps_to_homecore() {
let f = write_fixture(FIXTURE_V13); let f = write_fixture(FIXTURE_V13);
@ -260,7 +334,13 @@ mod tests {
let f = write_fixture(json); let f = write_fixture(json);
let err = read_entity_registry(f.path()).unwrap_err(); let err = read_entity_registry(f.path()).unwrap_err();
assert!( assert!(
matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }), matches!(
err,
MigrateError::UnsupportedSchemaVersion {
minor_version: 99,
..
}
),
"got: {err}" "got: {err}"
); );
let msg = err.to_string(); let msg = err.to_string();

View File

@ -44,8 +44,10 @@ fn main() -> anyhow::Result<()> {
let entity_path = args.storage.join("core.entity_registry"); let entity_path = args.storage.join("core.entity_registry");
let entries = let entries =
homecore_migrate::entity_registry::read_entity_registry(&entity_path)?; homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
println!("Imported {} entity entries (P1: in-memory only)", entries.len()); let destination =
println!(" Destination: {} (P2 persistence)", args.to.display()); homecore_migrate::entity_registry::write_entity_registry(&args.to, &entries)?;
println!("Imported {} entity entries", entries.len());
println!(" Destination: {}", destination.display());
for e in &entries { for e in &entries {
println!( println!(
" {} ({}{})", " {} ({}{})",

View File

@ -25,6 +25,18 @@ use homecore::event::{DomainEvent, StateChangedEvent};
use crate::dedup::fnv64a_hash; use crate::dedup::fnv64a_hash;
use crate::schema::ALL_DDL; use crate::schema::ALL_DDL;
type SearchStateRecord = (
i64,
String,
String,
Option<String>,
f64,
f64,
Option<String>,
);
type StateRecord = (String, String, Option<String>, f64, f64, Option<String>);
type HistoryStateRecord = (i64, String, Option<String>, f64, f64, Option<String>);
/// Hard upper bound on rows returned by [`Recorder::get_state_history`]. /// Hard upper bound on rows returned by [`Recorder::get_state_history`].
/// ///
/// Without this cap a wide `[since, until]` window over a high-frequency entity /// Without this cap a wide `[since, until]` window over a high-frequency entity
@ -289,8 +301,7 @@ impl Recorder {
.replace('_', "\\_"); .replace('_', "\\_");
let pattern = format!("%{escaped}%"); let pattern = format!("%{escaped}%");
let rows: Vec<(i64, String, String, Option<String>, f64, f64, Option<String>)> = let rows: Vec<SearchStateRecord> = sqlx::query_as(
sqlx::query_as(
"SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \ "SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \ s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \ FROM states s \
@ -332,8 +343,7 @@ impl Recorder {
/// Fetch a single `StateRow` by its `state_id`, joining attributes. /// Fetch a single `StateRow` by its `state_id`, joining attributes.
async fn fetch_state_row(&self, state_id: i64) -> Result<Option<StateRow>, RecorderError> { async fn fetch_state_row(&self, state_id: i64) -> Result<Option<StateRow>, RecorderError> {
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> = let row: Option<StateRecord> = sqlx::query_as(
sqlx::query_as(
"SELECT s.entity_id, s.state, sa.shared_attrs, \ "SELECT s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \ s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \ FROM states s \
@ -409,7 +419,7 @@ impl Recorder {
let since_ts = since.timestamp_micros() as f64 / 1_000_000.0; 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 until_ts = until.timestamp_micros() as f64 / 1_000_000.0;
let rows: Vec<(i64, String, Option<String>, f64, f64, Option<String>)> = sqlx::query_as( let rows: Vec<HistoryStateRecord> = sqlx::query_as(
"SELECT s.state_id, s.state, sa.shared_attrs, \ "SELECT s.state_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \ s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \ FROM states s \
@ -884,7 +894,6 @@ mod tests {
// public method (whose cap is MAX_HISTORY_ROWS) and run the *same* SQL // 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 — // shape with a small bind to demonstrate the LIMIT term is effective —
// and separately assert the constant is a sane positive bound. // 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; let recorder = open_memory().await;
for v in &["1", "2", "3", "4", "5"] { for v in &["1", "2", "3", "4", "5"] {
recorder recorder

View File

@ -30,9 +30,12 @@ pub mod schema;
pub mod semantic; pub mod semantic;
// Re-export the primary public API surface. // 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; pub use listener::RecorderListener;
/// Null semantic index used when the `ruvector` feature is off. /// Null semantic index used when the `ruvector` feature is off.
/// Satisfies the [`db::SemanticIndex`] trait bound without any allocation. /// Satisfies the [`db::SemanticIndex`] trait bound without any allocation.
pub use db::NullSemanticIndex; pub use db::NullSemanticIndex;
#[cfg(feature = "ruvector")]
pub use semantic::RuvectorSemanticIndex;

View File

@ -23,8 +23,7 @@ path = "src/main.rs"
# The 8 HOMECORE crates this binary integrates # The 8 HOMECORE crates this binary integrates
homecore = { path = "../homecore", version = "0.1.0-alpha.0" } homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
homecore-api = { path = "../homecore-api", 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-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0", optional = true }
homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0" }
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" } homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" } homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" }
homecore-assist = { path = "../homecore-assist", version = "0.1.0-alpha.0" } homecore-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). # CHANGELOG / ADR-131 security note).
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf). # Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
futures = "0.3" futures = "0.3"
@ -63,4 +63,4 @@ default = []
# Pull in ruvector-backed semantic memory. # Pull in ruvector-backed semantic memory.
ruvector = ["homecore-recorder/ruvector"] ruvector = ["homecore-recorder/ruvector"]
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime). # Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
wasmtime = ["homecore-plugins/wasmtime"] wasmtime = ["dep:homecore-plugins", "homecore-plugins/wasmtime"]

View File

@ -1,204 +1,81 @@
# homecore-server # 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)](.) It is not a drop-in replacement for the complete Home Assistant API. The exact
![License](https://img.shields.io/badge/license-MIT-blue.svg) implemented and deferred surfaces are listed in
![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg) [`docs/homecore-capabilities.md`](../../docs/homecore-capabilities.md).
[![ADR-126](https://img.shields.io/badge/ADR-126-orange.svg)](../../docs/adr/ADR-126-homecore-home-assistant-port.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 Authentication fails closed. Set one or more comma-separated bearer tokens:
`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):
```bash ```bash
cargo build -p homecore-server set HOMECORE_TOKENS=replace-with-a-long-random-token
./target/debug/homecore-server cargo run -p homecore-server
# Listens on http://localhost:8123
``` ```
**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 <file>` 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 <token>`.
```bash ```bash
./target/debug/homecore-server \ curl -H "Authorization: Bearer $HOMECORE_TOKEN" \
--bind 0.0.0.0:8123 \ http://127.0.0.1:8123/api/states
--db sqlite:~/.homecore/home.db \
--location-name "My Home"
```
**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 \ curl -X POST \
-H "Authorization: Bearer $HOMECORE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"state":"on","attributes":{"brightness":200}}' \ -d '{"entity_id":"light.kitchen"}' \
http://localhost:8123/api/states/light.kitchen http://127.0.0.1:8123/api/services/light/turn_on
# WebSocket subscription (real-time state changes) curl -X POST \
wscat -c ws://localhost:8123/api/websocket -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 ## Configuration
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
```
## 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 | ## Validation
|------|---------|---------|-------------|
| `--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` | `<crate>/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**:
```bash ```bash
cargo test -p homecore-server 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)

View File

@ -29,12 +29,16 @@ use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State}; use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use homecore_api::auth::BearerAuth; use homecore_api::auth::BearerAuth;
use homecore_api::SharedState; 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`). /// Static gateway configuration (from CLI/env in `main`).
pub struct GatewayConfig { pub struct GatewayConfig {
@ -54,15 +58,36 @@ pub struct GatewayState {
pub shared: SharedState, pub shared: SharedState,
pub http: reqwest::Client, pub http: reqwest::Client,
pub cfg: Arc<GatewayConfig>, pub cfg: Arc<GatewayConfig>,
pub assist: Arc<AssistPipeline<RegexIntentRecognizer>>,
} }
impl GatewayState { impl GatewayState {
#[cfg(test)]
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self { 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<RegexIntentRecognizer>,
) -> Self {
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.timeout(cfg.timeout) .timeout(cfg.timeout)
.build() .build()
.unwrap_or_else(|_| reqwest::Client::new()); .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/rooms", get(rooms))
.route("/api/homecore/cogs", get(cogs_list)) .route("/api/homecore/cogs", get(cogs_list))
.route("/api/homecore/appliance", get(appliance)) .route("/api/homecore/appliance", get(appliance))
.route("/api/intent/handle", post(handle_intent))
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ─────── // ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
.route("/api/homecore/seeds", get(stub_503)) .route("/api/homecore/seeds", get(stub_503))
.route("/api/homecore/seeds/:id", 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) .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<GatewayState>,
headers: HeaderMap,
Json(request): Json<IntentRequest>,
) -> 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 ───────────────────────────────────────────── // ── auth + typed errors ─────────────────────────────────────────────
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> { 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() (status, Json(json!({ "error": error, "detail": detail }))).into_response()
} }
fn upstream_unavailable(detail: &str) -> 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 { fn upstream_timeout(detail: &str) -> Response {
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail) 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). /// like `%252e` decodes once to `%2e` and is caught here).
/// ///
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged. /// 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 `/`). // 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
if path.starts_with('/') { 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('\\') { 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. // 2. Percent-decode once and re-check; reject if decoding is invalid.
let decoded = percent_decode_once(path) let decoded = percent_decode_once(path).ok_or("proxied path has invalid percent-encoding")?;
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
if decoded.starts_with('/') || decoded.contains('\\') { 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 // 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. // encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
for form in [path, decoded.as_str()] { for form in [path, decoded.as_str()] {
for seg in form.split(['/', '\\']) { for seg in form.split(['/', '\\']) {
if seg == "." || seg == ".." { 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 // Defence in depth: a residual encoded traversal marker survived the
// single decode (e.g. originally double-encoded). Reject it outright. // single decode (e.g. originally double-encoded). Reject it outright.
let lower = form.to_ascii_lowercase(); let lower = form.to_ascii_lowercase();
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { 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(()) Ok(())
@ -194,7 +260,9 @@ async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Respons
if let Err(r) = require_auth(&headers, &st).await { if let Err(r) = require_auth(&headers, &st).await {
return r; 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). /// 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 { if let Err(r) = require_auth(&headers, &st).await {
return r; return r;
} }
if let Err(r) = validate_proxy_path(&path) { if let Err(detail) = validate_proxy_path(&path) {
return r; return bad_request(detail);
} }
let base = match &st.cfg.calibration_url { let base = match &st.cfg.calibration_url {
Some(u) => u, 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(); let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
// The wildcard already carries the `v1/...` segment (the UI calls // 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 { if let Err(r) = require_auth(&headers, &st).await {
return r; return r;
} }
if let Err(r) = validate_proxy_path(&path) { if let Err(detail) = validate_proxy_path(&path) {
return r; return bad_request(detail);
} }
let base = match &st.cfg.calibration_url { let base = match &st.cfg.calibration_url {
Some(u) => u, 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 url = format!("{}/api/{}", base.trim_end_matches('/'), path);
let rb = st let rb = st
@ -264,7 +336,8 @@ async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
} }
match rb.send().await { match rb.send().await {
Ok(resp) => { 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 let ct = resp
.headers() .headers()
.get(reqwest::header::CONTENT_TYPE) .get(reqwest::header::CONTENT_TYPE)
@ -325,7 +398,10 @@ async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
let base = base.as_str(); let base = base.as_str();
async move { async move {
let url = format!("{base}/api/v1/room/state?bank={bank}"); 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<Value> = futures::future::join_all(fetches) let out: Vec<Value> = futures::future::join_all(fetches)
@ -352,10 +428,7 @@ fn bank_names(v: &Value) -> Vec<String> {
_ => None, _ => None,
}) })
.collect(), .collect(),
Value::Object(o) => o Value::Object(o) => o.get("baselines").map(bank_names).unwrap_or_default(),
.get("baselines")
.map(|b| bank_names(b))
.unwrap_or_default(),
_ => Vec::new(), _ => Vec::new(),
} }
} }
@ -456,7 +529,11 @@ async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
} }
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> { fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
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(s) = std::fs::read_to_string(dir.join(&name)) {
if let Ok(p) = s.trim().parse::<i64>() { if let Ok(p) = s.trim().parse::<i64>() {
return Some(p); return Some(p);
@ -515,7 +592,9 @@ async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
} }
fn read_first_line(path: &str) -> Option<String> { fn read_first_line(path: &str) -> Option<String> {
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<u64> { fn uptime_secs() -> Option<u64> {
@ -551,7 +630,9 @@ fn cpu_load_pct() -> Option<f64> {
.next()? .next()?
.parse::<f64>() .parse::<f64>()
.ok()?; .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) Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
} }
@ -606,7 +687,9 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let status = resp.status(); 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()) (status, String::from_utf8_lossy(&b).into_owned())
} }
@ -614,7 +697,12 @@ mod tests {
async fn unauthenticated_is_rejected() { async fn unauthenticated_is_rejected() {
let app = gateway_router(gw()); let app = gateway_router(gw());
let resp = app 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 .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
@ -636,7 +724,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn seed_tier_routes_are_typed_503() { 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; let (status, body) = send(gateway_router(gw()), "GET", p).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503"); assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
assert!(body.contains("upstream_unavailable"), "{p} typed body"); assert!(body.contains("upstream_unavailable"), "{p} typed body");
@ -651,6 +744,46 @@ mod tests {
assert!(body.contains("\"ram_pct\"")); 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] #[test]
fn adapt_room_state_maps_fields_and_preserves_null() { fn adapt_room_state_maps_fields_and_preserves_null() {
// breathing/heartbeat rename; None → null; anomaly gets a threshold. // breathing/heartbeat rename; None → null; anomaly gets a threshold.
@ -668,7 +801,10 @@ mod tests {
assert_eq!(ui["stale"], true); assert_eq!(ui["stale"], true);
assert_eq!(ui["presence"]["value"], "occupied"); assert_eq!(ui["presence"]["value"], "occupied");
assert_eq!(ui["breathing_bpm"]["value"], 12.0); 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 // §6 invariant 3: upstream RoomState carries no threshold here, so the
// adapter must emit null (withheld) — NOT a fabricated constant. // adapter must emit null (withheld) — NOT a fabricated constant.
assert!( assert!(
@ -685,7 +821,10 @@ mod tests {
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73}, "anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
}); });
let ui = adapt_room_state("bedroom_1", &cal); 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] #[test]
@ -731,8 +870,15 @@ mod tests {
("POST", "/api/cal/v1/../../x"), ("POST", "/api/cal/v1/../../x"),
] { ] {
let (status, body) = send(gateway_router(gw()), method, path).await; let (status, body) = send(gateway_router(gw()), method, path).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400"); assert_eq!(
assert!(body.contains("bad_request"), "{method} {path} typed 400 body"); status,
StatusCode::BAD_REQUEST,
"{method} {path} must be 400"
);
assert!(
body.contains("bad_request"),
"{method} {path} typed 400 body"
);
assert!( assert!(
!body.contains("upstream_unavailable"), !body.contains("upstream_unavailable"),
"{method} {path} must NOT reach the upstream-config branch" "{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). // "not configured" 503 (proving it was NOT blocked as traversal).
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await; let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); 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] #[test]
fn bank_names_accepts_strings_and_objects() { fn bank_names_accepts_strings_and_objects() {
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]); 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"]); assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
} }
} }

View File

@ -5,10 +5,8 @@
//! - HomeCore runtime (state machine + event bus + service registry) //! - HomeCore runtime (state machine + event bus + service registry)
//! - SQLite recorder writing every state_changed event //! - SQLite recorder writing every state_changed event
//! - REST + WebSocket API on :8123 (HA wire-compat) //! - REST + WebSocket API on :8123 (HA wire-compat)
//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime)
//! - Automation engine subscribed to the state machine //! - Automation engine subscribed to the state machine
//! - Assist pipeline (intent recognizer + handler set) //! - Assist pipeline (intent recognizer + handler set)
//! - HAP bridge surface (accessories registered via the API)
//! //!
//! Run with: //! Run with:
//! //!
@ -19,21 +17,20 @@
//! cargo run -p homecore-server --features ruvector,wasmtime -- ... //! cargo run -p homecore-server --features ruvector,wasmtime -- ...
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use tracing::{info, warn}; use tracing::{info, warn};
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler; use homecore::service::FnHandler;
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState}; use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline; use homecore_assist::{
use homecore_assist::RegexIntentRecognizer; AssistPipeline, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
RegexIntentRecognizer,
};
use homecore_automation::AutomationEngine; use homecore_automation::AutomationEngine;
use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord}; use homecore_recorder::{Recorder, RecorderListener};
use homecore_plugins::{InProcessRuntime, PluginRegistry};
use homecore_recorder::Recorder;
use axum::Router; use axum::Router;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
@ -71,7 +68,11 @@ struct Cli {
calibration_token: Option<String>, calibration_token: Option<String>,
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6). /// 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, apps_dir: String,
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1). /// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
@ -79,7 +80,7 @@ struct Cli {
gateway_timeout_ms: u64, gateway_timeout_ms: u64,
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run. /// 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, db: String,
/// Friendly location name surfaced via `/api/config`. /// Friendly location name surfaced via `/api/config`.
@ -90,20 +91,50 @@ struct Cli {
#[arg(long)] #[arg(long)]
no_recorder: bool, no_recorder: bool,
/// Skip the boot-time entity seeding (10 demo entities including /// Explicitly allow any non-empty bearer token. Development only.
/// 4 RuView-derived sensors). Use this when wiring real #[arg(long, env = "HOMECORE_INSECURE_DEV_AUTH", default_value_t = false)]
/// integrations that will populate the state machine themselves. insecure_dev_auth: bool,
/// Seed synthetic demo entities. Disabled by default so simulated
/// biometric readings are never mistaken for live sensor data.
#[arg(long)] #[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<std::path::PathBuf>,
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
init_tracing(); init_tracing();
let cli = Cli::parse(); 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={:?}", info!(
cli.bind, cli.db, cli.location_name); "HOMECORE booting — bind={}, db={}, location={:?}",
cli.bind, cli.db, cli.location_name
);
// ── 1. HomeCore runtime ───────────────────────────────────────── // ── 1. HomeCore runtime ─────────────────────────────────────────
let hc = HomeCore::new(); let hc = HomeCore::new();
@ -114,33 +145,27 @@ async fn main() -> Result<()> {
// first boot. These are no-op handlers (they just echo back the // first boot. These are no-op handlers (they just echo back the
// call as JSON for observability) — integrations override them // call as JSON for observability) — integrations override them
// by registering the same ServiceName later. // 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 + // Seed 10 representative entities so the web UI's Dashboard +
// States pages have content out of the box. Operators registering // States pages have content out of the box. Operators registering
// real integrations / plugins overwrite these by writing the same // real integrations / plugins overwrite these by writing the same
// entity_id with new values. Opt out with `--no-seed-entities`. // 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); seed_default_entities(&hc);
} else { } else {
info!("Entity seeding disabled by --no-seed-entities"); info!("Synthetic demo entities disabled (use --seed-demo-entities to opt in)");
} }
// ── 2. Recorder (optional) ────────────────────────────────────── // ── 2. Recorder (optional) ──────────────────────────────────────
if !cli.no_recorder { if !cli.no_recorder {
match Recorder::open(&cli.db).await { match open_recorder(&cli.db).await {
Ok(recorder) => { Ok(recorder) => {
let recorder = Arc::new(recorder); let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn();
let rec_clone = Arc::clone(&recorder); info!(
let mut state_rx = hc.states().subscribe(); "Recorder open at {} — state_changed events being persisted",
tokio::spawn(async move { cli.db
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);
} }
Err(e) => { Err(e) => {
warn!("Recorder failed to open ({e}) — continuing without persistence"); warn!("Recorder failed to open ({e}) — continuing without persistence");
@ -151,11 +176,6 @@ async fn main() -> Result<()> {
} }
// ── 3. Plugin runtime ─────────────────────────────────────────── // ── 3. Plugin runtime ───────────────────────────────────────────
let plugin_runtime = InProcessRuntime;
let plugin_registry: PluginRegistry<InProcessRuntime> = 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 ──────────────────────────────────────── // ── 4. Automation engine ────────────────────────────────────────
// Construct AND start the engine (HC-WS-03, ADR-161). `start()` // Construct AND start the engine (HC-WS-03, ADR-161). `start()`
// spawns the state-change event loop + the 1 Hz wall-clock timer // 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 // boot yet (YAML loader is P-next); integrations register via
// `engine.register(..)`. // `engine.register(..)`.
let automation_engine = AutomationEngine::new(hc.clone()); 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<homecore_automation::Automation> = 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(); let _automation_task = automation_engine.start();
info!( info!(
"Automation engine started ({} automations registered) — \ "Automation engine started ({} automations registered) — \
@ -174,36 +202,12 @@ async fn main() -> Result<()> {
); );
// ── 5. Assist pipeline ────────────────────────────────────────── // ── 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 ─────────────────────────────────────── // ── 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 ──────────────────────────────────────────── // ── 7. REST + WS API ────────────────────────────────────────────
// Token provisioning closes audit findings HC-01/HC-02. If // Token provisioning closes audit findings HC-01/HC-02. If
// HOMECORE_TOKENS is set in the env, populate the store from // HOMECORE_TOKENS is set in the env, populate the store from
// its comma-separated list. Otherwise fall back to DEV mode // its comma-separated list. Otherwise fall back to DEV mode
// (warn-on-each-request) so existing smoke tests still work. // (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( let api_state = SharedState::with_tokens(
hc.clone(), hc.clone(),
cli.location_name, cli.location_name,
@ -213,7 +217,12 @@ async fn main() -> Result<()> {
// BFF gateway (ADR-131 §11): single-origin aggregation of the // BFF gateway (ADR-131 §11): single-origin aggregation of the
// calibration API + SEED/appliance tiers. Shares the same token store // calibration API + SEED/appliance tiers. Shares the same token store
// for auth; upstream credentials stay server-side. // 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(), api_state.clone(),
GatewayConfig { GatewayConfig {
calibration_url: cli.calibration_url.clone(), calibration_url: cli.calibration_url.clone(),
@ -221,6 +230,7 @@ async fn main() -> Result<()> {
apps_dir: std::path::PathBuf::from(&cli.apps_dir), apps_dir: std::path::PathBuf::from(&cli.apps_dir),
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms), 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 // Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
// audited CORS allowlist + request tracing to the WHOLE surface. The // audited CORS allowlist + request tracing to the WHOLE surface. The
@ -233,19 +243,35 @@ async fn main() -> Result<()> {
.layer(build_cors_layer()) .layer(build_cors_layer())
.layer(TraceLayer::new_for_http()); .layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(cli.bind).await?; 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!( info!(
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})", "HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
cli.calibration_url cli.calibration_url
); );
if !cli.ui_dir.trim().is_empty() { 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 { } else {
info!("HOMECORE-UI mount disabled (--ui-dir empty)"); info!("HOMECORE-UI mount disabled (--ui-dir empty)");
} }
// Run forever (until SIGINT). axum::serve handles graceful shutdown. let shutdown_hc = hc.clone();
axum::serve(listener, app).await?; 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(()) Ok(())
} }
@ -263,11 +289,68 @@ fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
app.nest_service("/homecore", ServeDir::new(ui_dir)) app.nest_service("/homecore", ServeDir::new(ui_dir))
} }
#[cfg(not(feature = "ruvector"))]
async fn open_recorder(path: &str) -> anyhow::Result<Recorder> {
Ok(Recorder::open(path).await?)
}
async fn build_assist_pipeline() -> anyhow::Result<AssistPipeline<RegexIntentRecognizer>> {
let recognizer = RegexIntentRecognizer::new();
recognizer
.register(
"HassTurnOn",
r"^turn on (?:the )?(?P<entity_id>[a-z0-9_]+\.[a-z0-9_]+)$",
"*",
)
.await?;
recognizer
.register(
"HassTurnOff",
r"^turn off (?:the )?(?P<entity_id>[a-z0-9_]+\.[a-z0-9_]+)$",
"*",
)
.await?;
recognizer
.register(
"HassLightSet",
r"^set (?P<entity_id>light\.[a-z0-9_]+) to (?P<brightness>[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<Recorder> {
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<RwLock<dyn SemanticIndex>> =
std::sync::Arc::new(RwLock::new(index));
Ok(Recorder::open_with_index(path, semantic).await?)
}
fn init_tracing() { fn init_tracing() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
.unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()), "info,homecore=debug,homecore_server=debug,tower_http=info".into()
}),
) )
.init(); .init();
} }
@ -281,42 +364,125 @@ fn init_tracing() {
/// light / switch / scene / automation domains) plus a `homecore.*` /// light / switch / scene / automation domains) plus a `homecore.*`
/// domain so operators can see HOMECORE-native services distinguished /// domain so operators can see HOMECORE-native services distinguished
/// from the HA-compat ones. /// from the HA-compat ones.
async fn seed_default_services(hc: &HomeCore) { async fn register_builtin_services(hc: &HomeCore) {
let echo = || FnHandler(|call: ServiceCall| async move { hc.services()
Ok(serde_json::json!({ .register(
"called": format!("{}.{}", call.name.domain, call.name.service), ServiceName::new("homecore", "ping"),
"service_data": call.data, FnHandler(|_call| async { Ok(serde_json::json!({ "pong": true })) }),
"acknowledged": true, )
})) .await;
});
let svcs = [ let snapshot_states = hc.states().clone();
// Conventional HA wire-compat services hc.services()
("homeassistant", "restart"), .register(
("homeassistant", "stop"), ServiceName::new("homecore", "snapshot_state"),
("homeassistant", "reload_core_config"), FnHandler(move |_call| {
("light", "turn_on"), let states = snapshot_states.clone();
("light", "turn_off"), async move { Ok(serde_json::to_value(states.all()).unwrap_or_default()) }
("light", "toggle"), }),
("switch", "turn_on"), )
("switch", "turn_off"), .await;
("switch", "toggle"),
("scene", "apply"),
("automation", "trigger"),
// HOMECORE-native services
("homecore", "ping"),
("homecore", "snapshot_state"),
];
for (domain, service) in svcs { for domain in ["homeassistant", "light", "switch"] {
hc.services() for action in ["turn_on", "turn_off", "toggle"] {
.register(ServiceName::new(domain, service), echo()) let states = hc.states().clone();
.await; 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(); info!(
let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() }; "Registered {} executable built-in services",
info!("Service registry seeded with {} default service(s)", count); hc.services().registered_services().await.len()
);
}
fn service_entity_ids(data: &serde_json::Value) -> Result<Vec<EntityId>, 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::<Result<_, _>>()?,
_ => {
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:` /// Register 10 representative entities so a fresh `--db :memory:`
@ -325,45 +491,85 @@ async fn seed_default_services(hc: &HomeCore) {
/// they stay in sync. /// they stay in sync.
fn seed_default_entities(hc: &HomeCore) { fn seed_default_entities(hc: &HomeCore) {
let entities: Vec<(&str, &str, serde_json::Value)> = vec![ 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", "sensor.living_room_presence",
"source": "RuView ESP32-C6 BFLD" "false",
})), serde_json::json!({
("sensor.living_room_motion_score", "0.0", serde_json::json!({ "friendly_name": "Living Room Presence", "device_class": "occupancy",
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score", "source": "RuView ESP32-C6 BFLD"
"icon": "mdi:motion-sensor" }),
})), ),
("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({ (
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM", "sensor.living_room_motion_score",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave" "0.0",
})), serde_json::json!({
("sensor.bedroom_heart_rate", "68.0", serde_json::json!({ "friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM", "icon": "mdi:motion-sensor"
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave" }),
})), ),
("light.kitchen_ceiling", "on", serde_json::json!({ (
"friendly_name": "Kitchen Ceiling", "brightness": 230, "sensor.bedroom_breathing_rate",
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"] "14.5",
})), serde_json::json!({
("light.living_room_lamp", "off", serde_json::json!({ "friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
"friendly_name": "Living Room Lamp", "brightness": 0, "device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
"supported_color_modes": ["brightness"] }),
})), ),
("switch.coffee_maker", "off", serde_json::json!({ (
"friendly_name": "Coffee Maker", "device_class": "outlet" "sensor.bedroom_heart_rate",
})), "68.0",
("binary_sensor.front_door", "off", serde_json::json!({ serde_json::json!({
"friendly_name": "Front Door", "device_class": "door" "friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
})), "device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
("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 "light.kitchen_ceiling",
})), "on",
("sensor.air_quality_index", "42", serde_json::json!({ serde_json::json!({
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI", "friendly_name": "Kitchen Ceiling", "brightness": 230,
"device_class": "aqi" "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 { for (id, state, attrs) in entities {
@ -381,8 +587,11 @@ fn seed_default_entities(hc: &HomeCore) {
context: Context::new(), context: Context::new(),
}; };
let total = hc.states().all().len(); let total = hc.states().all().len();
info!("State machine seeded with {} default entit{}", total, info!(
if total == 1 { "y" } else { "ies" }); "State machine seeded with {} default entit{}",
total,
if total == 1 { "y" } else { "ies" }
);
} }
#[cfg(test)] #[cfg(test)]
@ -419,9 +628,19 @@ mod ui_tests {
async fn ui_index_is_served_at_homecore() { async fn ui_index_is_served_at_homecore() {
let app = build_app(test_state(), DEFAULT_UI_DIR); let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/").await; let (status, body) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html"); assert_eq!(
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE"); status,
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js"); 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] #[tokio::test]
@ -437,8 +656,18 @@ mod ui_tests {
#[tokio::test] #[tokio::test]
async fn ui_panels_are_served() { async fn ui_panels_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR); let app = build_app(test_state(), DEFAULT_UI_DIR);
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail", for p in [
"entities", "cogs", "events", "audit", "settings"] { "dashboard",
"rooms",
"calibration",
"fleet",
"seed-detail",
"entities",
"cogs",
"events",
"audit",
"settings",
] {
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await; let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served"); assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
} }
@ -459,9 +688,15 @@ mod ui_tests {
.await .await
.unwrap(); .unwrap();
let status = resp.status(); 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); 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")); assert!(body.contains("API running"));
} }
@ -469,12 +704,16 @@ mod ui_tests {
async fn ui_mount_can_be_disabled() { async fn ui_mount_can_be_disabled() {
let app = build_app(test_state(), ""); let app = build_app(test_state(), "");
let (status, _) = get(app, "/homecore/").await; 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 /// Build the SAME merged + layered surface `main()` serves: API + UI mount
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the /// + 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 { fn full_app(state: SharedState) -> Router {
use crate::gateway::{GatewayConfig, GatewayState}; use crate::gateway::{GatewayConfig, GatewayState};
let gw = GatewayState::new( let gw = GatewayState::new(
@ -527,4 +766,47 @@ mod ui_tests {
"gateway route must echo the allowlisted dev origin" "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 { .. })));
}
} }

View File

@ -31,7 +31,7 @@ export function demoMode() {
export const api = { export const api = {
base: '', 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], isDemo: (key) => !!demoFlags[key],
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0, anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
demoMode, demoMode,

View File

@ -84,9 +84,23 @@ impl Default for Context {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum SystemEvent { pub enum SystemEvent {
StateChanged(StateChangedEvent), StateChanged(StateChangedEvent),
ServiceRegistered { domain: String, service: String }, ServiceCalled {
ServiceRemoved { domain: String, service: String }, domain: String,
ComponentLoaded { component: String }, service: String,
data: serde_json::Value,
context: Context,
},
ServiceRegistered {
domain: String,
service: String,
},
ServiceRemoved {
domain: String,
service: String,
},
ComponentLoaded {
component: String,
},
HomeCoreStart, HomeCoreStart,
HomeCoreStarted, HomeCoreStarted,
HomeCoreStop, HomeCoreStop,

View File

@ -24,11 +24,12 @@ struct HomeCoreInner {
impl HomeCore { impl HomeCore {
pub fn new() -> Self { pub fn new() -> Self {
let bus = EventBus::new();
Self { Self {
inner: Arc::new(HomeCoreInner { inner: Arc::new(HomeCoreInner {
bus: EventBus::new(), states: StateMachine::with_event_bus(bus.clone()),
states: StateMachine::new(), services: ServiceRegistry::with_event_bus(bus.clone()),
services: ServiceRegistry::new(), bus,
entities: EntityRegistry::new(), entities: EntityRegistry::new(),
}), }),
} }
@ -61,15 +62,76 @@ impl Default for HomeCore {
mod tests { mod tests {
use super::*; use super::*;
use crate::entity::EntityId; use crate::entity::EntityId;
use crate::event::Context; use crate::event::{Context, SystemEvent};
use crate::service::{FnHandler, ServiceCall, ServiceName};
#[tokio::test] #[tokio::test]
async fn end_to_end_set_then_get() { async fn end_to_end_set_then_get() {
let hc = HomeCore::new(); let hc = HomeCore::new();
let id = EntityId::parse("light.kitchen").unwrap(); 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(); let snap = hc.states().get(&id).unwrap();
assert_eq!(snap.state, "on"); assert_eq!(snap.state, "on");
assert_eq!(snap.attributes["brightness"], 200); 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:?}"),
}
}
} }

View File

@ -56,7 +56,10 @@ impl EntityRegistry {
} }
pub async fn register(&self, entry: EntityEntry) { 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<EntityEntry> { pub async fn get(&self, entity_id: &EntityId) -> Option<EntityEntry> {
@ -74,6 +77,10 @@ impl EntityRegistry {
pub async fn len(&self) -> usize { pub async fn len(&self) -> usize {
self.entries.read().await.len() self.entries.read().await.len()
} }
pub async fn is_empty(&self) -> bool {
self.entries.read().await.is_empty()
}
} }
impl Default for EntityRegistry { impl Default for EntityRegistry {

View File

@ -1,4 +1,4 @@
//! Service registry stub. //! Concurrent service registry with panic-isolated direct dispatch.
//! //!
//! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public //! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public
//! surface + a simple direct-dispatch `call` so downstream ADRs can //! surface + a simple direct-dispatch `call` so downstream ADRs can
@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio::sync::RwLock; 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 /// Service name within a domain. e.g. `light.turn_on` → domain
/// `"light"`, service `"turn_on"`. /// `"light"`, service `"turn_on"`.
@ -78,12 +79,22 @@ where
#[derive(Clone)] #[derive(Clone)]
pub struct ServiceRegistry { pub struct ServiceRegistry {
handlers: Arc<RwLock<HashMap<ServiceName, Arc<dyn ServiceHandler>>>>, handlers: Arc<RwLock<HashMap<ServiceName, Arc<dyn ServiceHandler>>>>,
bus: Option<EventBus>,
} }
impl ServiceRegistry { impl ServiceRegistry {
pub fn new() -> Self { 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<EventBus>) -> Self {
Self { Self {
handlers: Arc::new(RwLock::new(HashMap::new())), handlers: Arc::new(RwLock::new(HashMap::new())),
bus,
} }
} }
@ -111,6 +122,14 @@ impl ServiceRegistry {
/// that drives the engine. Mirrors HA isolating service-handler /// that drives the engine. Mirrors HA isolating service-handler
/// exceptions. /// exceptions.
pub async fn call(&self, call: ServiceCall) -> Result<serde_json::Value, ServiceError> { pub async fn call(&self, call: ServiceCall) -> Result<serde_json::Value, ServiceError> {
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 handler = {
let guard = self.handlers.read().await; let guard = self.handlers.read().await;
guard.get(&call.name).cloned() guard.get(&call.name).cloned()

View File

@ -22,8 +22,9 @@ use chrono::Utc;
use dashmap::DashMap; use dashmap::DashMap;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::bus::EventBus;
use crate::entity::{EntityId, State}; 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 /// Broadcast channel capacity for state-changed events. 4,096 events
/// at 20 Hz per entity covers ~3 minutes of backlog for a single hot /// at 20 Hz per entity covers ~3 minutes of backlog for a single hot
@ -40,15 +41,28 @@ pub struct StateMachine {
struct StateMachineInner { struct StateMachineInner {
states: DashMap<EntityId, Arc<State>>, states: DashMap<EntityId, Arc<State>>,
tx: broadcast::Sender<StateChangedEvent>, tx: broadcast::Sender<StateChangedEvent>,
bus: Option<EventBus>,
} }
impl StateMachine { impl StateMachine {
pub fn new() -> Self { 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<EventBus>) -> Self {
let (tx, _) = broadcast::channel(STATE_CHANGED_CHANNEL_CAPACITY); let (tx, _) = broadcast::channel(STATE_CHANGED_CHANNEL_CAPACITY);
Self { Self {
inner: Arc::new(StateMachineInner { inner: Arc::new(StateMachineInner {
states: DashMap::with_capacity(256), states: DashMap::with_capacity(256),
tx, tx,
bus,
}), }),
} }
} }
@ -135,7 +149,10 @@ impl StateMachine {
fired_at: Utc::now(), fired_at: Utc::now(),
}; };
// err = no receivers; that's fine, write still committed. // 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. // `_guard` (and the shard lock) drops here, after the event is sent.
next next
@ -151,7 +168,10 @@ impl StateMachine {
new_state: None, new_state: None,
fired_at: Utc::now(), 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 removed
} }
@ -159,7 +179,11 @@ impl StateMachine {
/// Snapshot all current states. Allocates a new Vec — useful for /// Snapshot all current states. Allocates a new Vec — useful for
/// the REST GET /api/states path (ADR-130). /// the REST GET /api/states path (ADR-130).
pub fn all(&self) -> Vec<Arc<State>> { pub fn all(&self) -> Vec<Arc<State>> {
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. /// Snapshot all states whose entity_id matches a domain prefix.
@ -201,7 +225,12 @@ mod tests {
async fn set_writes_and_fires() { async fn set_writes_and_fires() {
let sm = StateMachine::new(); let sm = StateMachine::new();
let mut rx = sm.subscribe(); 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(); let evt = rx.recv().await.unwrap();
assert_eq!(evt.entity_id.as_str(), "light.kitchen"); assert_eq!(evt.entity_id.as_str(), "light.kitchen");
assert!(evt.old_state.is_none()); assert!(evt.old_state.is_none());
@ -222,9 +251,19 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn attribute_only_change_fires_but_preserves_last_changed() { async fn attribute_only_change_fires_but_preserves_last_changed() {
let sm = StateMachine::new(); 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; 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_eq!(s1.last_changed, s2.last_changed);
assert!(s2.last_updated > s1.last_updated); assert!(s2.last_updated > s1.last_updated);
} }
@ -367,10 +406,7 @@ mod tests {
drainer.join().unwrap(); drainer.join().unwrap();
let log = log.lock().unwrap(); let log = log.lock().unwrap();
let dup = log let dup = log.windows(2).filter(|w| w[0] == w[1]).count();
.windows(2)
.filter(|w| w[0] == w[1])
.count();
assert_eq!( assert_eq!(
dup, 0, dup, 0,
"{dup} consecutive fired state_changed events carried an \ "{dup} consecutive fired state_changed events carried an \

View File

@ -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 113 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.