mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
fix(homecore): harden runtime and publish truthful capabilities (#1450)
This commit is contained in:
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user