diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e1075..ef0b191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.7.9 - 2026-06-15 +- Event-basierte Vorhersagen verwenden den frischen Sensorzustand direkt aus + dem Home-Assistant-WebSocket-Event, damit Kontextwechsel ohne REST-Race sofort + bewertet und geschaltet werden können +- Regressionstest stellt sicher, dass ein Türsensor-Event trotz veraltetem + HA-Snapshot direkt `light.turn_on` auslöst + ## 0.7.8 - 2026-06-15 - Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive- Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu diff --git a/addon/config.yaml b/addon/config.yaml index 878b0ba..43084cc 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.8" +version: "0.7.9" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/behavior/engine.py b/app/behavior/engine.py index e77e79c..e7641dc 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -187,7 +187,13 @@ class BehaviorEngine: results.append(record) return results - def evaluate(self, actuator_entity_id: str) -> ActuatorRecord: + def evaluate( + self, + actuator_entity_id: str, + *, + context_state_overrides: dict[str, str | None] | None = None, + context_changed_at_overrides: dict[str, datetime | None] | None = None, + ) -> ActuatorRecord: record = self._store.get(actuator_entity_id) now = datetime.now(timezone.utc) try: @@ -230,6 +236,22 @@ class BehaviorEngine: entity_id: entities[entity_id].last_changed for entity_id in current_context } + selected_context_ids = { + entity_id + for entity_id in ( + [ + record.assignment.selected_numeric_entity_id, + *record.assignment.selected_context_entity_ids, + ] + ) + if entity_id + } + for entity_id, state in (context_state_overrides or {}).items(): + if entity_id in selected_context_ids and state is not None: + current_context[entity_id] = state + for entity_id, changed_at in (context_changed_at_overrides or {}).items(): + if entity_id in current_context: + current_context_changed_at[entity_id] = changed_at or now prediction = predict_behavior( record.behavior.patterns, current_context=current_context, @@ -622,6 +644,8 @@ class BehaviorEngine: except Exception: logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id) return + event_state = _event_state(new_state) + event_changed_at = _event_changed_at(new_state) or datetime.now(timezone.utc) # Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen affected_actuators = [ record.actuator_entity_id @@ -633,11 +657,37 @@ class BehaviorEngine: ] for actuator_entity_id in affected_actuators: try: - self.evaluate(actuator_entity_id) + self.evaluate( + actuator_entity_id, + context_state_overrides={entity_id: event_state}, + context_changed_at_overrides={entity_id: event_changed_at}, + ) except Exception: logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id) +def _event_state(new_state: dict[str, object] | None) -> str | None: + if not isinstance(new_state, dict): + return None + state = new_state.get("state") + return state if isinstance(state, str) else None + + +def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None: + if not isinstance(new_state, dict): + return None + value = new_state.get("last_changed") or new_state.get("last_updated") + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + def predict_behavior( patterns: list[BehaviorPattern], *, diff --git a/app/main.py b/app/main.py index 848702e..5e63f39 100644 --- a/app/main.py +++ b/app/main.py @@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.7.8", + version="0.7.9", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/pyproject.toml b/pyproject.toml index 2eb8876..5c059ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.8" +version = "0.7.9" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index 01ad7a0..f0dae46 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -523,3 +523,67 @@ def test_prediction_ignores_stale_causal_context_state() -> None: min_support=1, window_minutes=30, ) is None + + +def test_state_change_uses_websocket_context_state_for_immediate_action( + tmp_path: Path, +) -> None: + now = datetime.now(timezone.utc).replace(microsecond=0) + settings = _settings(tmp_path) + store = ActuatorStore(settings.actuator_store) + record = store.configure("light.storage") + record = record.model_copy( + update={ + "assignment": record.assignment.model_copy( + update={ + "selected_context_entity_ids": ["binary_sensor.storage_door"], + } + ), + "behavior": record.behavior.model_copy( + update={ + "mode": BehaviorMode.ACTIVE, + "status": BehaviorStatus.TRAINED, + "activation_ready": True, + "patterns": [ + BehaviorPattern( + target_state="on", + minute_of_day=60, + weekday=0, + context_states={"binary_sensor.storage_door": "on"}, + trigger_entity_id="binary_sensor.storage_door", + trigger_from_state="off", + trigger_to_state="on", + source="automation", + weight=1.0, + observed_at=now - timedelta(days=days_ago), + ) + for days_ago in (3, 2, 1) + ], + } + ), + } + ) + store.upsert(record) + reader = FakeBehaviorReader( + entities=[ + HaEntitySummary(entity_id="light.storage", domain="light", state="off"), + HaEntitySummary( + entity_id="binary_sensor.storage_door", + domain="binary_sensor", + state="off", + last_changed=now - timedelta(minutes=5), + ), + ], + history=[], + logbook=[], + ) + engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings) + + engine.handle_state_change( + "binary_sensor.storage_door", + {"state": "on", "last_changed": now.isoformat()}, + ) + + assert reader.service_calls == [ + ("light", "turn_on", {"entity_id": "light.storage"}) + ]