diff --git a/.gitea/ISSUE_TEMPLATE/bug.md b/.gitea/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..cc79f15 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,33 @@ +--- +name: Fehler +about: Reproduzierbaren SillyHome-Fehler melden +title: "BUG: " +--- + +## Beobachtet + +Was ist tatsächlich passiert? + +## Erwartet + +Was sollte passieren? + +## Aktor und Kontext + +- Aktor: +- Trigger/Kontext: +- SillyHome-Modus: +- Passende HA-Automation und Zustand: + +## Nachweise + +- Version: +- Relevante Logs: +- `activation_reason`: +- `prediction.execution_reason`: + +## Reproduktion + +1. +2. +3. diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..227d675 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## Ziel + +Welches konkrete Verhalten ändert sich? + +## Umsetzung + +- + +## Sicherheit + +- Backup/Rollback: +- Auswirkung auf bestehende HA-Automationen: +- Shadow/Active-Verhalten: + +## Verifikation + +```bash +.venv/bin/pytest -q +.venv/bin/ruff check . +.venv/bin/mypy app backend tests +git diff --check +``` + +- Live-Health: +- Live-Aktor: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..960ae33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +Diese Datei ist die kurze Arbeitsanweisung für Menschen und kleine Coding-Modelle. + +## Reihenfolge + +1. `README.md` lesen. +2. Für Verhaltenslogik `docs/BEHAVIOR_ENGINE.md` lesen. +3. Für Fehler `docs/DEBUGGING.md` abarbeiten. +4. Für HA-Automationen `docs/CONTROL_HANDOFF.md` lesen. +5. Vor Release oder Live-Update `docs/OPERATIONS.md` vollständig abarbeiten. + +## Verbindliche Regeln + +- Erst Zustand und Logs prüfen, dann Ursache formulieren, dann ändern. +- Keine Annahme als Fakt darstellen. +- Vor Live-Änderungen Backup oder klaren Rollback-Punkt erstellen. +- Bestehende Nutzeränderungen nicht zurücksetzen. +- Kleine, fokussierte Änderungen mit passenden Tests. +- Eigene SillyHome-Schaltungen niemals als neues Nutzerverhalten lernen. +- Ein Aktor darf nicht unbeabsichtigt ohne Steuerung bleiben: + - SillyHome aktiv: passende HA-Automation darf pausiert sein. + - SillyHome Shadow: HA-Automation muss auf Wunsch fortgesetzt werden können. +- Keine Secrets in Code, Dokumentation, Commits oder Logs. + +## Pflichtprüfung + +```bash +.venv/bin/pytest -q +.venv/bin/ruff check . +.venv/bin/mypy app backend tests +git diff --check +``` + +## Versionsstellen + +Bei jedem Release dieselbe Version setzen: + +- `pyproject.toml` +- `addon/config.yaml` +- `app/main.py` +- `CHANGELOG.md` + +Danach prüfen: + +```bash +grep -R 'version.*0\\.7\\.0' -n pyproject.toml addon/config.yaml app/main.py +``` + +Die konkrete Zielversion im Befehl anpassen. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bd1fc56..1561fa4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,10 +11,13 @@ Autonomes Schalten wird separat pro Aktor freigegeben. - Lokal-first und datensparsam; keine Cloudpflicht. - Trennung von Datenintegration, Kontextzuordnung, Verhaltenslernen, Vorhersage und Aktorausführung. -- Logbook-basierte Herkunftserkennung; bekannte Automationen und eigene - Schaltungen werden nicht als Nutzerhandlungen trainiert. +- Logbook-basierte Herkunftserkennung; eindeutig erkannte HA-Automationen + zählen wie manuelle Bedienungen. Eigene SillyHome-Schaltungen werden nicht + zurückgelernt. - Ausführung nur für freigegebene, reversible Domains und Zustände sowie mit - Konfidenzschwelle und Cooldown. + Konfidenzschwelle und zustandsbezogenem Cooldown. +- Eindeutig passende HA-Automationen können bei einer SillyHome-Übernahme + pausiert und beim Rückfall in den Shadow-Modus wieder fortgesetzt werden. - Standardintegration über die lokale Home-Assistant-REST-API. - Persistenz als atomische lokale Modell- und Aktorartefakte. - Deployment als Home-Assistant-Add-on oder über Docker Compose. diff --git a/CHANGELOG.md b/CHANGELOG.md index 741ab25..df1542c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.7.0 - 2026-06-14 +- Freie Eingabe von Home-Assistant-Entitätsnamen mit Vorschlagsliste +- Freigabestatus und Blockadegrund sind in Übersicht und Details immer sichtbar +- Vorhersagen erklären konkret, warum sie ausgeführt oder nicht ausgeführt wurden +- Cooldown blockiert nur Wiederholungen desselben Zielzustands; Gegenaktionen + wie `Licht an` gefolgt von `Licht aus` bleiben sofort möglich +- Passende HA-Automationen werden aus ihren echten Konfigurationen erkannt und + können pausiert oder fortgesetzt werden +- Sichere Steuerungsübergabe: SillyHome kann übernehmen und passende + HA-Automationen pausieren; beim Stoppen können sie gezielt fortgesetzt werden +- Dashboard wird ohne Browser-Cache ausgeliefert +- Reproduzierbare Runbooks für Debugging, Berechnung, Entwicklung, Tests, + Release, Add-on-Update, Live-Verifikation und Rollback + ## 0.6.2 - 2026-06-14 - Eindeutig im Home-Assistant-Logbuch erkannte Automationen und Scripts zählen für Lernen und Freigabe gleichwertig wie manuelle Bedienungen diff --git a/README.md b/README.md index 9dc6a8c..2075e4e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # SillyHome Next -Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant. +SillyHome lernt aus Home Assistant, sagt Aktorhandlungen voraus und darf sie +nach einer ausdrücklichen Freigabe ausführen. + +## Schnell orientieren + +- Fehler finden: [`docs/DEBUGGING.md`](docs/DEBUGGING.md) +- Berechnung verstehen: [`docs/BEHAVIOR_ENGINE.md`](docs/BEHAVIOR_ENGINE.md) +- Steuerung übernehmen/zurückgeben: + [`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md) +- Entwickeln, testen, veröffentlichen und installieren: + [`docs/OPERATIONS.md`](docs/OPERATIONS.md) +- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) ## Reifegrad @@ -112,6 +123,9 @@ Lernentscheidungen erfolgen automatisch. erlaubte Zustände geschaltet. Eindeutig im HA-Logbuch erkannte Automationen und Scripts zählen dabei gleichwertig wie manuelle Bedienungen. Eigene Schaltungen von SillyHome werden nicht zurückgelernt. +7. Bei der Freigabe kann SillyHome passende HA-Automationen pausieren und die + Steuerung übernehmen. Beim Stoppen können diese Automationen gezielt wieder + fortgesetzt werden. Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups** eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte @@ -122,5 +136,5 @@ Teststand `v0.3.0` wurde als HA-Backup `7df0fca0` gesichert. ```bash pytest ruff check . -mypy +mypy app backend tests ``` diff --git a/addon/config.yaml b/addon/config.yaml index 68a07d6..a5050dd 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.6.2" +version: "0.7.0" 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/actuators/models.py b/app/actuators/models.py index 383dfb5..b1faee5 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -107,6 +107,7 @@ class BehaviorPrediction(BaseModel): reason: str matching_patterns: int = Field(default=0, ge=0) executed: bool = False + execution_reason: str = "Vorhersage wurde noch nicht ausgeführt." class ExecutionEvent(BaseModel): @@ -114,6 +115,13 @@ class ExecutionEvent(BaseModel): executed_at: datetime +class RelatedAutomation(BaseModel): + entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") + config_id: str = Field(min_length=1, max_length=120) + friendly_name: str = Field(min_length=1, max_length=200) + enabled: bool + + class BehaviorState(BaseModel): mode: BehaviorMode = BehaviorMode.SHADOW status: BehaviorStatus = BehaviorStatus.COLLECTING @@ -126,6 +134,10 @@ class BehaviorState(BaseModel): last_evaluated_at: datetime | None = None last_executed_at: datetime | None = None execution_events: list[ExecutionEvent] = Field(default_factory=list) + activation_ready: bool = False + activation_reason: str = "Noch nicht genügend Verhalten für eine Freigabe gelernt." + related_automations: list[RelatedAutomation] = Field(default_factory=list) + paused_automation_entity_ids: list[str] = Field(default_factory=list) reason: str = "Historische Aktorhandlungen werden analysiert." diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index d4b672e..de8bf47 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -9,6 +9,7 @@ from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.dependencies import get_ha_reader from app.ha.discovery import EntityRole +from app.ha.exceptions import HaClientError from app.ha.models import HaEntitySummary from app.ha.reader import HaReader @@ -22,6 +23,13 @@ class ConfigureActuatorRequest(BaseModel): class ActivationRequest(BaseModel): active: bool + pause_matching_automations: bool = False + restore_paused_automations: bool = False + + +class AutomationControlRequest(BaseModel): + automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") + enabled: bool @router.get("/discovery", response_model=list[HaEntitySummary]) @@ -96,13 +104,55 @@ def set_activation( request: Request, ) -> ActuatorRecord: try: - return _behavior(request).set_active(actuator_entity_id, active=payload.active) + return _behavior(request).set_active( + actuator_entity_id, + active=payload.active, + pause_matching_automations=payload.pause_matching_automations, + restore_paused_automations=payload.restore_paused_automations, + ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc +@router.post( + "/{actuator_entity_id}/related-automations/refresh", + response_model=ActuatorRecord, +) +def refresh_related_automations( + actuator_entity_id: str, + request: Request, +) -> ActuatorRecord: + try: + return _behavior(request).refresh_related_automations(actuator_entity_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (ValueError, HaClientError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post( + "/{actuator_entity_id}/related-automations/control", + response_model=ActuatorRecord, +) +def control_related_automation( + actuator_entity_id: str, + payload: AutomationControlRequest, + request: Request, +) -> ActuatorRecord: + try: + return _behavior(request).set_automation_enabled( + actuator_entity_id, + payload.automation_entity_id, + enabled=payload.enabled, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (ValueError, HaClientError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @router.get("/reconciliation/state", response_model=ReconciliationState) def get_reconciliation_state(request: Request) -> ReconciliationState: store = getattr(request.app.state, "actuator_store", None) diff --git a/app/behavior/engine.py b/app/behavior/engine.py index c37f784..b1cee52 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -12,6 +12,7 @@ from app.actuators.models import ( BehaviorState, BehaviorStatus, ExecutionEvent, + RelatedAutomation, ) from app.actuators.store import ActuatorStore from app.config import Settings @@ -71,6 +72,10 @@ class BehaviorEngine: record.behavior.model_copy( update={ "status": BehaviorStatus.COLLECTING, + "activation_ready": False, + "activation_reason": ( + "Freigabe gesperrt: Noch kein geeigneter Kontext erkannt." + ), "last_trained_at": now, "reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.", } @@ -105,6 +110,11 @@ class BehaviorEngine: "status": BehaviorStatus.COLLECTING, "sample_count": 0, "high_confidence_sample_count": 0, + "activation_ready": False, + "activation_reason": ( + "Freigabe gesperrt: Noch keine historischen " + "Aktorhandlungen gefunden." + ), "patterns": [], "last_trained_at": now, "reason": "Noch keine historischen Aktorhandlungen gefunden.", @@ -140,11 +150,26 @@ class BehaviorEngine: "benötigten Handlungen gelernt." ) ) + activation_ready = ( + status is BehaviorStatus.TRAINED + and trusted_actions >= self._settings.min_behavior_actions + ) + activation_reason = ( + "Freigabe bereit: Genügend eindeutig zugeordnete Handlungen gelernt." + if activation_ready + else ( + "Freigabe gesperrt: " + f"{max(0, self._settings.min_behavior_actions - trusted_actions)} " + "eindeutig zugeordnete Handlungen fehlen." + ) + ) behavior = record.behavior.model_copy( update={ "status": status, "sample_count": len(patterns), "high_confidence_sample_count": trusted_actions, + "activation_ready": activation_ready, + "activation_reason": activation_reason, "patterns": patterns[-_MAX_PATTERNS:], "last_trained_at": now, "reason": reason, @@ -215,6 +240,17 @@ class BehaviorEngine: causal_window_seconds=self._settings.prediction_interval_seconds * 2, timezone_name=self._settings.timezone, ) + if prediction is not None: + prediction = prediction.model_copy( + update={ + "execution_reason": self._prediction_execution_reason( + record, + actuator.state, + prediction, + now, + ) + } + ) behavior = record.behavior.model_copy( update={ "last_evaluated_at": now, @@ -231,7 +267,11 @@ class BehaviorEngine: and behavior.mode is BehaviorMode.ACTIVE and prediction.confidence >= self._settings.prediction_confidence and actuator.state != prediction.target_state - and self._cooldown_elapsed(behavior, now) + and self._cooldown_elapsed( + behavior, + now, + prediction.target_state, + ) ): domain = actuator_entity_id.split(".", 1)[0] service = service_for_state(domain, prediction.target_state) @@ -260,7 +300,14 @@ class BehaviorEngine: ) behavior = behavior.model_copy( update={ - "prediction": prediction.model_copy(update={"executed": True}), + "prediction": prediction.model_copy( + update={ + "executed": True, + "execution_reason": ( + f"Ausgeführt mit {prediction.confidence:.0%} Sicherheit." + ), + } + ), "last_executed_at": now, "execution_events": [ *behavior.execution_events, @@ -282,8 +329,71 @@ class BehaviorEngine: ) return self._save_behavior(record, behavior) - def set_active(self, actuator_entity_id: str, *, active: bool) -> ActuatorRecord: + def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord: record = self._store.get(actuator_entity_id) + related = [ + RelatedAutomation( + entity_id=item.entity_id, + config_id=item.config_id, + friendly_name=item.friendly_name, + enabled=item.enabled, + ) + for item in self._ha_reader.find_automations_for_entity( + actuator_entity_id + ) + ] + behavior = record.behavior.model_copy( + update={"related_automations": related} + ) + return self._save_behavior(record, behavior) + + def set_automation_enabled( + self, + actuator_entity_id: str, + automation_entity_id: str, + *, + enabled: bool, + ) -> ActuatorRecord: + record = self.refresh_related_automations(actuator_entity_id) + if automation_entity_id not in { + item.entity_id for item in record.behavior.related_automations + }: + raise ValueError( + "Die Automation ist diesem Aktor nicht eindeutig zugeordnet." + ) + self._ha_reader.call_service( + "automation", + "turn_on" if enabled else "turn_off", + {"entity_id": automation_entity_id}, + ) + related = [ + item.model_copy(update={"enabled": enabled}) + if item.entity_id == automation_entity_id + else item + for item in record.behavior.related_automations + ] + paused = [ + entity_id + for entity_id in record.behavior.paused_automation_entity_ids + if entity_id != automation_entity_id + ] + behavior = record.behavior.model_copy( + update={ + "related_automations": related, + "paused_automation_entity_ids": paused, + } + ) + return self._save_behavior(record, behavior) + + def set_active( + self, + actuator_entity_id: str, + *, + active: bool, + pause_matching_automations: bool = False, + restore_paused_automations: bool = False, + ) -> ActuatorRecord: + record = self.refresh_related_automations(actuator_entity_id) now = datetime.now(timezone.utc) if active: domain = actuator_entity_id.split(".", 1)[0] @@ -293,30 +403,138 @@ class BehaviorEngine: ) if record.behavior.status is not BehaviorStatus.TRAINED: raise ValueError("Das Verhaltensmodell hat noch nicht genügend Handlungen gelernt.") - if ( - record.behavior.high_confidence_sample_count - < self._settings.min_behavior_actions - ): - raise ValueError( - "Für die Freigabe fehlen noch eindeutig zugeordnete manuelle " - "oder automatisierte Handlungen." - ) + if not record.behavior.activation_ready: + raise ValueError(record.behavior.activation_reason) mode = BehaviorMode.ACTIVE approved_at = now - reason = "Autonomes Lernen und Schalten wurde ausdrücklich freigegeben." + behavior = record.behavior.model_copy( + update={ + "mode": mode, + "approved_at": approved_at, + "reason": ( + "Autonomes Lernen und Schalten wurde ausdrücklich freigegeben." + ), + } + ) + record = self._save_behavior(record, behavior) + if pause_matching_automations: + paused: list[str] = [] + try: + for automation in record.behavior.related_automations: + if not automation.enabled: + continue + self._ha_reader.call_service( + "automation", + "turn_off", + {"entity_id": automation.entity_id}, + ) + paused.append(automation.entity_id) + except (HaClientError, ValueError): + for entity_id in paused: + try: + self._ha_reader.call_service( + "automation", + "turn_on", + {"entity_id": entity_id}, + ) + except (HaClientError, ValueError): + logger.exception( + "Failed to restore automation %s after handoff error", + entity_id, + ) + rollback = record.behavior.model_copy( + update={ + "mode": BehaviorMode.SHADOW, + "approved_at": None, + "reason": ( + "Übernahme fehlgeschlagen; SillyHome bleibt im " + "Shadow-Modus." + ), + } + ) + self._save_behavior(record, rollback) + raise + related = [ + automation.model_copy(update={"enabled": False}) + if automation.entity_id in paused + else automation + for automation in record.behavior.related_automations + ] + behavior = record.behavior.model_copy( + update={ + "related_automations": related, + "paused_automation_entity_ids": paused, + "reason": ( + "SillyHome steuert aktiv; passende HA-Automationen " + "wurden pausiert." + ), + } + ) + return self._save_behavior(record, behavior) + return record else: + if restore_paused_automations: + for entity_id in record.behavior.paused_automation_entity_ids: + self._ha_reader.call_service( + "automation", + "turn_on", + {"entity_id": entity_id}, + ) mode = BehaviorMode.SHADOW approved_at = None - reason = "Shadow-Modus aktiv; Vorhersagen werden nicht ausgeführt." + reason = ( + "Shadow-Modus aktiv; pausierte HA-Automationen wurden fortgesetzt." + if restore_paused_automations + else "Shadow-Modus aktiv; Vorhersagen werden nicht ausgeführt." + ) behavior = record.behavior.model_copy( update={ "mode": mode, "approved_at": approved_at, + "related_automations": [ + automation.model_copy(update={"enabled": True}) + if ( + restore_paused_automations + and automation.entity_id + in record.behavior.paused_automation_entity_ids + ) + else automation + for automation in record.behavior.related_automations + ], + "paused_automation_entity_ids": ( + [] + if restore_paused_automations + else record.behavior.paused_automation_entity_ids + ), "reason": reason, } ) return self._save_behavior(record, behavior) + def _prediction_execution_reason( + self, + record: ActuatorRecord, + current_state: str | None, + prediction: BehaviorPrediction, + now: datetime, + ) -> str: + if record.behavior.mode is not BehaviorMode.ACTIVE: + return "Nicht ausgeführt: SillyHome ist im Shadow-Modus." + if prediction.confidence < self._settings.prediction_confidence: + return ( + "Nicht ausgeführt: Sicherheit liegt unter der " + f"Schaltschwelle von {self._settings.prediction_confidence:.0%}." + ) + if current_state == prediction.target_state: + return "Nicht ausgeführt: Zielzustand ist bereits erreicht." + if not self._cooldown_elapsed( + record.behavior, + now, + prediction.target_state, + ): + return "Nicht ausgeführt: Sicherheits-Cooldown ist noch aktiv." + return "Ausführung ist freigegeben." + def _build_patterns( self, *, @@ -362,10 +580,20 @@ class BehaviorEngine: ) return patterns - def _cooldown_elapsed(self, behavior: BehaviorState, now: datetime) -> bool: - return behavior.last_executed_at is None or ( - now - behavior.last_executed_at - ) >= timedelta(seconds=self._settings.execution_cooldown_seconds) + def _cooldown_elapsed( + self, + behavior: BehaviorState, + now: datetime, + target_state: str, + ) -> bool: + if behavior.last_executed_at is None: + return True + last_event = behavior.execution_events[-1] if behavior.execution_events else None + if last_event is not None and last_event.target_state != target_state: + return True + return (now - behavior.last_executed_at) >= timedelta( + seconds=self._settings.execution_cooldown_seconds + ) def _save_behavior( self, diff --git a/app/ha/client.py b/app/ha/client.py index c423685..ab92709 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -107,6 +107,18 @@ class HaClient: ) return payload + def get_automation_config(self, automation_id: str) -> dict[str, object]: + if not automation_id or len(automation_id) > 120: + raise ValueError("Ungültige Automation-ID.") + payload = self._get_json( + f"/api/config/automation/config/{quote(automation_id, safe='')}" + ) + if not isinstance(payload, dict): + raise HaUnexpectedPayloadError( + "Automation-Konfiguration hat ein unerwartetes Format." + ) + return payload + def call_service( self, domain: str, diff --git a/app/ha/models.py b/app/ha/models.py index 671aa19..0ded291 100644 --- a/app/ha/models.py +++ b/app/ha/models.py @@ -26,3 +26,10 @@ class HaEntitySummary(BaseModel): area_name: str | None = None device_id: str | None = None device_name: str | None = None + + +class HaAutomationSummary(BaseModel): + entity_id: str + config_id: str + friendly_name: str + enabled: bool diff --git a/app/ha/reader.py b/app/ha/reader.py index 3559eb0..653411c 100644 --- a/app/ha/reader.py +++ b/app/ha/reader.py @@ -1,7 +1,8 @@ from __future__ import annotations from collections.abc import Sequence -from datetime import datetime +from datetime import datetime, timedelta, timezone +from threading import RLock from typing import Any import logging @@ -17,7 +18,7 @@ from app.ha.history import ( normalize_logbook_payload, normalize_state_history_payload, ) -from app.ha.models import HaEntitySummary +from app.ha.models import HaAutomationSummary, HaEntitySummary logger = logging.getLogger(__name__) @@ -25,6 +26,11 @@ logger = logging.getLogger(__name__) class HaReader: def __init__(self, client: HaClient) -> None: self._client = client + self._automation_cache: list[ + tuple[HaAutomationSummary, dict[str, object]] + ] = [] + self._automation_cache_at: datetime | None = None + self._automation_cache_lock = RLock() def read_entities(self) -> Sequence[HaEntitySummary]: entities = self._client.list_entities() @@ -112,6 +118,79 @@ class HaReader: ) -> Sequence[object]: return self._client.call_service(domain, service, service_data) + def find_automations_for_entity( + self, + entity_id: str, + ) -> list[HaAutomationSummary]: + current_states = { + raw_entity_id: item.get("state") == "on" + for item in self._client.list_entities() + if isinstance((raw_entity_id := item.get("entity_id")), str) + and raw_entity_id.startswith("automation.") + } + matches = [ + summary.model_copy( + update={ + "enabled": current_states.get( + summary.entity_id, + summary.enabled, + ) + } + ) + for summary, config in self._read_automation_configs() + if _contains_exact_value(config, entity_id) + ] + return sorted(matches, key=lambda item: item.entity_id) + + def _read_automation_configs( + self, + ) -> list[tuple[HaAutomationSummary, dict[str, object]]]: + now = datetime.now(timezone.utc) + with self._automation_cache_lock: + if ( + self._automation_cache_at is not None + and now - self._automation_cache_at < timedelta(minutes=10) + ): + return list(self._automation_cache) + configs: list[tuple[HaAutomationSummary, dict[str, object]]] = [] + for item in self._client.list_entities(): + raw_entity_id = item.get("entity_id") + if not isinstance(raw_entity_id, str) or not raw_entity_id.startswith( + "automation." + ): + continue + attributes = item.get("attributes") + if not isinstance(attributes, dict): + continue + config_id = attributes.get("id") + if not isinstance(config_id, str) or not config_id: + continue + try: + config = self._client.get_automation_config(config_id) + except (HaClientError, ValueError) as exc: + logger.warning( + "Automation config unavailable for %s: %s", + raw_entity_id, + exc, + ) + continue + configs.append( + ( + HaAutomationSummary( + entity_id=raw_entity_id, + config_id=config_id, + friendly_name=str( + attributes.get("friendly_name") or raw_entity_id + ), + enabled=item.get("state") == "on", + ), + config, + ) + ) + self._automation_cache = configs + self._automation_cache_at = now + return list(configs) + def _optional_str(value: object) -> str | None: if value is None or value == "": @@ -127,3 +206,13 @@ def _optional_datetime(value: object) -> datetime | None: except ValueError: return None return parsed if parsed.tzinfo is not None else None + + +def _contains_exact_value(value: object, expected: str) -> bool: + if value == expected: + return True + if isinstance(value, dict): + return any(_contains_exact_value(item, expected) for item in value.values()) + if isinstance(value, list): + return any(_contains_exact_value(item, expected) for item in value) + return False diff --git a/app/main.py b/app/main.py index 79b455f..605dbf9 100644 --- a/app/main.py +++ b/app/main.py @@ -77,7 +77,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.5.2", + version="0.7.0", lifespan=lifespan, ) app.state.settings = load_settings() @@ -97,7 +97,10 @@ def health() -> dict[str, str]: @app.get("/") def root() -> FileResponse: - return FileResponse(STATIC_DIR / "index.html") + return FileResponse( + STATIC_DIR / "index.html", + headers={"Cache-Control": "no-store, max-age=0"}, + ) async def _periodic_reconciliation(app: FastAPI) -> None: diff --git a/app/static/index.html b/app/static/index.html index ac3ec03..79eeff6 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -21,7 +21,7 @@ .warn { color: #f3c969; } .bad { color: #ff8f8f; } label { display: block; margin: 9px 0 4px; color: #b9c9d6; } - select,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; } + select,input,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; } button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button.secondary { background: #37495c; } button.danger { background: #7b3434; } @@ -77,8 +77,9 @@

