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 @@
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.1. Gerät zum Lernen auswählen
| Gerät | Lernstatus | Gelernte Handlungen | Letzte Vorhersage | Aktionen | |
|---|---|---|---|---|---|
| Gerät | Lernstatus | Freigabe | Gelernte Handlungen | Letzte Vorhersage | Aktionen |
| ${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 + ? `` + : ""} |