From 937c79a19b4fed9c43c358c08b401b8f8cfcb3e9 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 18 Jun 2026 13:00:57 +0200 Subject: [PATCH] Batch unrouted HA state events --- addon/Dockerfile | 2 +- addon/config.yaml | 2 +- app/main.py | 57 ++++++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- tests/test_addon_config.py | 2 +- tests/test_future_api.py | 2 +- 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/addon/Dockerfile b/addon/Dockerfile index e22a924..e49bf11 100644 --- a/addon/Dockerfile +++ b/addon/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim WORKDIR /app -ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.6 +ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.7 RUN python -m pip install --no-cache-dir \ "http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz" diff --git a/addon/config.yaml b/addon/config.yaml index 14d139d..6c1d305 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Future -version: "2.0.0-alpha.6" +version: "2.0.0-alpha.7" slug: sillyhome_future description: Event-first SillyHome v2 test controller url: http://192.168.6.31:3000/Otto/sillyhome-future diff --git a/app/main.py b/app/main.py index 7a3867e..2728eee 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from app.core.models import ( BackupBundle, ControlProfile, ControlState, + EntityState, HandoffMode, LearningState, RuntimeState, @@ -49,7 +50,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Future API", description="SillyHome v2 event-core side project.", - version="2.0.0-alpha.6", + version="2.0.0-alpha.7", lifespan=lifespan, ) @@ -188,16 +189,34 @@ def _load_initial_ha_states(client: FutureHaClient) -> None: async def _ha_listener_loop(client: FutureHaClient) -> None: + routed_triggers: set[str] = set() + next_route_refresh = 0.0 + pending_unrouted: list[StateEvent] = [] + last_unrouted_flush = 0.0 while True: await asyncio.to_thread(_set_websocket_status, "connecting") try: async for event in client.listen_state_events(): + loop_time = asyncio.get_running_loop().time() + if loop_time >= next_route_refresh: + routed_triggers = await asyncio.to_thread(_routed_trigger_ids) + next_route_refresh = loop_time + 5 await asyncio.to_thread(_set_websocket_status, "connected") - await asyncio.to_thread( - event_core.process_state_event, - event, - execute=_execute_ha_decision, - ) + if event.entity_id in routed_triggers: + if pending_unrouted: + await asyncio.to_thread(_merge_unrouted_events, pending_unrouted) + pending_unrouted = [] + await asyncio.to_thread( + event_core.process_state_event, + event, + execute=_execute_ha_decision, + ) + else: + pending_unrouted.append(event) + if len(pending_unrouted) >= 100 or loop_time - last_unrouted_flush >= 2: + await asyncio.to_thread(_merge_unrouted_events, pending_unrouted) + pending_unrouted = [] + last_unrouted_flush = loop_time await asyncio.sleep(0) except asyncio.CancelledError: raise @@ -214,6 +233,32 @@ def _set_websocket_status(status: str) -> None: stores.save_runtime(runtime) +def _routed_trigger_ids() -> set[str]: + result: set[str] = set() + for profile in stores.learning().profiles.values(): + for pattern in profile.patterns: + if pattern.trigger_entity_id is not None: + result.add(pattern.trigger_entity_id) + return result + + +def _merge_unrouted_events(events: list[StateEvent]) -> None: + if not events: + return + runtime = stores.runtime() + for event in events: + runtime.entities[event.entity_id] = EntityState( + entity_id=event.entity_id, + domain=event.entity_id.split(".", 1)[0], + state=event.new_state, + changed_at=event.changed_at, + area_name=event.attributes.get("area_name"), + device_id=event.attributes.get("device_id"), + friendly_name=event.attributes.get("friendly_name"), + ) + stores.save_runtime(runtime) + + def _execute_ha_decision(decision: object) -> bool: if ha_client is None or not hasattr(decision, "actuator_entity_id"): return False diff --git a/pyproject.toml b/pyproject.toml index 10867f7..c97d191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-future" -version = "2.0.0-alpha.6" +version = "2.0.0-alpha.7" description = "SillyHome v2 event-core prototype" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_addon_config.py b/tests/test_addon_config.py index 9f0a6b8..ec16b47 100644 --- a/tests/test_addon_config.py +++ b/tests/test_addon_config.py @@ -9,7 +9,7 @@ def test_addon_config_declares_future_addon() -> None: config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8")) assert config["slug"] == "sillyhome_future" - assert config["version"] == "2.0.0-alpha.6" + assert config["version"] == "2.0.0-alpha.7" assert config["ingress"] is True assert config["ingress_port"] == 8099 assert config["homeassistant_api"] is True diff --git a/tests/test_future_api.py b/tests/test_future_api.py index d8abcf8..81fcc02 100644 --- a/tests/test_future_api.py +++ b/tests/test_future_api.py @@ -15,7 +15,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig assert stores is not None assert health.status_code == 200 - assert health.json()["version"] == "2.0.0-alpha.6" + assert health.json()["version"] == "2.0.0-alpha.7" assert backup.status_code == 200 assert restore.status_code == 200 assert restore.json() == {"status": "restored"}