CONTROL-001: add safe HA automation handoff
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-14 16:21:57 +02:00
parent 77f328c4a8
commit b3cf68eade
25 changed files with 1083 additions and 69 deletions

View File

@@ -17,7 +17,7 @@ from app.ha.history import (
NumericHistoryPoint,
StateHistorySeries,
)
from app.ha.models import HaEntitySummary
from app.ha.models import HaAutomationSummary, HaEntitySummary
from app.ha.reader import HaReader
from app.main import app
from app.ml.registry.model_registry import ModelRegistry
@@ -84,6 +84,12 @@ class FakeHaReader(HaReader):
) -> list[object]:
return []
def find_automations_for_entity(
self,
entity_id: str,
) -> list[HaAutomationSummary]:
return []
def _install_service(tmp_path: Path) -> None:
entities = [

View File

@@ -5,7 +5,13 @@ from pathlib import Path
import pytest
from app.actuators.models import BehaviorMode, BehaviorPattern, BehaviorStatus
from app.actuators.models import (
BehaviorMode,
BehaviorPattern,
BehaviorState,
BehaviorStatus,
ExecutionEvent,
)
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
from app.config import Settings
@@ -14,7 +20,7 @@ from app.ha.history import (
StateHistoryPoint,
StateHistorySeries,
)
from app.ha.models import HaEntitySummary
from app.ha.models import HaAutomationSummary, HaEntitySummary
from app.ha.reader import HaReader
@@ -30,6 +36,7 @@ class FakeBehaviorReader(HaReader):
self.history = history
self.logbook = logbook
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
self.automations: list[HaAutomationSummary] = []
def read_entities(self) -> list[HaEntitySummary]:
return list(self.entities)
@@ -59,6 +66,12 @@ class FakeBehaviorReader(HaReader):
self.service_calls.append((domain, service, service_data))
return []
def find_automations_for_entity(
self,
entity_id: str,
) -> list[HaAutomationSummary]:
return list(self.automations)
def _settings(tmp_path: Path) -> Settings:
return Settings(
@@ -336,10 +349,91 @@ def test_active_mode_requires_trusted_manual_or_automation_actions(tmp_path: Pat
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
with pytest.raises(ValueError, match="manuelle oder automatisierte"):
with pytest.raises(ValueError, match="Freigabe"):
engine.set_active("light.office", active=True)
def test_control_handoff_pauses_and_restores_matching_automation(
tmp_path: Path,
) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"status": BehaviorStatus.TRAINED,
"sample_count": 3,
"high_confidence_sample_count": 3,
"activation_ready": True,
"activation_reason": "Freigabe bereit.",
}
)
}
)
)
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
reader.automations = [
HaAutomationSummary(
entity_id="automation.storage_light",
config_id="123",
friendly_name="Storage light",
enabled=True,
)
]
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
active = engine.set_active(
"light.storage",
active=True,
pause_matching_automations=True,
)
shadow = engine.set_active(
"light.storage",
active=False,
restore_paused_automations=True,
)
assert active.behavior.mode is BehaviorMode.ACTIVE
assert active.behavior.paused_automation_entity_ids == [
"automation.storage_light"
]
assert shadow.behavior.mode is BehaviorMode.SHADOW
assert shadow.behavior.paused_automation_entity_ids == []
assert reader.service_calls == [
(
"automation",
"turn_off",
{"entity_id": "automation.storage_light"},
),
(
"automation",
"turn_on",
{"entity_id": "automation.storage_light"},
),
]
def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
now = datetime.now(timezone.utc)
behavior = BehaviorState(
mode=BehaviorMode.ACTIVE,
last_executed_at=now - timedelta(seconds=5),
execution_events=[
ExecutionEvent(target_state="on", executed_at=now - timedelta(seconds=5))
],
)
assert engine._cooldown_elapsed(behavior, now, "off") is True
assert engine._cooldown_elapsed(behavior, now, "on") is False
@pytest.mark.parametrize(
("domain", "state", "service"),
[

View File

@@ -125,3 +125,28 @@ def test_ha_reader_normalizes_state_history_and_logbook() -> None:
assert history[0].points[0].state == "21.5"
assert logbook[0].context_user_id == "user-1"
def test_ha_reader_finds_automation_that_targets_entity() -> None:
client = FakeHaClient()
client.list_entities = lambda: [ # type: ignore[method-assign]
{
"entity_id": "automation.storage_light",
"state": "on",
"attributes": {
"id": "123",
"friendly_name": "Storage light",
},
}
]
client.get_automation_config = lambda automation_id: { # type: ignore[method-assign]
"id": automation_id,
"target": {"entity_id": "light.storage"},
}
reader = HaReader(client)
matches = reader.find_automations_for_entity("light.storage")
assert len(matches) == 1
assert matches[0].entity_id == "automation.storage_light"
assert matches[0].enabled is True

View File

@@ -11,19 +11,19 @@ def test_dashboard_is_served_at_root() -> None:
assert "SillyHome Next" in response.text
assert "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
assert "Freigabe noch gesperrt" in response.text
assert "manuelle oder automatisierte Handlung" in response.text
assert "Freigabestatus" in response.text
assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" in response.text
assert "Davon erkannte HA-Automationen" in response.text
assert "Aktuelle Situation auswerten" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text
assert (
'record.behavior.status === "trained" && missingTrustedActions === 0'
in response.text
)
assert "record.behavior.activation_ready" in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text