mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
fix(homecore): harden runtime and publish truthful capabilities (#1450)
This commit is contained in:
Generated
+1
-1
@@ -3674,13 +3674,13 @@ dependencies = [
|
||||
"homecore-api",
|
||||
"homecore-assist",
|
||||
"homecore-automation",
|
||||
"homecore-hap",
|
||||
"homecore-plugins",
|
||||
"homecore-recorder",
|
||||
"http-body-util",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
|
||||
@@ -40,13 +40,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env
|
||||
// whitelist; fall back to DEV mode (warn-logged) only when unset.
|
||||
let tokens = if std::env::var("HOMECORE_TOKENS")
|
||||
let has_tokens = std::env::var("HOMECORE_TOKENS")
|
||||
.map(|v| !v.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
.unwrap_or(false);
|
||||
let insecure_dev_auth = std::env::var("HOMECORE_INSECURE_DEV_AUTH").as_deref() == Ok("1");
|
||||
if !has_tokens && !insecure_dev_auth {
|
||||
return Err(
|
||||
"HOMECORE_TOKENS is required (or set HOMECORE_INSECURE_DEV_AUTH=1 for loopback-only development)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let tokens = if has_tokens {
|
||||
let s = LongLivedTokenStore::from_env();
|
||||
let n = s.len().await;
|
||||
tracing::info!("LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS");
|
||||
tracing::info!(
|
||||
"LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS"
|
||||
);
|
||||
s
|
||||
} else {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -93,7 +93,7 @@ pub async fn get_state(
|
||||
) -> ApiResult<Json<StateView>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?;
|
||||
let st = s.homecore().states().get(&id).ok_or(ApiError::NotFound(entity_id))?;
|
||||
Ok(Json(StateView::from_state(&st)))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use homecore::HomeCore;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::tokens::LongLivedTokenStore;
|
||||
|
||||
@@ -28,15 +28,13 @@ impl SharedState {
|
||||
location_name: impl Into<String>,
|
||||
homecore_version: impl Into<String>,
|
||||
) -> Self {
|
||||
// P2 default: dev-mode token store (accepts any non-empty
|
||||
// bearer) so existing smoke tests still work; the
|
||||
// `homecore-server` binary uses with_tokens() to provision a
|
||||
// real store at boot.
|
||||
// Fail closed by default. Tests and explicitly insecure local
|
||||
// development must opt into `allow_any_non_empty()` themselves.
|
||||
Self::with_tokens(
|
||||
homecore,
|
||||
location_name,
|
||||
homecore_version,
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
LongLivedTokenStore::empty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,8 +54,16 @@ impl SharedState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn homecore(&self) -> &HomeCore { &self.inner.homecore }
|
||||
pub fn version(&self) -> &str { &self.inner.homecore_version }
|
||||
pub fn location_name(&self) -> &str { &self.inner.location_name }
|
||||
pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens }
|
||||
pub fn homecore(&self) -> &HomeCore {
|
||||
&self.inner.homecore
|
||||
}
|
||||
pub fn version(&self) -> &str {
|
||||
&self.inner.homecore_version
|
||||
}
|
||||
pub fn location_name(&self) -> &str {
|
||||
&self.inner.location_name
|
||||
}
|
||||
pub fn tokens(&self) -> &LongLivedTokenStore {
|
||||
&self.inner.tokens
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@ impl LongLivedTokenStore {
|
||||
self.inner.read().await.tokens.len()
|
||||
}
|
||||
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
self.inner.read().await.tokens.is_empty()
|
||||
}
|
||||
|
||||
/// Is the store accepting any non-empty bearer (DEV mode)?
|
||||
pub async fn is_dev_mode(&self) -> bool {
|
||||
self.inner.read().await.allow_any
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
//! drains the response channel onto the socket (HC-WS-02 closed the prior
|
||||
//! reply-theater where responses were logged and discarded).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
@@ -28,6 +27,10 @@ use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Per-connection outbound queue. A bounded queue prevents a client that
|
||||
/// stops reading from turning event fan-out into unbounded process memory.
|
||||
const OUTBOUND_QUEUE_CAPACITY: usize = 256;
|
||||
use tracing::warn;
|
||||
|
||||
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
|
||||
@@ -49,7 +52,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
|
||||
"type": "auth_required",
|
||||
"ha_version": state.version(),
|
||||
});
|
||||
if socket.send(Message::Text(auth_req.to_string())).await.is_err() {
|
||||
if socket
|
||||
.send(Message::Text(auth_req.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +66,8 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
|
||||
_ => {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(),
|
||||
serde_json::json!({"type":"auth_invalid","message":"expected auth"})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
@@ -85,7 +93,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
|
||||
return;
|
||||
}
|
||||
let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()});
|
||||
if socket.send(Message::Text(auth_ok.to_string())).await.is_err() {
|
||||
if socket
|
||||
.send(Message::Text(auth_ok.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,7 +152,6 @@ struct ErrorView<'a> {
|
||||
|
||||
struct Connection {
|
||||
state: SharedState,
|
||||
next_sub_id: AtomicU64,
|
||||
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
|
||||
}
|
||||
|
||||
@@ -152,7 +163,6 @@ impl Connection {
|
||||
fn new(state: SharedState) -> Self {
|
||||
Self {
|
||||
state,
|
||||
next_sub_id: AtomicU64::new(1),
|
||||
subs: Arc::new(dashmap::DashMap::new()),
|
||||
}
|
||||
}
|
||||
@@ -168,7 +178,7 @@ impl Connection {
|
||||
// DISCARDED every message — so no `result`/`pong`/`event` ever
|
||||
// reached the client. Now `rx` feeds `socket.send`.
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(OUTBOUND_QUEUE_CAPACITY);
|
||||
|
||||
// Writer task: drain replies onto the socket. A `__pong:<n>`
|
||||
// sentinel maps to a binary Pong control frame; everything else
|
||||
@@ -205,7 +215,7 @@ impl Connection {
|
||||
conn.handle_cmd(cmd, &reader_tx).await;
|
||||
}
|
||||
Ok(Message::Ping(p)) => {
|
||||
let _ = reader_tx.send(format!("__pong:{}", p.len()));
|
||||
let _ = reader_tx.try_send(format!("__pong:{}", p.len()));
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
@@ -224,15 +234,16 @@ impl Connection {
|
||||
let _ = writer_task.await;
|
||||
}
|
||||
|
||||
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {
|
||||
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::Sender<String>) {
|
||||
match cmd.kind.as_str() {
|
||||
"ping" => {
|
||||
let msg = serde_json::json!({"id": cmd.id, "type": "pong"});
|
||||
let _ = tx.send(msg.to_string());
|
||||
let _ = tx.try_send(msg.to_string());
|
||||
}
|
||||
"get_states" => {
|
||||
let snapshots = self.state.homecore().states().all();
|
||||
let views: Vec<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()));
|
||||
}
|
||||
"get_config" => {
|
||||
@@ -245,17 +256,28 @@ impl Connection {
|
||||
}
|
||||
"get_services" => {
|
||||
let services = self.state.homecore().services().registered_services().await;
|
||||
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut by_domain: std::collections::HashMap<
|
||||
String,
|
||||
serde_json::Map<String, serde_json::Value>,
|
||||
> = std::collections::HashMap::new();
|
||||
for s in services {
|
||||
by_domain.entry(s.domain).or_default().insert(s.service, serde_json::json!({}));
|
||||
by_domain
|
||||
.entry(s.domain)
|
||||
.or_default()
|
||||
.insert(s.service, serde_json::json!({}));
|
||||
}
|
||||
let payload = serde_json::to_value(by_domain).unwrap();
|
||||
self.ack(tx, cmd.id, true, Some(payload));
|
||||
}
|
||||
"call_service" => {
|
||||
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else {
|
||||
self.err(tx, cmd.id, "missing_domain_service", "domain and service are required");
|
||||
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone())
|
||||
else {
|
||||
self.err(
|
||||
tx,
|
||||
cmd.id,
|
||||
"missing_domain_service",
|
||||
"domain and service are required",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let call = ServiceCall {
|
||||
@@ -269,7 +291,13 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
"subscribe_events" => {
|
||||
let sub_id = self.next_sub_id.fetch_add(1, Ordering::Relaxed);
|
||||
// HA uses the subscribing command ID as the subscription ID
|
||||
// in every emitted event and in `unsubscribe_events`.
|
||||
let sub_id = cmd.id;
|
||||
if self.subs.contains_key(&sub_id) {
|
||||
self.err(tx, cmd.id, "id_reused", "subscription id is already active");
|
||||
return;
|
||||
}
|
||||
let filter = cmd.event_type.clone();
|
||||
let tx_clone = tx.clone();
|
||||
let mut domain_rx = self.state.homecore().bus().subscribe_domain();
|
||||
@@ -294,7 +322,27 @@ impl Connection {
|
||||
"time_fired": sc.fired_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
if tx_clone.try_send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Ok(SystemEvent::ServiceCalled { domain, service, data, context }) => {
|
||||
if filter.as_deref() == Some("call_service") || filter.is_none() {
|
||||
let payload = serde_json::json!({
|
||||
"id": sub_id,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "call_service",
|
||||
"data": {
|
||||
"domain": domain,
|
||||
"service": service,
|
||||
"service_data": data,
|
||||
},
|
||||
"origin": "LOCAL",
|
||||
"time_fired": chrono::Utc::now().to_rfc3339(),
|
||||
"context": context,
|
||||
}
|
||||
});
|
||||
if tx_clone.try_send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -323,7 +371,7 @@ impl Connection {
|
||||
"time_fired": de.fired_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
if tx_clone.try_send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
// Same recoverable-lag handling as the system arm
|
||||
@@ -353,11 +401,21 @@ impl Connection {
|
||||
self.err(tx, cmd.id, "not_found", "subscription_id not found");
|
||||
}
|
||||
} else {
|
||||
self.err(tx, cmd.id, "missing_subscription", "subscription is required");
|
||||
self.err(
|
||||
tx,
|
||||
cmd.id,
|
||||
"missing_subscription",
|
||||
"subscription is required",
|
||||
);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}"));
|
||||
self.err(
|
||||
tx,
|
||||
cmd.id,
|
||||
"unknown_command",
|
||||
&format!("unknown ws command: {other}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
// entity_id is reserved for future per-entity subscribes
|
||||
@@ -366,7 +424,7 @@ impl Connection {
|
||||
|
||||
fn ack(
|
||||
&self,
|
||||
tx: &tokio::sync::mpsc::UnboundedSender<String>,
|
||||
tx: &tokio::sync::mpsc::Sender<String>,
|
||||
id: u64,
|
||||
success: bool,
|
||||
result: Option<serde_json::Value>,
|
||||
@@ -378,10 +436,16 @@ impl Connection {
|
||||
result,
|
||||
error: None,
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&msg).unwrap());
|
||||
let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
|
||||
fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender<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 {
|
||||
id,
|
||||
kind: "result",
|
||||
@@ -389,7 +453,7 @@ impl Connection {
|
||||
result: None,
|
||||
error: Some(ErrorView { code, message }),
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&msg).unwrap());
|
||||
let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,10 @@ async fn wrong_token_is_rejected() {
|
||||
resp["type"], "auth_invalid",
|
||||
"wrong token must be rejected with auth_invalid, got: {resp}"
|
||||
);
|
||||
assert_ne!(resp["type"], "auth_ok", "wrong token must NOT receive auth_ok");
|
||||
assert_ne!(
|
||||
resp["type"], "auth_ok",
|
||||
"wrong token must NOT receive auth_ok"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -99,7 +102,10 @@ async fn correct_token_is_accepted() {
|
||||
.unwrap();
|
||||
|
||||
let resp = next_json(&mut ws).await;
|
||||
assert_eq!(resp["type"], "auth_ok", "correct token should be accepted, got: {resp}");
|
||||
assert_eq!(
|
||||
resp["type"], "auth_ok",
|
||||
"correct token should be accepted, got: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -133,7 +139,10 @@ async fn result_reply_is_received() {
|
||||
let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
|
||||
.await
|
||||
.expect("did not receive a reply within 5s — reply theater (HC-WS-02)");
|
||||
assert_eq!(reply["type"], "result", "expected a result reply, got: {reply}");
|
||||
assert_eq!(
|
||||
reply["type"], "result",
|
||||
"expected a result reply, got: {reply}"
|
||||
);
|
||||
assert_eq!(reply["id"], 1);
|
||||
assert_eq!(reply["success"], true);
|
||||
}
|
||||
@@ -263,3 +272,69 @@ async fn subscription_survives_broadcast_lag() {
|
||||
);
|
||||
assert_eq!(got["event"]["data"]["marker"], "post-lag");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_state_change_uses_client_subscription_id() {
|
||||
use homecore::{Context, EntityId};
|
||||
|
||||
let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await;
|
||||
let url = format!("ws://{addr}/api/websocket");
|
||||
let (mut ws, _resp) = connect_async(&url).await.unwrap();
|
||||
|
||||
let _ = next_json(&mut ws).await;
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = next_json(&mut ws).await;
|
||||
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"id": 41,
|
||||
"type": "subscribe_events",
|
||||
"event_type": "state_changed"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let ack = next_json(&mut ws).await;
|
||||
assert_eq!(ack["id"], 41);
|
||||
assert_eq!(ack["success"], true);
|
||||
|
||||
hc.states().set(
|
||||
EntityId::parse("light.integration_probe").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({"source":"test"}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
|
||||
.await
|
||||
.expect("state change was not bridged to the WebSocket system-event subscription");
|
||||
assert_eq!(
|
||||
event["id"], 41,
|
||||
"HA events must use the subscribe command id"
|
||||
);
|
||||
assert_eq!(event["type"], "event");
|
||||
assert_eq!(event["event"]["event_type"], "state_changed");
|
||||
assert_eq!(
|
||||
event["event"]["data"]["entity_id"],
|
||||
"light.integration_probe"
|
||||
);
|
||||
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"id": 42,
|
||||
"type": "unsubscribe_events",
|
||||
"subscription": 41
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let unsub = next_json(&mut ws).await;
|
||||
assert_eq!(unsub["id"], 42);
|
||||
assert_eq!(unsub["success"], true);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ impl Condition {
|
||||
Condition::State { entity_id, state } => {
|
||||
ctx.states
|
||||
.get(entity_id)
|
||||
.map_or(false, |s| s.state == *state)
|
||||
.is_some_and(|s| s.state == *state)
|
||||
}
|
||||
Condition::NumericState { entity_id, above, below } => {
|
||||
let value: Option<f64> = ctx
|
||||
@@ -84,16 +84,13 @@ impl Condition {
|
||||
match value {
|
||||
None => false,
|
||||
Some(v) => {
|
||||
above.map_or(true, |a| v > a) && below.map_or(true, |b| v < b)
|
||||
above.is_none_or(|a| v > a) && below.is_none_or(|b| v < b)
|
||||
}
|
||||
}
|
||||
}
|
||||
Condition::Template { value_template } => {
|
||||
if let Some(env) = &ctx.template_env {
|
||||
match env.render_bool(value_template) {
|
||||
Ok(v) => v,
|
||||
Err(_) => false,
|
||||
}
|
||||
env.render_bool(value_template).unwrap_or_default()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ impl Trigger {
|
||||
pub fn matches_sync(&self, ctx: &TriggerContext) -> bool {
|
||||
match self {
|
||||
Trigger::State { entity_id, from, to } => {
|
||||
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
|
||||
let eid_match = ctx.entity_id.as_ref() == Some(entity_id);
|
||||
if !eid_match {
|
||||
return false;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ impl Trigger {
|
||||
true
|
||||
}
|
||||
Trigger::NumericState { entity_id, above, below } => {
|
||||
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
|
||||
let eid_match = ctx.entity_id.as_ref() == Some(entity_id);
|
||||
if !eid_match {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ The tool enforces version schema compatibility: unknown HA schema versions are r
|
||||
|
||||
## Features
|
||||
|
||||
- **Entity registry import** — `core.entity_registry` → HOMECORE entity definitions (ready for import)
|
||||
- **Entity registry import** — `core.entity_registry` → an atomically written,
|
||||
HA-compatible HOMECORE registry file; existing destinations are not overwritten
|
||||
- **Device registry inspection** — read HA device metadata; full conversion deferred to P2
|
||||
- **Config entries analysis** — list active integrations by domain (enables gap analysis)
|
||||
- **Secrets extraction** — read `secrets.yaml` references for annotation (resolution in P2)
|
||||
|
||||
@@ -26,17 +26,15 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
|
||||
|
||||
use crate::{
|
||||
storage::read_envelope,
|
||||
storage_format::v13,
|
||||
MigrateError,
|
||||
};
|
||||
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
|
||||
|
||||
// Key used by `inspect` subcommand when scanning the directory.
|
||||
#[allow(dead_code)]
|
||||
@@ -75,21 +73,21 @@ struct HaEntityRow {
|
||||
// Fields present in v13 that we capture but do not yet map to HOMECORE.
|
||||
// Forwarded as Q5 items.
|
||||
#[serde(default)]
|
||||
hidden_by: Option<String>, // v13: "user" | "integration"
|
||||
hidden_by: Option<String>, // v13: "user" | "integration"
|
||||
#[serde(default)]
|
||||
has_entity_name: Option<bool>, // v13: HA naming convention flag
|
||||
has_entity_name: Option<bool>, // v13: HA naming convention flag
|
||||
#[serde(default)]
|
||||
original_name: Option<String>, // v13: integration-provided default name
|
||||
original_name: Option<String>, // v13: integration-provided default name
|
||||
#[serde(default)]
|
||||
icon: Option<String>, // v13: mdi:xxx icon override
|
||||
icon: Option<String>, // v13: mdi:xxx icon override
|
||||
#[serde(default)]
|
||||
original_icon: Option<String>, // v13: integration-provided icon
|
||||
original_icon: Option<String>, // v13: integration-provided icon
|
||||
#[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)]
|
||||
capabilities: Option<serde_json::Value>, // v13: integration-specific caps
|
||||
#[serde(default)]
|
||||
supported_features: Option<u64>, // v13: bitmask
|
||||
supported_features: Option<u64>, // v13: bitmask
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -166,6 +164,64 @@ pub fn read_entity_registry(path: &Path) -> Result<Vec<EntityEntry>, MigrateErro
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Persist imported entries using the HA-compatible v13 storage envelope.
|
||||
///
|
||||
/// The destination is created if needed and the file is written through a
|
||||
/// same-directory temporary file followed by an atomic rename. Existing
|
||||
/// registries are never overwritten implicitly.
|
||||
pub fn write_entity_registry(
|
||||
storage_dir: &Path,
|
||||
entries: &[EntityEntry],
|
||||
) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -227,7 +283,10 @@ mod tests {
|
||||
fn entity_fields_round_trip_correctly() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
let light = entries.iter().find(|e| e.entity_id.as_str() == "light.kitchen").unwrap();
|
||||
let light = entries
|
||||
.iter()
|
||||
.find(|e| e.entity_id.as_str() == "light.kitchen")
|
||||
.unwrap();
|
||||
assert_eq!(light.unique_id.as_deref(), Some("hue_lamp_42"));
|
||||
assert_eq!(light.platform, "hue");
|
||||
assert_eq!(light.name.as_deref(), Some("Kitchen lamp"));
|
||||
@@ -238,6 +297,21 @@ mod tests {
|
||||
assert_eq!(light.config_entry_id.as_deref(), Some("ce_001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_atomic_compatible_registry_without_overwrite() {
|
||||
let source = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(source.path()).unwrap();
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
|
||||
let path = write_entity_registry(destination.path(), &entries).unwrap();
|
||||
let imported = read_entity_registry(&path).unwrap();
|
||||
assert_eq!(imported.len(), entries.len());
|
||||
assert_eq!(imported[0].entity_id, entries[0].entity_id);
|
||||
|
||||
let second = write_entity_registry(destination.path(), &entries).unwrap_err();
|
||||
assert!(second.to_string().contains("refusing to overwrite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_by_maps_to_homecore() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
@@ -260,7 +334,13 @@ mod tests {
|
||||
let f = write_fixture(json);
|
||||
let err = read_entity_registry(f.path()).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }),
|
||||
matches!(
|
||||
err,
|
||||
MigrateError::UnsupportedSchemaVersion {
|
||||
minor_version: 99,
|
||||
..
|
||||
}
|
||||
),
|
||||
"got: {err}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
|
||||
@@ -44,8 +44,10 @@ fn main() -> anyhow::Result<()> {
|
||||
let entity_path = args.storage.join("core.entity_registry");
|
||||
let entries =
|
||||
homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
|
||||
println!("Imported {} entity entries (P1: in-memory only)", entries.len());
|
||||
println!(" Destination: {} (P2 persistence)", args.to.display());
|
||||
let destination =
|
||||
homecore_migrate::entity_registry::write_entity_registry(&args.to, &entries)?;
|
||||
println!("Imported {} entity entries", entries.len());
|
||||
println!(" Destination: {}", destination.display());
|
||||
for e in &entries {
|
||||
println!(
|
||||
" {} ({}{})",
|
||||
|
||||
@@ -25,6 +25,18 @@ use homecore::event::{DomainEvent, StateChangedEvent};
|
||||
use crate::dedup::fnv64a_hash;
|
||||
use crate::schema::ALL_DDL;
|
||||
|
||||
type SearchStateRecord = (
|
||||
i64,
|
||||
String,
|
||||
String,
|
||||
Option<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`].
|
||||
///
|
||||
/// Without this cap a wide `[since, until]` window over a high-frequency entity
|
||||
@@ -289,8 +301,7 @@ impl Recorder {
|
||||
.replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
|
||||
let rows: Vec<(i64, String, String, Option<String>, f64, f64, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
let rows: Vec<SearchStateRecord> = sqlx::query_as(
|
||||
"SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
@@ -332,8 +343,7 @@ impl Recorder {
|
||||
|
||||
/// Fetch a single `StateRow` by its `state_id`, joining attributes.
|
||||
async fn fetch_state_row(&self, state_id: i64) -> Result<Option<StateRow>, RecorderError> {
|
||||
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
let row: Option<StateRecord> = sqlx::query_as(
|
||||
"SELECT s.entity_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
@@ -409,7 +419,7 @@ impl Recorder {
|
||||
let since_ts = since.timestamp_micros() as f64 / 1_000_000.0;
|
||||
let until_ts = until.timestamp_micros() as f64 / 1_000_000.0;
|
||||
|
||||
let rows: Vec<(i64, String, Option<String>, f64, f64, Option<String>)> = sqlx::query_as(
|
||||
let rows: Vec<HistoryStateRecord> = sqlx::query_as(
|
||||
"SELECT s.state_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
@@ -884,7 +894,6 @@ mod tests {
|
||||
// public method (whose cap is MAX_HISTORY_ROWS) and run the *same* SQL
|
||||
// shape with a small bind to demonstrate the LIMIT term is effective —
|
||||
// and separately assert the constant is a sane positive bound.
|
||||
assert!(MAX_HISTORY_ROWS > 0, "history cap must be positive");
|
||||
let recorder = open_memory().await;
|
||||
for v in &["1", "2", "3", "4", "5"] {
|
||||
recorder
|
||||
|
||||
@@ -30,9 +30,12 @@ pub mod schema;
|
||||
pub mod semantic;
|
||||
|
||||
// Re-export the primary public API surface.
|
||||
pub use db::{PurgeStats, Recorder, RecorderError, StateRow, MAX_HISTORY_ROWS};
|
||||
pub use db::{PurgeStats, Recorder, RecorderError, SemanticIndex, StateRow, MAX_HISTORY_ROWS};
|
||||
pub use listener::RecorderListener;
|
||||
|
||||
/// Null semantic index used when the `ruvector` feature is off.
|
||||
/// Satisfies the [`db::SemanticIndex`] trait bound without any allocation.
|
||||
pub use db::NullSemanticIndex;
|
||||
|
||||
#[cfg(feature = "ruvector")]
|
||||
pub use semantic::RuvectorSemanticIndex;
|
||||
|
||||
@@ -23,8 +23,7 @@ path = "src/main.rs"
|
||||
# The 8 HOMECORE crates this binary integrates
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
homecore-api = { path = "../homecore-api", version = "0.1.0-alpha.0" }
|
||||
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0" }
|
||||
homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0" }
|
||||
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0", optional = true }
|
||||
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
|
||||
homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" }
|
||||
homecore-assist = { path = "../homecore-assist", version = "0.1.0-alpha.0" }
|
||||
@@ -50,6 +49,7 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
|
||||
# CHANGELOG / ADR-131 security note).
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
|
||||
futures = "0.3"
|
||||
|
||||
@@ -63,4 +63,4 @@ default = []
|
||||
# Pull in ruvector-backed semantic memory.
|
||||
ruvector = ["homecore-recorder/ruvector"]
|
||||
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
|
||||
wasmtime = ["homecore-plugins/wasmtime"]
|
||||
wasmtime = ["dep:homecore-plugins", "homecore-plugins/wasmtime"]
|
||||
|
||||
@@ -1,204 +1,81 @@
|
||||
# homecore-server
|
||||
|
||||
Integrated HOMECORE server binary that wires state machine, API, recorder, plugins, automations, intent assistant, and HomeKit bridge into one process.
|
||||
`homecore-server` is the alpha integration binary for HOMECORE. It runs one
|
||||
shared state machine, event bus, service registry, REST/WebSocket API, optional
|
||||
SQLite recorder, automation engine, intent endpoint, BFF gateway, and static
|
||||
dashboard.
|
||||
|
||||
[](.)
|
||||

|
||||

|
||||
[](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
|
||||
It is not a drop-in replacement for the complete Home Assistant API. The exact
|
||||
implemented and deferred surfaces are listed in
|
||||
[`docs/homecore-capabilities.md`](../../docs/homecore-capabilities.md).
|
||||
|
||||
The production-ready HOMECORE binary — boots all 7 subsystems (core, API, recorder, plugins, automation, assist, HAP bridge) in a single process listening on `:8123`.
|
||||
## Security defaults
|
||||
|
||||
## What this crate does
|
||||
|
||||
`homecore-server` is the integration point for the entire HOMECORE ecosystem. It orchestrates:
|
||||
|
||||
1. **HomeCore runtime** — state machine, event bus, service registry
|
||||
2. **REST + WebSocket API** — Axum server on `:8123` (HA-compatible)
|
||||
3. **SQLite Recorder** — persists all state changes to disk
|
||||
4. **Plugin Registry** — loads and manages integrations (InProcessRuntime by default)
|
||||
5. **Automation Engine** — evaluates triggers, conditions, and actions
|
||||
6. **Assist Pipeline** — intent recognition and execution
|
||||
7. **HAP Bridge** — exposes accessories to HomeKit
|
||||
|
||||
All subsystems share the same `HomeCore` instance, so state changes flow through the event bus and trigger automations, record history, and notify WebSocket subscribers in lockstep.
|
||||
|
||||
## Features
|
||||
|
||||
- **Single unified process** — no external microservices; run with `cargo run -p homecore-server`
|
||||
- **HA-compatible REST API** — drop-in replacement for Home Assistant's `/api/` on `:8123`
|
||||
- **SQLite state history** — persistent recording of all state changes
|
||||
- **Automation engine** — YAML-driven trigger→condition→action execution
|
||||
- **Intent assistant** — regex-based (P1) intent recognition + service calling
|
||||
- **HomeKit bridge** — exposes HOMECORE entities as HomeKit accessories
|
||||
- **Plugin system** — load first-party Rust plugins; Wasmtime WASM plugins (P2, `--features wasmtime`)
|
||||
- **Configurable via CLI + env vars** — no YAML required; sensible defaults
|
||||
- **Structured logging** — tracing output with `RUST_LOG` filtering
|
||||
- **Feature-gated subsystems** — disable recorder (`--no-recorder`), enable ruvector/wasmtime as needed
|
||||
|
||||
## Subsystems
|
||||
|
||||
| Subsystem | Crate | Role | Notes |
|
||||
|-----------|-------|------|-------|
|
||||
| State Machine | `homecore` | Core domain model | All other subsystems depend on this |
|
||||
| REST API | `homecore-api` | HTTP boundary | Listens on `:8123`; Axum framework |
|
||||
| Recorder | `homecore-recorder` | Persistence | SQLite; optional `--no-recorder` |
|
||||
| Plugins | `homecore-plugins` | Extension system | InProcessRuntime default; Wasmtime w/ feature |
|
||||
| Automation | `homecore-automation` | Trigger execution | Subscribes to event bus; YAML-driven |
|
||||
| Assist | `homecore-assist` | Intent pipeline | Regex recognizer (P1); semantic (P2) |
|
||||
| HAP Bridge | `homecore-hap` | HomeKit export | Accessories + characteristics; mDNS (P2) |
|
||||
|
||||
## Usage
|
||||
|
||||
**Basic startup** (in-memory recorder):
|
||||
Authentication fails closed. Set one or more comma-separated bearer tokens:
|
||||
|
||||
```bash
|
||||
cargo build -p homecore-server
|
||||
./target/debug/homecore-server
|
||||
# Listens on http://localhost:8123
|
||||
set HOMECORE_TOKENS=replace-with-a-long-random-token
|
||||
cargo run -p homecore-server
|
||||
```
|
||||
|
||||
**With persistent SQLite**:
|
||||
`--insecure-dev-auth` explicitly allows any non-empty bearer token and must only
|
||||
be used in an isolated development environment. The browser UI does not contain
|
||||
a default token. Configure allowed browser origins with
|
||||
`HOMECORE_CORS_ORIGINS`.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
- SQLite recording is enabled by default at `sqlite://homecore.db`.
|
||||
- Synthetic entities are disabled by default; opt in with
|
||||
`--seed-demo-entities`.
|
||||
- Automations can be loaded with `--automations <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
|
||||
./target/debug/homecore-server \
|
||||
--bind 0.0.0.0:8123 \
|
||||
--db sqlite:~/.homecore/home.db \
|
||||
--location-name "My Home"
|
||||
```
|
||||
curl -H "Authorization: Bearer $HOMECORE_TOKEN" \
|
||||
http://127.0.0.1:8123/api/states
|
||||
|
||||
**Full feature build** (ruvector semantic search + Wasmtime plugins):
|
||||
|
||||
```bash
|
||||
cargo build -p homecore-server --features ruvector,wasmtime --release
|
||||
```
|
||||
|
||||
**Via Docker** (Dockerfile planned P2):
|
||||
|
||||
```bash
|
||||
docker run -p 8123:8123 \
|
||||
-e HOMECORE_DB=sqlite:///data/home.db \
|
||||
-v ~/.homecore:/data \
|
||||
homecore-server:latest
|
||||
```
|
||||
|
||||
**Test the API**:
|
||||
|
||||
```bash
|
||||
# List all entities
|
||||
curl http://localhost:8123/api/states
|
||||
|
||||
# Set a light to "on"
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer $HOMECORE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"on","attributes":{"brightness":200}}' \
|
||||
http://localhost:8123/api/states/light.kitchen
|
||||
-d '{"entity_id":"light.kitchen"}' \
|
||||
http://127.0.0.1:8123/api/services/light/turn_on
|
||||
|
||||
# WebSocket subscription (real-time state changes)
|
||||
wscat -c ws://localhost:8123/api/websocket
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer $HOMECORE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"utterance":"turn on light.kitchen","language":"en"}' \
|
||||
http://127.0.0.1:8123/api/intent/handle
|
||||
```
|
||||
|
||||
**Configuration via env**:
|
||||
Built-in executable services are `homecore.ping`,
|
||||
`homecore.snapshot_state`, and `turn_on`/`turn_off`/`toggle` for the
|
||||
`homeassistant`, `light`, and `switch` domains. Unsupported services are left
|
||||
unregistered and return an error rather than a false acknowledgement.
|
||||
|
||||
```bash
|
||||
export HOMECORE_BIND="0.0.0.0:8123"
|
||||
export HOMECORE_DB="sqlite:~/.homecore/home.db"
|
||||
export HOMECORE_LOCATION="Living Room"
|
||||
export RUST_LOG="homecore=debug,homecore_api=info"
|
||||
./target/debug/homecore-server
|
||||
```
|
||||
## Configuration
|
||||
|
||||
## CLI Options
|
||||
| Flag | Environment | Default |
|
||||
|---|---|---|
|
||||
| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` |
|
||||
| `--db` | `HOMECORE_DB` | `sqlite://homecore.db` |
|
||||
| `--location-name` | `HOMECORE_LOCATION` | `Home` |
|
||||
| `--automations` | `HOMECORE_AUTOMATIONS` | unset |
|
||||
| `--insecure-dev-auth` | `HOMECORE_INSECURE_DEV_AUTH` | `false` |
|
||||
| `--seed-demo-entities` | — | `false` |
|
||||
| `--no-recorder` | — | `false` |
|
||||
|
||||
| Flag | Env Var | Default | Description |
|
||||
|------|---------|---------|-------------|
|
||||
| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` | REST API listen address |
|
||||
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
|
||||
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
|
||||
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
|
||||
| `--ui-dir` | `HOMECORE_UI_DIR` | `<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**:
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cargo test -p homecore-server
|
||||
cargo clippy -p homecore-server --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
**Enable debug logging**:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug cargo run -p homecore-server -- --bind 127.0.0.1:8123
|
||||
```
|
||||
|
||||
**Build documentation**:
|
||||
|
||||
```bash
|
||||
cargo doc -p homecore-server --open
|
||||
```
|
||||
|
||||
## Relation to other HOMECORE crates
|
||||
|
||||
```
|
||||
homecore-server (orchestration binary)
|
||||
├── homecore (state machine)
|
||||
├── homecore-api (REST + WS)
|
||||
├── homecore-recorder (SQLite persistence)
|
||||
├── homecore-plugins (extension system)
|
||||
├── homecore-automation (trigger execution)
|
||||
├── homecore-assist (intent pipeline)
|
||||
└── homecore-hap (HomeKit bridge)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
|
||||
- [README — wifi-densepose](../../../README.md)
|
||||
- [Dockerfile (planned P2)](Dockerfile.planned)
|
||||
- [Docker Hub image (planned P2)](https://hub.docker.com/r/ruvnet/homecore-server)
|
||||
|
||||
@@ -29,12 +29,16 @@ use axum::body::Bytes;
|
||||
use axum::extract::{Path, RawQuery, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use homecore_api::auth::BearerAuth;
|
||||
use homecore_api::SharedState;
|
||||
use homecore_assist::{AssistPipeline, RegexIntentRecognizer};
|
||||
#[cfg(test)]
|
||||
use homecore_assist::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
|
||||
|
||||
/// Static gateway configuration (from CLI/env in `main`).
|
||||
pub struct GatewayConfig {
|
||||
@@ -54,15 +58,36 @@ pub struct GatewayState {
|
||||
pub shared: SharedState,
|
||||
pub http: reqwest::Client,
|
||||
pub cfg: Arc<GatewayConfig>,
|
||||
pub assist: Arc<AssistPipeline<RegexIntentRecognizer>>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
#[cfg(test)]
|
||||
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
|
||||
let mut assist = AssistPipeline::new(RegexIntentRecognizer::new());
|
||||
assist.register_handler(HassTurnOn);
|
||||
assist.register_handler(HassTurnOff);
|
||||
assist.register_handler(HassLightSet);
|
||||
assist.register_handler(HassNevermind);
|
||||
assist.register_handler(HassCancelAll);
|
||||
Self::with_assist(shared, cfg, assist)
|
||||
}
|
||||
|
||||
pub fn with_assist(
|
||||
shared: SharedState,
|
||||
cfg: GatewayConfig,
|
||||
assist: AssistPipeline<RegexIntentRecognizer>,
|
||||
) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(cfg.timeout)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { shared, http, cfg: Arc::new(cfg) }
|
||||
Self {
|
||||
shared,
|
||||
http,
|
||||
cfg: Arc::new(cfg),
|
||||
assist: Arc::new(assist),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +101,7 @@ pub fn gateway_router(state: GatewayState) -> Router {
|
||||
.route("/api/homecore/rooms", get(rooms))
|
||||
.route("/api/homecore/cogs", get(cogs_list))
|
||||
.route("/api/homecore/appliance", get(appliance))
|
||||
.route("/api/intent/handle", post(handle_intent))
|
||||
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
|
||||
.route("/api/homecore/seeds", get(stub_503))
|
||||
.route("/api/homecore/seeds/:id", get(stub_503))
|
||||
@@ -93,6 +119,43 @@ pub fn gateway_router(state: GatewayState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IntentRequest {
|
||||
#[serde(alias = "text")]
|
||||
utterance: String,
|
||||
#[serde(default = "default_language")]
|
||||
language: String,
|
||||
}
|
||||
|
||||
fn default_language() -> String {
|
||||
"en".to_owned()
|
||||
}
|
||||
|
||||
async fn handle_intent(
|
||||
State(st): State<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 ─────────────────────────────────────────────
|
||||
|
||||
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
|
||||
@@ -106,7 +169,11 @@ fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
|
||||
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
|
||||
}
|
||||
fn upstream_unavailable(detail: &str) -> Response {
|
||||
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
|
||||
typed(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"upstream_unavailable",
|
||||
detail,
|
||||
)
|
||||
}
|
||||
fn upstream_timeout(detail: &str) -> Response {
|
||||
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
|
||||
@@ -130,33 +197,32 @@ fn bad_request(detail: &str) -> Response {
|
||||
/// like `%252e` decodes once to `%2e` and is caught here).
|
||||
///
|
||||
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
|
||||
fn validate_proxy_path(path: &str) -> Result<(), Response> {
|
||||
fn validate_proxy_path(path: &str) -> Result<(), &'static str> {
|
||||
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
|
||||
if path.starts_with('/') {
|
||||
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
|
||||
return Err("proxied path must be relative (leading '/' not allowed)");
|
||||
}
|
||||
if path.contains('\\') {
|
||||
return Err(bad_request("proxied path must not contain a backslash"));
|
||||
return Err("proxied path must not contain a backslash");
|
||||
}
|
||||
// 2. Percent-decode once and re-check; reject if decoding is invalid.
|
||||
let decoded = percent_decode_once(path)
|
||||
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
|
||||
let decoded = percent_decode_once(path).ok_or("proxied path has invalid percent-encoding")?;
|
||||
if decoded.starts_with('/') || decoded.contains('\\') {
|
||||
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
|
||||
return Err("proxied path resolves to an absolute/traversal path");
|
||||
}
|
||||
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
|
||||
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
|
||||
for form in [path, decoded.as_str()] {
|
||||
for seg in form.split(['/', '\\']) {
|
||||
if seg == "." || seg == ".." {
|
||||
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
|
||||
return Err("proxied path must not contain '.' or '..' segments");
|
||||
}
|
||||
}
|
||||
// Defence in depth: a residual encoded traversal marker survived the
|
||||
// single decode (e.g. originally double-encoded). Reject it outright.
|
||||
let lower = form.to_ascii_lowercase();
|
||||
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
|
||||
return Err(bad_request("proxied path must not contain encoded traversal markers"));
|
||||
return Err("proxied path must not contain encoded traversal markers");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -194,7 +260,9 @@ async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Respons
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
|
||||
upstream_unavailable(
|
||||
"endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)",
|
||||
)
|
||||
}
|
||||
|
||||
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
|
||||
@@ -216,12 +284,14 @@ async fn cal_proxy_get(
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
if let Err(r) = validate_proxy_path(&path) {
|
||||
return r;
|
||||
if let Err(detail) = validate_proxy_path(&path) {
|
||||
return bad_request(detail);
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u,
|
||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||
None => return upstream_unavailable(
|
||||
"calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)",
|
||||
),
|
||||
};
|
||||
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
|
||||
// The wildcard already carries the `v1/...` segment (the UI calls
|
||||
@@ -239,12 +309,14 @@ async fn cal_proxy_post(
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
if let Err(r) = validate_proxy_path(&path) {
|
||||
return r;
|
||||
if let Err(detail) = validate_proxy_path(&path) {
|
||||
return bad_request(detail);
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u,
|
||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||
None => return upstream_unavailable(
|
||||
"calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)",
|
||||
),
|
||||
};
|
||||
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
|
||||
let rb = st
|
||||
@@ -264,7 +336,8 @@ async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(resp) => {
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let status =
|
||||
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
@@ -325,7 +398,10 @@ async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
let base = base.as_str();
|
||||
async move {
|
||||
let url = format!("{base}/api/v1/room/state?bank={bank}");
|
||||
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
|
||||
fetch_json(st, &url)
|
||||
.await
|
||||
.ok()
|
||||
.map(|v| adapt_room_state(&bank, &v))
|
||||
}
|
||||
});
|
||||
let out: Vec<Value> = futures::future::join_all(fetches)
|
||||
@@ -352,10 +428,7 @@ fn bank_names(v: &Value) -> Vec<String> {
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
Value::Object(o) => o
|
||||
.get("baselines")
|
||||
.map(|b| bank_names(b))
|
||||
.unwrap_or_default(),
|
||||
Value::Object(o) => o.get("baselines").map(bank_names).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -456,7 +529,11 @@ async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
|
||||
}
|
||||
|
||||
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(p) = s.trim().parse::<i64>() {
|
||||
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> {
|
||||
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> {
|
||||
@@ -551,7 +630,9 @@ fn cpu_load_pct() -> Option<f64> {
|
||||
.next()?
|
||||
.parse::<f64>()
|
||||
.ok()?;
|
||||
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
|
||||
let ncpu = std::thread::available_parallelism()
|
||||
.map(|n| n.get() as f64)
|
||||
.unwrap_or(1.0);
|
||||
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
|
||||
}
|
||||
|
||||
@@ -606,7 +687,9 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let b = axum::body::to_bytes(resp.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
(status, String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
@@ -614,7 +697,12 @@ mod tests {
|
||||
async fn unauthenticated_is_rejected() {
|
||||
let app = gateway_router(gw());
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/homecore/cogs")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
@@ -636,7 +724,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn seed_tier_routes_are_typed_503() {
|
||||
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
|
||||
for p in [
|
||||
"/api/homecore/seeds",
|
||||
"/api/homecore/federation",
|
||||
"/api/homecore/witness",
|
||||
"/api/events",
|
||||
] {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", p).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
|
||||
assert!(body.contains("upstream_unavailable"), "{p} typed body");
|
||||
@@ -651,6 +744,46 @@ mod tests {
|
||||
assert!(body.contains("\"ram_pct\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intent_endpoint_executes_a_real_service() {
|
||||
let state = gw();
|
||||
let hc = state.shared.homecore().clone();
|
||||
let id = homecore::EntityId::parse("light.kitchen").unwrap();
|
||||
hc.states().set(
|
||||
id.clone(),
|
||||
"off",
|
||||
serde_json::json!({}),
|
||||
homecore::Context::new(),
|
||||
);
|
||||
crate::register_builtin_services(&hc).await;
|
||||
let assist = crate::build_assist_pipeline().await.unwrap();
|
||||
let state = GatewayState::with_assist(
|
||||
state.shared,
|
||||
GatewayConfig {
|
||||
calibration_url: None,
|
||||
calibration_token: None,
|
||||
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
|
||||
timeout: Duration::from_millis(200),
|
||||
},
|
||||
assist,
|
||||
);
|
||||
let response = gateway_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/intent/handle")
|
||||
.header("authorization", "Bearer dev")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"utterance":"turn on light.kitchen"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(hc.states().get(&id).unwrap().state, "on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_room_state_maps_fields_and_preserves_null() {
|
||||
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
|
||||
@@ -668,7 +801,10 @@ mod tests {
|
||||
assert_eq!(ui["stale"], true);
|
||||
assert_eq!(ui["presence"]["value"], "occupied");
|
||||
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
|
||||
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
|
||||
assert!(
|
||||
ui["heart_bpm"].is_null(),
|
||||
"None heartbeat must map to null (not trained)"
|
||||
);
|
||||
// §6 invariant 3: upstream RoomState carries no threshold here, so the
|
||||
// adapter must emit null (withheld) — NOT a fabricated constant.
|
||||
assert!(
|
||||
@@ -685,7 +821,10 @@ mod tests {
|
||||
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
|
||||
});
|
||||
let ui = adapt_room_state("bedroom_1", &cal);
|
||||
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
|
||||
assert_eq!(
|
||||
ui["anomaly"]["threshold"], 0.73,
|
||||
"real threshold must pass through"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -731,8 +870,15 @@ mod tests {
|
||||
("POST", "/api/cal/v1/../../x"),
|
||||
] {
|
||||
let (status, body) = send(gateway_router(gw()), method, path).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
|
||||
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{method} {path} must be 400"
|
||||
);
|
||||
assert!(
|
||||
body.contains("bad_request"),
|
||||
"{method} {path} typed 400 body"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("upstream_unavailable"),
|
||||
"{method} {path} must NOT reach the upstream-config branch"
|
||||
@@ -746,13 +892,19 @@ mod tests {
|
||||
// "not configured" 503 (proving it was NOT blocked as traversal).
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
|
||||
assert!(
|
||||
body.contains("upstream_unavailable"),
|
||||
"legit path should reach upstream branch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_names_accepts_strings_and_objects() {
|
||||
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
|
||||
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
|
||||
assert_eq!(
|
||||
bank_names(&json!([{"name":"x"}, {"id":"y"}])),
|
||||
vec!["x", "y"]
|
||||
);
|
||||
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
//! - HomeCore runtime (state machine + event bus + service registry)
|
||||
//! - SQLite recorder writing every state_changed event
|
||||
//! - REST + WebSocket API on :8123 (HA wire-compat)
|
||||
//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime)
|
||||
//! - Automation engine subscribed to the state machine
|
||||
//! - Assist pipeline (intent recognizer + handler set)
|
||||
//! - HAP bridge surface (accessories registered via the API)
|
||||
//!
|
||||
//! Run with:
|
||||
//!
|
||||
@@ -19,21 +17,20 @@
|
||||
//! cargo run -p homecore-server --features ruvector,wasmtime -- ...
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
|
||||
use homecore_assist::pipeline::default_pipeline;
|
||||
use homecore_assist::RegexIntentRecognizer;
|
||||
use homecore_assist::{
|
||||
AssistPipeline, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
RegexIntentRecognizer,
|
||||
};
|
||||
use homecore_automation::AutomationEngine;
|
||||
use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
|
||||
use homecore_plugins::{InProcessRuntime, PluginRegistry};
|
||||
use homecore_recorder::Recorder;
|
||||
use homecore_recorder::{Recorder, RecorderListener};
|
||||
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
@@ -71,7 +68,11 @@ struct Cli {
|
||||
calibration_token: Option<String>,
|
||||
|
||||
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
|
||||
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "HOMECORE_APPS_DIR",
|
||||
default_value = "/var/lib/cognitum/apps"
|
||||
)]
|
||||
apps_dir: String,
|
||||
|
||||
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
|
||||
@@ -79,7 +80,7 @@ struct Cli {
|
||||
gateway_timeout_ms: u64,
|
||||
|
||||
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
|
||||
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
|
||||
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite://homecore.db")]
|
||||
db: String,
|
||||
|
||||
/// Friendly location name surfaced via `/api/config`.
|
||||
@@ -90,20 +91,50 @@ struct Cli {
|
||||
#[arg(long)]
|
||||
no_recorder: bool,
|
||||
|
||||
/// Skip the boot-time entity seeding (10 demo entities including
|
||||
/// 4 RuView-derived sensors). Use this when wiring real
|
||||
/// integrations that will populate the state machine themselves.
|
||||
/// Explicitly allow any non-empty bearer token. Development only.
|
||||
#[arg(long, env = "HOMECORE_INSECURE_DEV_AUTH", default_value_t = false)]
|
||||
insecure_dev_auth: bool,
|
||||
|
||||
/// Seed synthetic demo entities. Disabled by default so simulated
|
||||
/// biometric readings are never mistaken for live sensor data.
|
||||
#[arg(long)]
|
||||
no_seed_entities: bool,
|
||||
seed_demo_entities: bool,
|
||||
|
||||
/// Optional Home Assistant-style automations YAML file to load at boot.
|
||||
#[arg(long, env = "HOMECORE_AUTOMATIONS")]
|
||||
automations: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing();
|
||||
let cli = Cli::parse();
|
||||
let has_tokens = std::env::var("HOMECORE_TOKENS")
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !has_tokens && !cli.insecure_dev_auth {
|
||||
anyhow::bail!(
|
||||
"HOMECORE_TOKENS is required; use --insecure-dev-auth only for isolated development"
|
||||
);
|
||||
}
|
||||
let tokens = if has_tokens {
|
||||
let store = LongLivedTokenStore::from_env();
|
||||
info!(
|
||||
"Provisioned {} bearer token(s) from HOMECORE_TOKENS",
|
||||
store.len().await
|
||||
);
|
||||
store
|
||||
} else {
|
||||
warn!(
|
||||
"Insecure development authentication enabled: any non-empty bearer token is accepted"
|
||||
);
|
||||
LongLivedTokenStore::allow_any_non_empty()
|
||||
};
|
||||
|
||||
info!("HOMECORE booting — bind={}, db={}, location={:?}",
|
||||
cli.bind, cli.db, cli.location_name);
|
||||
info!(
|
||||
"HOMECORE booting — bind={}, db={}, location={:?}",
|
||||
cli.bind, cli.db, cli.location_name
|
||||
);
|
||||
|
||||
// ── 1. HomeCore runtime ─────────────────────────────────────────
|
||||
let hc = HomeCore::new();
|
||||
@@ -114,33 +145,27 @@ async fn main() -> Result<()> {
|
||||
// first boot. These are no-op handlers (they just echo back the
|
||||
// call as JSON for observability) — integrations override them
|
||||
// by registering the same ServiceName later.
|
||||
seed_default_services(&hc).await;
|
||||
register_builtin_services(&hc).await;
|
||||
|
||||
// Seed 10 representative entities so the web UI's Dashboard +
|
||||
// States pages have content out of the box. Operators registering
|
||||
// real integrations / plugins overwrite these by writing the same
|
||||
// entity_id with new values. Opt out with `--no-seed-entities`.
|
||||
if !cli.no_seed_entities {
|
||||
if cli.seed_demo_entities {
|
||||
seed_default_entities(&hc);
|
||||
} else {
|
||||
info!("Entity seeding disabled by --no-seed-entities");
|
||||
info!("Synthetic demo entities disabled (use --seed-demo-entities to opt in)");
|
||||
}
|
||||
|
||||
// ── 2. Recorder (optional) ──────────────────────────────────────
|
||||
if !cli.no_recorder {
|
||||
match Recorder::open(&cli.db).await {
|
||||
match open_recorder(&cli.db).await {
|
||||
Ok(recorder) => {
|
||||
let recorder = Arc::new(recorder);
|
||||
let rec_clone = Arc::clone(&recorder);
|
||||
let mut state_rx = hc.states().subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = state_rx.recv().await {
|
||||
if let Err(e) = rec_clone.record_state(&event).await {
|
||||
warn!("recorder write failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
info!("Recorder open at {} — state_changed events being persisted", cli.db);
|
||||
let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn();
|
||||
info!(
|
||||
"Recorder open at {} — state_changed events being persisted",
|
||||
cli.db
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Recorder failed to open ({e}) — continuing without persistence");
|
||||
@@ -151,11 +176,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// ── 3. Plugin runtime ───────────────────────────────────────────
|
||||
let plugin_runtime = InProcessRuntime;
|
||||
let plugin_registry: PluginRegistry<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 ────────────────────────────────────────
|
||||
// Construct AND start the engine (HC-WS-03, ADR-161). `start()`
|
||||
// spawns the state-change event loop + the 1 Hz wall-clock timer
|
||||
@@ -166,6 +186,14 @@ async fn main() -> Result<()> {
|
||||
// boot yet (YAML loader is P-next); integrations register via
|
||||
// `engine.register(..)`.
|
||||
let automation_engine = AutomationEngine::new(hc.clone());
|
||||
if let Some(path) = &cli.automations {
|
||||
let raw = tokio::fs::read_to_string(path).await?;
|
||||
let automations: Vec<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();
|
||||
info!(
|
||||
"Automation engine started ({} automations registered) — \
|
||||
@@ -174,36 +202,12 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// ── 5. Assist pipeline ──────────────────────────────────────────
|
||||
let recognizer = RegexIntentRecognizer::new();
|
||||
let pipeline = default_pipeline(recognizer);
|
||||
info!("Assist pipeline ready (5 built-in intent handlers via default_pipeline)");
|
||||
let _ = pipeline; // wired-but-idle at boot; voice WS plugs in here
|
||||
|
||||
// ── 6. HAP bridge surface ───────────────────────────────────────
|
||||
let hap_record = HapServiceRecord {
|
||||
instance_name: "HOMECORE".to_string(),
|
||||
port: 51826,
|
||||
setup_code: "123-45-678".to_string(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".to_string(),
|
||||
};
|
||||
let hap_bridge = HapBridge::new(hap_record);
|
||||
info!("HAP bridge surface ready ({} accessories registered)", hap_bridge.running_accessories().len());
|
||||
let _ = hap_bridge;
|
||||
|
||||
// ── 7. REST + WS API ────────────────────────────────────────────
|
||||
// Token provisioning closes audit findings HC-01/HC-02. If
|
||||
// HOMECORE_TOKENS is set in the env, populate the store from
|
||||
// its comma-separated list. Otherwise fall back to DEV mode
|
||||
// (warn-on-each-request) so existing smoke tests still work.
|
||||
let tokens = if std::env::var("HOMECORE_TOKENS").map(|v| !v.trim().is_empty()).unwrap_or(false) {
|
||||
let s = LongLivedTokenStore::from_env();
|
||||
let n = s.len().await;
|
||||
info!("LongLivedTokenStore provisioned with {} bearer token(s) from HOMECORE_TOKENS", n);
|
||||
s
|
||||
} else {
|
||||
warn!("HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer accepted). Provision real tokens before exposing to the network.");
|
||||
LongLivedTokenStore::allow_any_non_empty()
|
||||
};
|
||||
let api_state = SharedState::with_tokens(
|
||||
hc.clone(),
|
||||
cli.location_name,
|
||||
@@ -213,7 +217,12 @@ async fn main() -> Result<()> {
|
||||
// BFF gateway (ADR-131 §11): single-origin aggregation of the
|
||||
// calibration API + SEED/appliance tiers. Shares the same token store
|
||||
// for auth; upstream credentials stay server-side.
|
||||
let gw = GatewayState::new(
|
||||
let assist = build_assist_pipeline().await?;
|
||||
info!(
|
||||
"Assist intent endpoint ready with {} handlers",
|
||||
assist.handler_count()
|
||||
);
|
||||
let gw = GatewayState::with_assist(
|
||||
api_state.clone(),
|
||||
GatewayConfig {
|
||||
calibration_url: cli.calibration_url.clone(),
|
||||
@@ -221,6 +230,7 @@ async fn main() -> Result<()> {
|
||||
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
|
||||
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
|
||||
},
|
||||
assist,
|
||||
);
|
||||
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
|
||||
// audited CORS allowlist + request tracing to the WHOLE surface. The
|
||||
@@ -233,19 +243,35 @@ async fn main() -> Result<()> {
|
||||
.layer(build_cors_layer())
|
||||
.layer(TraceLayer::new_for_http());
|
||||
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
||||
info!(
|
||||
"HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)",
|
||||
cli.bind
|
||||
);
|
||||
info!(
|
||||
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
|
||||
cli.calibration_url
|
||||
);
|
||||
if !cli.ui_dir.trim().is_empty() {
|
||||
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
|
||||
info!(
|
||||
"HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}",
|
||||
cli.bind, cli.ui_dir
|
||||
);
|
||||
} else {
|
||||
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
|
||||
}
|
||||
|
||||
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
|
||||
axum::serve(listener, app).await?;
|
||||
let shutdown_hc = hc.clone();
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
warn!("failed to install Ctrl-C handler: {error}");
|
||||
}
|
||||
shutdown_hc
|
||||
.bus()
|
||||
.fire_system(homecore::SystemEvent::HomeCoreStop);
|
||||
info!("Shutdown requested; draining active HTTP connections");
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -263,11 +289,68 @@ fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
|
||||
app.nest_service("/homecore", ServeDir::new(ui_dir))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ruvector"))]
|
||||
async fn open_recorder(path: &str) -> anyhow::Result<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() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()),
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
"info,homecore=debug,homecore_server=debug,tower_http=info".into()
|
||||
}),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
@@ -281,42 +364,125 @@ fn init_tracing() {
|
||||
/// light / switch / scene / automation domains) plus a `homecore.*`
|
||||
/// domain so operators can see HOMECORE-native services distinguished
|
||||
/// from the HA-compat ones.
|
||||
async fn seed_default_services(hc: &HomeCore) {
|
||||
let echo = || FnHandler(|call: ServiceCall| async move {
|
||||
Ok(serde_json::json!({
|
||||
"called": format!("{}.{}", call.name.domain, call.name.service),
|
||||
"service_data": call.data,
|
||||
"acknowledged": true,
|
||||
}))
|
||||
});
|
||||
async fn register_builtin_services(hc: &HomeCore) {
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homecore", "ping"),
|
||||
FnHandler(|_call| async { Ok(serde_json::json!({ "pong": true })) }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let svcs = [
|
||||
// Conventional HA wire-compat services
|
||||
("homeassistant", "restart"),
|
||||
("homeassistant", "stop"),
|
||||
("homeassistant", "reload_core_config"),
|
||||
("light", "turn_on"),
|
||||
("light", "turn_off"),
|
||||
("light", "toggle"),
|
||||
("switch", "turn_on"),
|
||||
("switch", "turn_off"),
|
||||
("switch", "toggle"),
|
||||
("scene", "apply"),
|
||||
("automation", "trigger"),
|
||||
// HOMECORE-native services
|
||||
("homecore", "ping"),
|
||||
("homecore", "snapshot_state"),
|
||||
];
|
||||
let snapshot_states = hc.states().clone();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homecore", "snapshot_state"),
|
||||
FnHandler(move |_call| {
|
||||
let states = snapshot_states.clone();
|
||||
async move { Ok(serde_json::to_value(states.all()).unwrap_or_default()) }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (domain, service) in svcs {
|
||||
hc.services()
|
||||
.register(ServiceName::new(domain, service), echo())
|
||||
.await;
|
||||
for domain in ["homeassistant", "light", "switch"] {
|
||||
for action in ["turn_on", "turn_off", "toggle"] {
|
||||
let states = hc.states().clone();
|
||||
let required_domain = (domain != "homeassistant").then(|| domain.to_owned());
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, action),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let states = states.clone();
|
||||
let required_domain = required_domain.clone();
|
||||
async move {
|
||||
let ids = service_entity_ids(&call.data)?;
|
||||
let mut changed = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(domain) = required_domain.as_deref() {
|
||||
if id.domain() != domain {
|
||||
return Err(ServiceError::HandlerFailed(format!(
|
||||
"{}.{} only accepts {domain} entities",
|
||||
call.name.domain, call.name.service
|
||||
)));
|
||||
}
|
||||
}
|
||||
let current = states.get(&id).ok_or_else(|| {
|
||||
ServiceError::HandlerFailed(format!(
|
||||
"entity not found: {}",
|
||||
id.as_str()
|
||||
))
|
||||
})?;
|
||||
let next = match call.name.service.as_str() {
|
||||
"turn_on" => "on",
|
||||
"turn_off" => "off",
|
||||
"toggle" if current.state == "on" => "off",
|
||||
"toggle" => "on",
|
||||
_ => unreachable!("only registered actions are dispatched"),
|
||||
};
|
||||
let mut attributes = current.attributes.clone();
|
||||
if next == "on" {
|
||||
if let (Some(object), Some(brightness)) =
|
||||
(attributes.as_object_mut(), call.data.get("brightness"))
|
||||
{
|
||||
object.insert("brightness".into(), brightness.clone());
|
||||
}
|
||||
}
|
||||
states.set(
|
||||
id.clone(),
|
||||
next,
|
||||
attributes,
|
||||
Context::child_of(&call.context),
|
||||
);
|
||||
changed.push(id.as_str().to_owned());
|
||||
}
|
||||
Ok(serde_json::json!({ "changed": changed }))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let count = hc.services().registered_services().await.len();
|
||||
let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() };
|
||||
info!("Service registry seeded with {} default service(s)", count);
|
||||
info!(
|
||||
"Registered {} executable built-in services",
|
||||
hc.services().registered_services().await.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn service_entity_ids(data: &serde_json::Value) -> Result<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:`
|
||||
@@ -325,45 +491,85 @@ async fn seed_default_services(hc: &HomeCore) {
|
||||
/// they stay in sync.
|
||||
fn seed_default_entities(hc: &HomeCore) {
|
||||
let entities: Vec<(&str, &str, serde_json::Value)> = vec![
|
||||
("sensor.living_room_presence", "false", serde_json::json!({
|
||||
"friendly_name": "Living Room Presence", "device_class": "occupancy",
|
||||
"source": "RuView ESP32-C6 BFLD"
|
||||
})),
|
||||
("sensor.living_room_motion_score", "0.0", serde_json::json!({
|
||||
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
|
||||
"icon": "mdi:motion-sensor"
|
||||
})),
|
||||
("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({
|
||||
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
|
||||
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
|
||||
})),
|
||||
("sensor.bedroom_heart_rate", "68.0", serde_json::json!({
|
||||
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
|
||||
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
|
||||
})),
|
||||
("light.kitchen_ceiling", "on", serde_json::json!({
|
||||
"friendly_name": "Kitchen Ceiling", "brightness": 230,
|
||||
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
|
||||
})),
|
||||
("light.living_room_lamp", "off", serde_json::json!({
|
||||
"friendly_name": "Living Room Lamp", "brightness": 0,
|
||||
"supported_color_modes": ["brightness"]
|
||||
})),
|
||||
("switch.coffee_maker", "off", serde_json::json!({
|
||||
"friendly_name": "Coffee Maker", "device_class": "outlet"
|
||||
})),
|
||||
("binary_sensor.front_door", "off", serde_json::json!({
|
||||
"friendly_name": "Front Door", "device_class": "door"
|
||||
})),
|
||||
("climate.thermostat", "heat", serde_json::json!({
|
||||
"friendly_name": "Thermostat", "current_temperature": 21.5,
|
||||
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
|
||||
"supported_features": 387
|
||||
})),
|
||||
("sensor.air_quality_index", "42", serde_json::json!({
|
||||
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
|
||||
"device_class": "aqi"
|
||||
})),
|
||||
(
|
||||
"sensor.living_room_presence",
|
||||
"false",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Living Room Presence", "device_class": "occupancy",
|
||||
"source": "RuView ESP32-C6 BFLD"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"sensor.living_room_motion_score",
|
||||
"0.0",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
|
||||
"icon": "mdi:motion-sensor"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"sensor.bedroom_breathing_rate",
|
||||
"14.5",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
|
||||
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"sensor.bedroom_heart_rate",
|
||||
"68.0",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
|
||||
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"light.kitchen_ceiling",
|
||||
"on",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Kitchen Ceiling", "brightness": 230,
|
||||
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"light.living_room_lamp",
|
||||
"off",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Living Room Lamp", "brightness": 0,
|
||||
"supported_color_modes": ["brightness"]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"switch.coffee_maker",
|
||||
"off",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Coffee Maker", "device_class": "outlet"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"binary_sensor.front_door",
|
||||
"off",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Front Door", "device_class": "door"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"climate.thermostat",
|
||||
"heat",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Thermostat", "current_temperature": 21.5,
|
||||
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
|
||||
"supported_features": 387
|
||||
}),
|
||||
),
|
||||
(
|
||||
"sensor.air_quality_index",
|
||||
"42",
|
||||
serde_json::json!({
|
||||
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
|
||||
"device_class": "aqi"
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (id, state, attrs) in entities {
|
||||
@@ -381,8 +587,11 @@ fn seed_default_entities(hc: &HomeCore) {
|
||||
context: Context::new(),
|
||||
};
|
||||
let total = hc.states().all().len();
|
||||
info!("State machine seeded with {} default entit{}", total,
|
||||
if total == 1 { "y" } else { "ies" });
|
||||
info!(
|
||||
"State machine seeded with {} default entit{}",
|
||||
total,
|
||||
if total == 1 { "y" } else { "ies" }
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -419,9 +628,19 @@ mod ui_tests {
|
||||
async fn ui_index_is_served_at_homecore() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
let (status, body) = get(app, "/homecore/").await;
|
||||
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
|
||||
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
|
||||
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"GET /homecore/ should serve index.html"
|
||||
);
|
||||
assert!(
|
||||
body.contains("HOMECORE"),
|
||||
"index.html should mention HOMECORE"
|
||||
);
|
||||
assert!(
|
||||
body.contains("./js/app.js"),
|
||||
"index.html should bootstrap app.js"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -437,8 +656,18 @@ mod ui_tests {
|
||||
#[tokio::test]
|
||||
async fn ui_panels_are_served() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
|
||||
"entities", "cogs", "events", "audit", "settings"] {
|
||||
for p in [
|
||||
"dashboard",
|
||||
"rooms",
|
||||
"calibration",
|
||||
"fleet",
|
||||
"seed-detail",
|
||||
"entities",
|
||||
"cogs",
|
||||
"events",
|
||||
"audit",
|
||||
"settings",
|
||||
] {
|
||||
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
|
||||
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
|
||||
}
|
||||
@@ -459,9 +688,15 @@ mod ui_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"the HA-compat API must coexist with the UI mount"
|
||||
);
|
||||
assert!(body.contains("API running"));
|
||||
}
|
||||
|
||||
@@ -469,12 +704,16 @@ mod ui_tests {
|
||||
async fn ui_mount_can_be_disabled() {
|
||||
let app = build_app(test_state(), "");
|
||||
let (status, _) = get(app, "/homecore/").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"empty --ui-dir disables the mount"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
|
||||
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
|
||||
/// whole thing. Used to prove the gateway routes are CORS-covered.
|
||||
/// whole thing. Used to prove the gateway routes are CORS-covered.
|
||||
fn full_app(state: SharedState) -> Router {
|
||||
use crate::gateway::{GatewayConfig, GatewayState};
|
||||
let gw = GatewayState::new(
|
||||
@@ -527,4 +766,47 @@ mod ui_tests {
|
||||
"gateway route must echo the allowlisted dev origin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_light_service_changes_real_state() {
|
||||
let hc = HomeCore::new();
|
||||
let id = EntityId::parse("light.kitchen").unwrap();
|
||||
hc.states().set(
|
||||
id.clone(),
|
||||
"off",
|
||||
serde_json::json!({"friendly_name": "Kitchen"}),
|
||||
Context::new(),
|
||||
);
|
||||
register_builtin_services(&hc).await;
|
||||
|
||||
let result = hc
|
||||
.services()
|
||||
.call(ServiceCall {
|
||||
name: ServiceName::new("light", "turn_on"),
|
||||
data: serde_json::json!({"entity_id": "light.kitchen", "brightness": 123}),
|
||||
context: Context::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result["changed"][0], "light.kitchen");
|
||||
let state = hc.states().get(&id).unwrap();
|
||||
assert_eq!(state.state, "on");
|
||||
assert_eq!(state.attributes["brightness"], 123);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_builtin_service_is_not_success_shaped() {
|
||||
let hc = HomeCore::new();
|
||||
register_builtin_services(&hc).await;
|
||||
let result = hc
|
||||
.services()
|
||||
.call(ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "restart"),
|
||||
data: serde_json::json!({}),
|
||||
context: Context::new(),
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(ServiceError::NotRegistered { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function demoMode() {
|
||||
|
||||
export const api = {
|
||||
base: '',
|
||||
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
|
||||
token: () => { try { return localStorage.getItem('homecore_token') || ''; } catch { return ''; } },
|
||||
isDemo: (key) => !!demoFlags[key],
|
||||
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
|
||||
demoMode,
|
||||
|
||||
@@ -84,9 +84,23 @@ impl Default for Context {
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SystemEvent {
|
||||
StateChanged(StateChangedEvent),
|
||||
ServiceRegistered { domain: String, service: String },
|
||||
ServiceRemoved { domain: String, service: String },
|
||||
ComponentLoaded { component: String },
|
||||
ServiceCalled {
|
||||
domain: String,
|
||||
service: String,
|
||||
data: serde_json::Value,
|
||||
context: Context,
|
||||
},
|
||||
ServiceRegistered {
|
||||
domain: String,
|
||||
service: String,
|
||||
},
|
||||
ServiceRemoved {
|
||||
domain: String,
|
||||
service: String,
|
||||
},
|
||||
ComponentLoaded {
|
||||
component: String,
|
||||
},
|
||||
HomeCoreStart,
|
||||
HomeCoreStarted,
|
||||
HomeCoreStop,
|
||||
|
||||
@@ -24,11 +24,12 @@ struct HomeCoreInner {
|
||||
|
||||
impl HomeCore {
|
||||
pub fn new() -> Self {
|
||||
let bus = EventBus::new();
|
||||
Self {
|
||||
inner: Arc::new(HomeCoreInner {
|
||||
bus: EventBus::new(),
|
||||
states: StateMachine::new(),
|
||||
services: ServiceRegistry::new(),
|
||||
states: StateMachine::with_event_bus(bus.clone()),
|
||||
services: ServiceRegistry::with_event_bus(bus.clone()),
|
||||
bus,
|
||||
entities: EntityRegistry::new(),
|
||||
}),
|
||||
}
|
||||
@@ -61,15 +62,76 @@ impl Default for HomeCore {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entity::EntityId;
|
||||
use crate::event::Context;
|
||||
use crate::event::{Context, SystemEvent};
|
||||
use crate::service::{FnHandler, ServiceCall, ServiceName};
|
||||
|
||||
#[tokio::test]
|
||||
async fn end_to_end_set_then_get() {
|
||||
let hc = HomeCore::new();
|
||||
let id = EntityId::parse("light.kitchen").unwrap();
|
||||
hc.states().set(id.clone(), "on", serde_json::json!({"brightness": 200}), Context::new());
|
||||
hc.states().set(
|
||||
id.clone(),
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
Context::new(),
|
||||
);
|
||||
let snap = hc.states().get(&id).unwrap();
|
||||
assert_eq!(snap.state, "on");
|
||||
assert_eq!(snap.attributes["brightness"], 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_changes_are_published_on_shared_system_bus() {
|
||||
let hc = HomeCore::new();
|
||||
let mut rx = hc.bus().subscribe_system();
|
||||
let id = EntityId::parse("light.kitchen").unwrap();
|
||||
|
||||
hc.states()
|
||||
.set(id.clone(), "on", serde_json::json!({}), Context::new());
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
match event {
|
||||
SystemEvent::StateChanged(change) => {
|
||||
assert_eq!(change.entity_id, id);
|
||||
assert_eq!(change.new_state.unwrap().state, "on");
|
||||
}
|
||||
other => panic!("expected StateChanged, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_calls_are_published_on_shared_system_bus() {
|
||||
let hc = HomeCore::new();
|
||||
let service = ServiceName::new("light", "turn_on");
|
||||
hc.services()
|
||||
.register(
|
||||
service.clone(),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
let mut rx = hc.bus().subscribe_system();
|
||||
|
||||
hc.services()
|
||||
.call(ServiceCall {
|
||||
name: service,
|
||||
data: serde_json::json!({"brightness": 42}),
|
||||
context: Context::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match rx.recv().await.unwrap() {
|
||||
SystemEvent::ServiceCalled {
|
||||
domain,
|
||||
service,
|
||||
data,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(domain, "light");
|
||||
assert_eq!(service, "turn_on");
|
||||
assert_eq!(data["brightness"], 42);
|
||||
}
|
||||
other => panic!("expected ServiceCalled, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ impl EntityRegistry {
|
||||
}
|
||||
|
||||
pub async fn register(&self, entry: EntityEntry) {
|
||||
self.entries.write().await.insert(entry.entity_id.clone(), entry);
|
||||
self.entries
|
||||
.write()
|
||||
.await
|
||||
.insert(entry.entity_id.clone(), entry);
|
||||
}
|
||||
|
||||
pub async fn get(&self, entity_id: &EntityId) -> Option<EntityEntry> {
|
||||
@@ -74,6 +77,10 @@ impl EntityRegistry {
|
||||
pub async fn len(&self) -> usize {
|
||||
self.entries.read().await.len()
|
||||
}
|
||||
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
self.entries.read().await.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EntityRegistry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Service registry stub.
|
||||
//! Concurrent service registry with panic-isolated direct dispatch.
|
||||
//!
|
||||
//! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public
|
||||
//! surface + a simple direct-dispatch `call` so downstream ADRs can
|
||||
@@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::event::Context;
|
||||
use crate::bus::EventBus;
|
||||
use crate::event::{Context, SystemEvent};
|
||||
|
||||
/// Service name within a domain. e.g. `light.turn_on` → domain
|
||||
/// `"light"`, service `"turn_on"`.
|
||||
@@ -78,12 +79,22 @@ where
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceRegistry {
|
||||
handlers: Arc<RwLock<HashMap<ServiceName, Arc<dyn ServiceHandler>>>>,
|
||||
bus: Option<EventBus>,
|
||||
}
|
||||
|
||||
impl ServiceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::new_inner(None)
|
||||
}
|
||||
|
||||
pub fn with_event_bus(bus: EventBus) -> Self {
|
||||
Self::new_inner(Some(bus))
|
||||
}
|
||||
|
||||
fn new_inner(bus: Option<EventBus>) -> Self {
|
||||
Self {
|
||||
handlers: Arc::new(RwLock::new(HashMap::new())),
|
||||
bus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +122,14 @@ impl ServiceRegistry {
|
||||
/// that drives the engine. Mirrors HA isolating service-handler
|
||||
/// exceptions.
|
||||
pub async fn call(&self, call: ServiceCall) -> Result<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 guard = self.handlers.read().await;
|
||||
guard.get(&call.name).cloned()
|
||||
|
||||
@@ -22,8 +22,9 @@ use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::bus::EventBus;
|
||||
use crate::entity::{EntityId, State};
|
||||
use crate::event::{Context, StateChangedEvent};
|
||||
use crate::event::{Context, StateChangedEvent, SystemEvent};
|
||||
|
||||
/// Broadcast channel capacity for state-changed events. 4,096 events
|
||||
/// at 20 Hz per entity covers ~3 minutes of backlog for a single hot
|
||||
@@ -40,15 +41,28 @@ pub struct StateMachine {
|
||||
struct StateMachineInner {
|
||||
states: DashMap<EntityId, Arc<State>>,
|
||||
tx: broadcast::Sender<StateChangedEvent>,
|
||||
bus: Option<EventBus>,
|
||||
}
|
||||
|
||||
impl StateMachine {
|
||||
pub fn new() -> Self {
|
||||
Self::new_inner(None)
|
||||
}
|
||||
|
||||
/// Create a state machine that also publishes every committed change on
|
||||
/// the shared HOMECORE system event bus. Standalone state machines retain
|
||||
/// their lightweight private broadcast channel via [`StateMachine::new`].
|
||||
pub fn with_event_bus(bus: EventBus) -> Self {
|
||||
Self::new_inner(Some(bus))
|
||||
}
|
||||
|
||||
fn new_inner(bus: Option<EventBus>) -> Self {
|
||||
let (tx, _) = broadcast::channel(STATE_CHANGED_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
inner: Arc::new(StateMachineInner {
|
||||
states: DashMap::with_capacity(256),
|
||||
tx,
|
||||
bus,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -135,7 +149,10 @@ impl StateMachine {
|
||||
fired_at: Utc::now(),
|
||||
};
|
||||
// err = no receivers; that's fine, write still committed.
|
||||
let _ = self.inner.tx.send(event);
|
||||
let _ = self.inner.tx.send(event.clone());
|
||||
if let Some(bus) = &self.inner.bus {
|
||||
bus.fire_system(SystemEvent::StateChanged(event));
|
||||
}
|
||||
}
|
||||
// `_guard` (and the shard lock) drops here, after the event is sent.
|
||||
next
|
||||
@@ -151,7 +168,10 @@ impl StateMachine {
|
||||
new_state: None,
|
||||
fired_at: Utc::now(),
|
||||
};
|
||||
let _ = self.inner.tx.send(event);
|
||||
let _ = self.inner.tx.send(event.clone());
|
||||
if let Some(bus) = &self.inner.bus {
|
||||
bus.fire_system(SystemEvent::StateChanged(event));
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
@@ -159,7 +179,11 @@ impl StateMachine {
|
||||
/// Snapshot all current states. Allocates a new Vec — useful for
|
||||
/// the REST GET /api/states path (ADR-130).
|
||||
pub fn all(&self) -> Vec<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.
|
||||
@@ -201,7 +225,12 @@ mod tests {
|
||||
async fn set_writes_and_fires() {
|
||||
let sm = StateMachine::new();
|
||||
let mut rx = sm.subscribe();
|
||||
sm.set(id("light.kitchen"), "on", serde_json::json!({"brightness": 200}), Context::new());
|
||||
sm.set(
|
||||
id("light.kitchen"),
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
Context::new(),
|
||||
);
|
||||
let evt = rx.recv().await.unwrap();
|
||||
assert_eq!(evt.entity_id.as_str(), "light.kitchen");
|
||||
assert!(evt.old_state.is_none());
|
||||
@@ -222,9 +251,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn attribute_only_change_fires_but_preserves_last_changed() {
|
||||
let sm = StateMachine::new();
|
||||
let s1 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "C"}), Context::new());
|
||||
let s1 = sm.set(
|
||||
id("sensor.t"),
|
||||
"20",
|
||||
serde_json::json!({"unit": "C"}),
|
||||
Context::new(),
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
let s2 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "F"}), Context::new());
|
||||
let s2 = sm.set(
|
||||
id("sensor.t"),
|
||||
"20",
|
||||
serde_json::json!({"unit": "F"}),
|
||||
Context::new(),
|
||||
);
|
||||
assert_eq!(s1.last_changed, s2.last_changed);
|
||||
assert!(s2.last_updated > s1.last_updated);
|
||||
}
|
||||
@@ -367,10 +406,7 @@ mod tests {
|
||||
drainer.join().unwrap();
|
||||
|
||||
let log = log.lock().unwrap();
|
||||
let dup = log
|
||||
.windows(2)
|
||||
.filter(|w| w[0] == w[1])
|
||||
.count();
|
||||
let dup = log.windows(2).filter(|w| w[0] == w[1]).count();
|
||||
assert_eq!(
|
||||
dup, 0,
|
||||
"{dup} consecutive fired state_changed events carried an \
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# HOMECORE capability status
|
||||
|
||||
This document describes the implemented runtime surface as of the current
|
||||
alpha. “Implemented” means wired into `homecore-server` and covered by tests;
|
||||
workspace libraries alone are not presented as server capabilities.
|
||||
|
||||
## Implemented
|
||||
|
||||
| Area | Runtime behavior |
|
||||
|---|---|
|
||||
| Core | Concurrent entity state machine, entity registry API, shared system/domain event bus, service registry, contexts |
|
||||
| Event flow | Committed state changes reach state subscribers and the shared event bus; service calls emit system events |
|
||||
| REST | API root, config, list/get/set/delete state, list/call service |
|
||||
| WebSocket | Auth handshake, ping, config/states/services queries, service calls, event subscribe/unsubscribe |
|
||||
| Backpressure | Per-connection WebSocket output is bounded to 256 messages; slow clients are disconnected from overflowing subscriptions |
|
||||
| Authentication | Token allowlist from `HOMECORE_TOKENS`; missing tokens fail startup unless insecure development mode is explicitly enabled |
|
||||
| Recorder | SQLite state history listener; broadcast lag is recovered by resynchronizing current state; optional ruvector semantic index |
|
||||
| Automation | State, numeric, event, and time triggers; optional YAML loading at server boot |
|
||||
| Assist | Authenticated `POST /api/intent/handle` with bounded regex recognition and five local handlers |
|
||||
| Built-in services | Real state mutation for `homeassistant`, `light`, and `switch` on/off/toggle; ping and state snapshot |
|
||||
| Migration | HA entity registry v1/minor 1–13 parsing and atomic, no-overwrite persistence into a HOMECORE storage directory |
|
||||
| Dashboard/BFF | Static UI, calibration proxy, rooms, COG list, appliance metrics, and typed unavailable responses for absent upstreams |
|
||||
|
||||
## Exact HA-style HTTP surface
|
||||
|
||||
- `GET /api/`
|
||||
- `GET /api/config`
|
||||
- `GET /api/states`
|
||||
- `GET|POST|DELETE /api/states/:entity_id`
|
||||
- `GET /api/services`
|
||||
- `POST /api/services/:domain/:service`
|
||||
- `GET /api/websocket`
|
||||
- `POST /api/intent/handle`
|
||||
|
||||
This is a compatible subset, not full Home Assistant parity.
|
||||
|
||||
## Explicitly deferred
|
||||
|
||||
- Loading native or Wasmtime plugins from `homecore-server`
|
||||
- A network HomeKit Accessory Protocol server, pairing, and mDNS advertisement
|
||||
- Device-registry persistence and full config-entry conversion
|
||||
- Full HA event/history/template/config/check endpoints
|
||||
- Restore-state on server startup
|
||||
- STT/TTS and satellite voice protocols
|
||||
- SEED/federation/witness/privacy upstream services when their daemons are absent
|
||||
- Claiming Home Assistant integration parity or production-scale performance
|
||||
|
||||
Unavailable BFF upstreams return typed `503 upstream_unavailable`. Unsupported
|
||||
services are not registered. Synthetic biometric/demo entities are opt-in and
|
||||
must not be interpreted as live sensing data.
|
||||
Reference in New Issue
Block a user