1. Gerät zum Lernen auswählen

Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.

- - + + +

Noch kein Aktor ausgewählt.

@@ -163,7 +164,7 @@ async function loadOverview() { } async function loadActuatorDiscovery() { - const select = document.getElementById("actuator-select"); + const options = document.getElementById("actuator-options"); try { const [available, configured] = await Promise.all([ api("v1/actuators/discovery"), @@ -171,16 +172,16 @@ async function loadActuatorDiscovery() { ]); const configuredIds = new Set(configured.map(record => record.actuator_entity_id)); const choices = available.filter(entity => !configuredIds.has(entity.entity_id)); - select.innerHTML = choices.length - ? choices.map(entity => ``).join("") - : ""; + options.innerHTML = choices.map(entity => + `` + ).join(""); } catch (error) { - select.innerHTML = ``; + options.innerHTML = ""; } } async function configureActuator() { - const actuatorId = document.getElementById("actuator-select").value; + const actuatorId = document.getElementById("actuator-input").value.trim(); const result = document.getElementById("actuator-config-result"); if (!actuatorId) return; result.textContent = "Kontext wird automatisch analysiert ..."; @@ -204,17 +205,23 @@ async function loadConfiguredActuators() { const rows = await api("v1/actuators"); box.innerHTML = rows.length ? ` - + ${rows.map(record => ` + @@ -229,7 +236,12 @@ async function showActuator(actuatorId, evaluationMessage = "") { currentActuatorId = actuatorId; const box = document.getElementById("actuator-detail"); try { - const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); + let record; + try { + record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"}); + } catch (_) { + record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); + } const contexts = [ record.assignment.selected_numeric_entity_id, ...record.assignment.selected_context_entity_ids, @@ -239,21 +251,26 @@ async function showActuator(actuatorId, evaluationMessage = "") { .map(candidate => `
  • ${escapeHtml(candidate.friendly_name || candidate.entity_id)}: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}
  • `) .join(""); const prediction = record.behavior.prediction; - const requiredTrustedActions = 3; const learnedAutomationActions = record.behavior.patterns.filter( pattern => pattern.source === "automation", ).length; - const missingTrustedActions = Math.max( - 0, - requiredTrustedActions - record.behavior.high_confidence_sample_count, - ); + const relatedAutomations = record.behavior.related_automations || []; const activationButton = record.behavior.mode === "active" - ? `` - : record.behavior.status === "trained" && missingTrustedActions === 0 - ? `` - : record.behavior.status === "trained" - ? `

    Freigabe noch gesperrt: ${missingTrustedActions} eindeutig zugeordnete manuelle oder automatisierte Handlung${missingTrustedActions === 1 ? "" : "en"} fehlen.

    ` - : "

    Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.

    "; + ? ` + ` + : record.behavior.activation_ready + ? ` + ` + : `

    ${escapeHtml(record.behavior.activation_reason)}

    `; + const automationControls = relatedAutomations.length + ? `` + : "

    Keine eindeutig passende HA-Automation gefunden.

    "; box.innerHTML = `
    @@ -272,6 +289,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {

    Davon erkannte HA-Automationen: ${learnedAutomationActions}

    Letztes Training: ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}

    Was noch passiert: ${escapeHtml(record.behavior.reason)}

    +

    Freigabestatus: ${escapeHtml(record.behavior.activation_reason)}

    ${activationButton}

    Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.

    @@ -280,8 +298,11 @@ async function showActuator(actuatorId, evaluationMessage = "") {

    Was SillyHome aktuell vorhersagt

    ${prediction - ? `

    ${escapeHtml(prediction.target_state)} mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${prediction.executed ? "Ausgeführt." : "Nicht ausgeführt."}

    ` + ? `

    ${escapeHtml(prediction.target_state)} mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${escapeHtml(prediction.execution_reason)}

    ` : "

    Aktuell ist kein gelerntes Handlungsmuster fällig.

    "} +

    Passende Home-Assistant-Automationen

    +

    Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.

    + ${automationControls}

    Welche Zusammenhänge automatisch verwendet werden

    ${evidence ? `` : "

    Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.

    "} `; @@ -309,15 +330,41 @@ async function evaluateActuator(actuatorId) { } } -async function setActivation(actuatorId, active) { +async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) { const question = active - ? `${actuatorId} wirklich für autonomes Lernen und Schalten freigeben?` - : `${actuatorId} wieder in den Shadow-Modus setzen?`; + ? pauseMatchingAutomations + ? `${actuatorId}: SillyHome aktivieren und passende HA-Automationen pausieren?` + : `${actuatorId}: SillyHome parallel zu den HA-Automationen aktivieren?` + : restorePausedAutomations + ? `${actuatorId}: SillyHome stoppen und pausierte HA-Automationen fortsetzen?` + : `${actuatorId}: SillyHome stoppen und HA-Automationen pausiert lassen?`; if (!confirm(question)) return; try { await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, { method: "POST", - body: JSON.stringify({active}), + body: JSON.stringify({ + active, + pause_matching_automations: pauseMatchingAutomations, + restore_paused_automations: restorePausedAutomations, + }), + }); + await loadConfiguredActuators(); + await showActuator(actuatorId); + } catch (error) { + alert(error.message); + } +} + +async function setRelatedAutomation(actuatorId, automationEntityId, enabled) { + const action = enabled ? "fortsetzen" : "pausieren"; + if (!confirm(`${automationEntityId} wirklich ${action}?`)) return; + try { + await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/control`, { + method: "POST", + body: JSON.stringify({ + automation_entity_id: automationEntityId, + enabled, + }), }); await loadConfiguredActuators(); await showActuator(actuatorId); diff --git a/docs/BEHAVIOR_ENGINE.md b/docs/BEHAVIOR_ENGINE.md new file mode 100644 index 0000000..085d058 --- /dev/null +++ b/docs/BEHAVIOR_ENGINE.md @@ -0,0 +1,73 @@ +# Verhaltensmodell und Berechnung + +## Datenfluss + +1. Nutzer wählt einen Aktor. +2. `ActuatorReconciliationService` ordnet Kontext-Entities zu. +3. `BehaviorEngine.train()` liest Aktor- und Kontexthistorie. +4. Aktor-Zustandswechsel werden als `BehaviorPattern` gespeichert. +5. `BehaviorEngine.evaluate()` vergleicht aktuelle Zustände mit den Mustern. +6. Shadow zeigt nur die Vorhersage. Active darf sie ausführen. + +## Herkunft und Gewicht + +- HA-Benutzer: `source=user`, Gewicht `1.0` +- eindeutig erkannte HA-Automation oder Script: `source=automation`, Gewicht `1.0` +- physisch oder unbekannt: `source=physical_or_unknown`, Gewicht `0.7` +- eigene SillyHome-Ausführung: wird verworfen + +Manuelle und eindeutig automatisierte Handlungen zählen für die Freigabe. + +## Kausale Muster + +Wechselt ein Kontextsensor höchstens drei Sekunden vor der Aktorhandlung, wird +der Wechsel gespeichert: + +```text +binary_sensor.tuer: off -> on +light.raum: off -> on +``` + +Eine kausale Vorhersage gilt nur, wenn derselbe Kontextzustand frisch ist. Das +Standardfenster ist zweimal `SILLYHOME_PREDICTION_INTERVAL_SECONDS`. + +## Nicht-kausale Bewertung + +Für Muster ohne frischen Trigger: + +```text +score = weight * ( + 0.45 * time_score + + 0.45 * context_score + + 0.10 * weekday_score +) +``` + +Die Confidence ist der mittlere Score, begrenzt durch die Mindestunterstützung: + +```text +confidence = mean(scores) * min(1, support / min_behavior_actions) +``` + +## Ausführungsbedingungen + +Eine Vorhersage wird nur ausgeführt, wenn alle Bedingungen erfüllt sind: + +- Betriebsart `active` +- Confidence mindestens `SILLYHOME_PREDICTION_CONFIDENCE` +- Zielzustand ist noch nicht erreicht +- Domain und Zustand sind erlaubt +- Cooldown erlaubt die Aktion + +Der Cooldown sperrt nur eine schnelle Wiederholung desselben Zielzustands. +Eine Gegenaktion, beispielsweise `on` gefolgt von `off`, bleibt sofort erlaubt. + +## Freigabe + +`activation_ready=true`, wenn: + +- Verhaltensstatus `trained` +- mindestens `SILLYHOME_MIN_BEHAVIOR_ACTIONS` eindeutig zugeordnete manuelle + oder automatisierte Handlungen vorhanden sind + +Die UI zeigt `activation_reason` immer an. diff --git a/docs/CONTROL_HANDOFF.md b/docs/CONTROL_HANDOFF.md new file mode 100644 index 0000000..b17ddff --- /dev/null +++ b/docs/CONTROL_HANDOFF.md @@ -0,0 +1,47 @@ +# Übergabe zwischen SillyHome und HA-Automationen + +## Erkennung + +SillyHome liest aktive `automation.*`-Entities, lädt deren Konfiguration über +die Home-Assistant-API und sucht darin nach der exakten Aktor-Entity-ID. +Namensähnlichkeit allein reicht nicht. + +## Betriebsarten + +### Shadow + +- SillyHome lernt und prognostiziert. +- SillyHome schaltet nicht. +- HA-Automationen können normal weiterlaufen. + +### Active parallel + +- SillyHome darf schalten. +- Passende HA-Automationen bleiben aktiv. +- Diese Betriebsart kann doppelte Auslöser verursachen und ist nur für Tests. + +### Active mit Übernahme + +- SillyHome wird zuerst aktiviert. +- Danach werden aktuell aktive, passend erkannte HA-Automationen pausiert. +- Nur erfolgreich pausierte Automationen werden für eine spätere + Wiederherstellung gespeichert. +- Scheitert die Pause, fällt SillyHome auf Shadow zurück und stellt bereits + pausierte Automationen wieder her. + +## Stoppen + +Zwei bewusste Optionen: + +- SillyHome stoppen und pausierte HA-Automationen fortsetzen. +- SillyHome stoppen und HA-Automationen pausiert lassen. + +Einzelne passende Automationen können im Dashboard jederzeit pausiert oder +fortgesetzt werden. + +In Home Assistant bedeutet: + +```text +automation.turn_off = pausieren/deaktivieren +automation.turn_on = fortsetzen/aktivieren +``` diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md new file mode 100644 index 0000000..649f874 --- /dev/null +++ b/docs/DEBUGGING.md @@ -0,0 +1,63 @@ +# Debugging + +## Vorhersage korrekt, aber keine Ausführung + +1. Aktor-Details öffnen. +2. `Betriebsart` prüfen. +3. `Freigabestatus` prüfen. +4. Text hinter der Vorhersage lesen. `execution_reason` nennt exakt: + - Shadow-Modus + - Confidence unter Schaltschwelle + - Zielzustand bereits erreicht + - Cooldown aktiv + - ausgeführt +5. Live-Zustand des Aktors und Triggers in HA prüfen. +6. Add-on-Logs prüfen. + +## Weder SillyHome noch HA-Automation schaltet + +1. SillyHome-Modus prüfen. +2. Unter `Passende Home-Assistant-Automationen` den Zustand prüfen. +3. Bei Shadow mindestens eine gewünschte HA-Automation fortsetzen. +4. Bei Active mit Übernahme müssen die passenden HA-Automationen pausiert sein. + +## Freigabe fehlt + +Die UI zeigt den Grund immer als `activation_reason`. + +Prüfen: + +```text +behavior.status +behavior.sample_count +behavior.high_confidence_sample_count +behavior.activation_ready +behavior.activation_reason +``` + +## Entität fehlt in der Liste + +Den vollständigen Entitätsnamen direkt eingeben. Der Server akzeptiert nur +existierende, unterstützte Aktoren. Ein unbekannter Name liefert `404`. + +## Standarddiagnose lokal + +```bash +.venv/bin/pytest tests/behavior/test_engine.py -q +.venv/bin/pytest tests/api/test_actuators.py -q +.venv/bin/ruff check app tests +.venv/bin/mypy app backend tests +``` + +## Standarddiagnose im HA-Add-on + +```bash +ha apps info 58adbe1e_sillyhome_next +ha apps logs 58adbe1e_sillyhome_next +``` + +Health aus einem Add-on mit Zugriff auf das interne Netz: + +```bash +wget -qO- http://58adbe1e-sillyhome-next:8000/health +``` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..e1e02d6 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,87 @@ +# Entwicklung, Release und Betrieb + +## Lokales Setup + +```bash +python3 -m venv .venv +.venv/bin/pip install -e '.[dev]' +cp .env.example .env +.venv/bin/uvicorn app.main:app --reload +``` + +`SILLYHOME_HA_URL` und `SILLYHOME_HA_TOKEN` nur lokal in `.env` setzen. + +## Qualitätsprüfung + +```bash +.venv/bin/pytest -q +.venv/bin/ruff check . +.venv/bin/mypy app backend tests +git diff --check +``` + +## Release + +1. Version in allen vier Stellen ändern: + `pyproject.toml`, `addon/config.yaml`, `app/main.py`, `CHANGELOG.md`. +2. Qualitätsprüfung ausführen. +3. Feature-Branch committen und pushen. +4. Pull Request nach `main` erstellen und mergen. +5. Annotiertes Tag auf dem Merge-Commit erstellen. +6. Gitea-Release aus demselben Tag erstellen. + +Beispiel: + +```bash +git tag -a v0.7.0 -m 'SillyHome Next 0.7.0' +git push origin v0.7.0 +``` + +## Home-Assistant-Update + +Vorher Teil-Backup des Add-ons erstellen. Danach: + +```bash +ha store reload +ha apps info 58adbe1e_sillyhome_next +ha apps update 58adbe1e_sillyhome_next +ha apps info 58adbe1e_sillyhome_next +ha apps logs 58adbe1e_sillyhome_next +``` + +Kein Home-Assistant-Neustart ist erforderlich. + +## Live-Verifikation + +Pflicht: + +```bash +wget -qO- http://58adbe1e-sillyhome-next:8000/health +wget -qO- http://58adbe1e-sillyhome-next:8000/v1/actuators +``` + +Für einen Aktor prüfen: + +- `behavior.mode` +- `behavior.activation_ready` +- `behavior.activation_reason` +- `behavior.related_automations` +- `behavior.paused_automation_entity_ids` +- `behavior.prediction.execution_reason` + +Bei einer Übernahme testen: + +1. Passende HA-Automation ist vorher `on`. +2. SillyHome übernimmt. +3. SillyHome ist `active`. +4. Passende HA-Automation ist `off`. +5. Trigger erzeugt erwartete Aktoraktion. +6. Gegenaktion wird trotz Cooldown ausgeführt. +7. SillyHome stoppen und Automationen fortsetzen. +8. SillyHome ist `shadow`, HA-Automation wieder `on`. + +## Rollback + +Bevorzugt das vor dem Update erstellte HA-Teil-Backup wiederherstellen. +Alternativ vorherige Git-Version in `addon/config.yaml` veröffentlichen und das +Add-on erneut aktualisieren. diff --git a/docs/ml_api.md b/docs/ml_api.md index c416dfd..a5e2816 100644 --- a/docs/ml_api.md +++ b/docs/ml_api.md @@ -205,12 +205,44 @@ Shadow-Modus wird niemals geschaltet. ### `POST /v1/actuators/{actuator_entity_id}/activation` ```json -{"active": true} +{ + "active": true, + "pause_matching_automations": true, + "restore_paused_automations": false +} ``` Aktiviert autonomes Schalten erst nach ausreichendem Training und nur für -erlaubte Aktor-Domains. Mit `false` wird der Aktor sofort wieder in den -Shadow-Modus versetzt. +erlaubte Aktor-Domains. `pause_matching_automations` pausiert eindeutig +zugeordnete HA-Automationen bei der Übernahme. + +Beim Stoppen: + +```json +{ + "active": false, + "pause_matching_automations": false, + "restore_paused_automations": true +} +``` + +Damit wird der Aktor in den Shadow-Modus versetzt und zuvor von SillyHome +pausierte Automationen werden fortgesetzt. + +### `POST /v1/actuators/{actuator_entity_id}/related-automations/refresh` + +Liest passende HA-Automationen anhand ihrer echten Konfiguration neu ein. + +### `POST /v1/actuators/{actuator_entity_id}/related-automations/control` + +```json +{ + "automation_entity_id": "automation.licht_abstellkammer", + "enabled": false +} +``` + +Pausiert oder aktiviert eine eindeutig diesem Aktor zugeordnete Automation. ## Betrieb diff --git a/pyproject.toml b/pyproject.toml index 6cae252..26e6177 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.5.2" +version = "0.7.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index a1eac2b..236f79d 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -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 = [ diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index 0d82415..01ad7a0 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -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"), [ diff --git a/tests/ha/test_ha_reader.py b/tests/ha/test_ha_reader.py index 752ac12..2ceef09 100644 --- a/tests/ha/test_ha_reader.py +++ b/tests/ha/test_ha_reader.py @@ -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 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 990e4b8..80ae1dc 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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
    GerätLernstatusGelernte HandlungenLetzte VorhersageAktionen
    GerätLernstatusFreigabeGelernte HandlungenLetzte VorhersageAktionen
    ${escapeHtml(record.actuator_entity_id)} ${escapeHtml(behaviorLabel(record))}${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)} ${record.behavior.sample_count} ${record.behavior.prediction ? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)` : "-"} + ${record.behavior.mode === "active" + ? `` + : record.behavior.activation_ready + ? `` + : ""}