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

This commit is contained in:
rUv
2026-07-27 10:41:43 -07:00
committed by GitHub
parent 13015c9d36
commit 581af67fbc
25 changed files with 1218 additions and 469 deletions
+17 -3
View File
@@ -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,
+67 -5
View File
@@ -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:?}"),
}
}
}
+8 -1
View File
@@ -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 {
+21 -2
View File
@@ -1,4 +1,4 @@
//! Service registry stub.
//! Concurrent service registry with panic-isolated direct dispatch.
//!
//! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public
//! 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()
+47 -11
View File
@@ -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 \