Merge pull request 'v0.7.0: sichere Steuerungsübergabe und klare Bedienung' (#40) from feature/control-handoff-v0.7.0 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit was merged in pull request #40.
This commit is contained in:
2026-06-14 16:22:18 +02:00
25 changed files with 1083 additions and 69 deletions

View File

@@ -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.

View File

@@ -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:

50
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -11,10 +11,13 @@ Autonomes Schalten wird separat pro Aktor freigegeben.
- Lokal-first und datensparsam; keine Cloudpflicht. - Lokal-first und datensparsam; keine Cloudpflicht.
- Trennung von Datenintegration, Kontextzuordnung, Verhaltenslernen, - Trennung von Datenintegration, Kontextzuordnung, Verhaltenslernen,
Vorhersage und Aktorausführung. Vorhersage und Aktorausführung.
- Logbook-basierte Herkunftserkennung; bekannte Automationen und eigene - Logbook-basierte Herkunftserkennung; eindeutig erkannte HA-Automationen
Schaltungen werden nicht als Nutzerhandlungen trainiert. 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 - 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. - Standardintegration über die lokale Home-Assistant-REST-API.
- Persistenz als atomische lokale Modell- und Aktorartefakte. - Persistenz als atomische lokale Modell- und Aktorartefakte.
- Deployment als Home-Assistant-Add-on oder über Docker Compose. - Deployment als Home-Assistant-Add-on oder über Docker Compose.

View File

@@ -1,5 +1,19 @@
# Changelog # 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 ## 0.6.2 - 2026-06-14
- Eindeutig im Home-Assistant-Logbuch erkannte Automationen und Scripts zählen für - Eindeutig im Home-Assistant-Logbuch erkannte Automationen und Scripts zählen für
Lernen und Freigabe gleichwertig wie manuelle Bedienungen Lernen und Freigabe gleichwertig wie manuelle Bedienungen

View File

@@ -1,6 +1,17 @@
# SillyHome Next # 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 ## Reifegrad
@@ -112,6 +123,9 @@ Lernentscheidungen erfolgen automatisch.
erlaubte Zustände geschaltet. Eindeutig im HA-Logbuch erkannte Automationen erlaubte Zustände geschaltet. Eindeutig im HA-Logbuch erkannte Automationen
und Scripts zählen dabei gleichwertig wie manuelle Bedienungen. Eigene und Scripts zählen dabei gleichwertig wie manuelle Bedienungen. Eigene
Schaltungen von SillyHome werden nicht zurückgelernt. 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** Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte 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 ```bash
pytest pytest
ruff check . ruff check .
mypy mypy app backend tests
``` ```

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.6.2" version: "0.7.0"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -107,6 +107,7 @@ class BehaviorPrediction(BaseModel):
reason: str reason: str
matching_patterns: int = Field(default=0, ge=0) matching_patterns: int = Field(default=0, ge=0)
executed: bool = False executed: bool = False
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
class ExecutionEvent(BaseModel): class ExecutionEvent(BaseModel):
@@ -114,6 +115,13 @@ class ExecutionEvent(BaseModel):
executed_at: datetime 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): class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING status: BehaviorStatus = BehaviorStatus.COLLECTING
@@ -126,6 +134,10 @@ class BehaviorState(BaseModel):
last_evaluated_at: datetime | None = None last_evaluated_at: datetime | None = None
last_executed_at: datetime | None = None last_executed_at: datetime | None = None
execution_events: list[ExecutionEvent] = Field(default_factory=list) 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." reason: str = "Historische Aktorhandlungen werden analysiert."

View File

@@ -9,6 +9,7 @@ from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole from app.ha.discovery import EntityRole
from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -22,6 +23,13 @@ class ConfigureActuatorRequest(BaseModel):
class ActivationRequest(BaseModel): class ActivationRequest(BaseModel):
active: bool 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]) @router.get("/discovery", response_model=list[HaEntitySummary])
@@ -96,13 +104,55 @@ def set_activation(
request: Request, request: Request,
) -> ActuatorRecord: ) -> ActuatorRecord:
try: 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: except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from 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) @router.get("/reconciliation/state", response_model=ReconciliationState)
def get_reconciliation_state(request: Request) -> ReconciliationState: def get_reconciliation_state(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)

View File

@@ -12,6 +12,7 @@ from app.actuators.models import (
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
ExecutionEvent, ExecutionEvent,
RelatedAutomation,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.config import Settings from app.config import Settings
@@ -71,6 +72,10 @@ class BehaviorEngine:
record.behavior.model_copy( record.behavior.model_copy(
update={ update={
"status": BehaviorStatus.COLLECTING, "status": BehaviorStatus.COLLECTING,
"activation_ready": False,
"activation_reason": (
"Freigabe gesperrt: Noch kein geeigneter Kontext erkannt."
),
"last_trained_at": now, "last_trained_at": now,
"reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.", "reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.",
} }
@@ -105,6 +110,11 @@ class BehaviorEngine:
"status": BehaviorStatus.COLLECTING, "status": BehaviorStatus.COLLECTING,
"sample_count": 0, "sample_count": 0,
"high_confidence_sample_count": 0, "high_confidence_sample_count": 0,
"activation_ready": False,
"activation_reason": (
"Freigabe gesperrt: Noch keine historischen "
"Aktorhandlungen gefunden."
),
"patterns": [], "patterns": [],
"last_trained_at": now, "last_trained_at": now,
"reason": "Noch keine historischen Aktorhandlungen gefunden.", "reason": "Noch keine historischen Aktorhandlungen gefunden.",
@@ -140,11 +150,26 @@ class BehaviorEngine:
"benötigten Handlungen gelernt." "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( behavior = record.behavior.model_copy(
update={ update={
"status": status, "status": status,
"sample_count": len(patterns), "sample_count": len(patterns),
"high_confidence_sample_count": trusted_actions, "high_confidence_sample_count": trusted_actions,
"activation_ready": activation_ready,
"activation_reason": activation_reason,
"patterns": patterns[-_MAX_PATTERNS:], "patterns": patterns[-_MAX_PATTERNS:],
"last_trained_at": now, "last_trained_at": now,
"reason": reason, "reason": reason,
@@ -215,6 +240,17 @@ class BehaviorEngine:
causal_window_seconds=self._settings.prediction_interval_seconds * 2, causal_window_seconds=self._settings.prediction_interval_seconds * 2,
timezone_name=self._settings.timezone, 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( behavior = record.behavior.model_copy(
update={ update={
"last_evaluated_at": now, "last_evaluated_at": now,
@@ -231,7 +267,11 @@ class BehaviorEngine:
and behavior.mode is BehaviorMode.ACTIVE and behavior.mode is BehaviorMode.ACTIVE
and prediction.confidence >= self._settings.prediction_confidence and prediction.confidence >= self._settings.prediction_confidence
and actuator.state != prediction.target_state 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] domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state) service = service_for_state(domain, prediction.target_state)
@@ -260,7 +300,14 @@ class BehaviorEngine:
) )
behavior = behavior.model_copy( behavior = behavior.model_copy(
update={ 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, "last_executed_at": now,
"execution_events": [ "execution_events": [
*behavior.execution_events, *behavior.execution_events,
@@ -282,8 +329,71 @@ class BehaviorEngine:
) )
return self._save_behavior(record, behavior) 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) 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) now = datetime.now(timezone.utc)
if active: if active:
domain = actuator_entity_id.split(".", 1)[0] domain = actuator_entity_id.split(".", 1)[0]
@@ -293,30 +403,138 @@ class BehaviorEngine:
) )
if record.behavior.status is not BehaviorStatus.TRAINED: if record.behavior.status is not BehaviorStatus.TRAINED:
raise ValueError("Das Verhaltensmodell hat noch nicht genügend Handlungen gelernt.") raise ValueError("Das Verhaltensmodell hat noch nicht genügend Handlungen gelernt.")
if ( if not record.behavior.activation_ready:
record.behavior.high_confidence_sample_count raise ValueError(record.behavior.activation_reason)
< self._settings.min_behavior_actions
):
raise ValueError(
"Für die Freigabe fehlen noch eindeutig zugeordnete manuelle "
"oder automatisierte Handlungen."
)
mode = BehaviorMode.ACTIVE mode = BehaviorMode.ACTIVE
approved_at = now 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: 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 mode = BehaviorMode.SHADOW
approved_at = None 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( behavior = record.behavior.model_copy(
update={ update={
"mode": mode, "mode": mode,
"approved_at": approved_at, "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, "reason": reason,
} }
) )
return self._save_behavior(record, behavior) 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( def _build_patterns(
self, self,
*, *,
@@ -362,10 +580,20 @@ class BehaviorEngine:
) )
return patterns return patterns
def _cooldown_elapsed(self, behavior: BehaviorState, now: datetime) -> bool: def _cooldown_elapsed(
return behavior.last_executed_at is None or ( self,
now - behavior.last_executed_at behavior: BehaviorState,
) >= timedelta(seconds=self._settings.execution_cooldown_seconds) 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( def _save_behavior(
self, self,

View File

@@ -107,6 +107,18 @@ class HaClient:
) )
return payload 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( def call_service(
self, self,
domain: str, domain: str,

View File

@@ -26,3 +26,10 @@ class HaEntitySummary(BaseModel):
area_name: str | None = None area_name: str | None = None
device_id: str | None = None device_id: str | None = None
device_name: str | None = None device_name: str | None = None
class HaAutomationSummary(BaseModel):
entity_id: str
config_id: str
friendly_name: str
enabled: bool

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import datetime, timedelta, timezone
from threading import RLock
from typing import Any from typing import Any
import logging import logging
@@ -17,7 +18,7 @@ from app.ha.history import (
normalize_logbook_payload, normalize_logbook_payload,
normalize_state_history_payload, normalize_state_history_payload,
) )
from app.ha.models import HaEntitySummary from app.ha.models import HaAutomationSummary, HaEntitySummary
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,6 +26,11 @@ logger = logging.getLogger(__name__)
class HaReader: class HaReader:
def __init__(self, client: HaClient) -> None: def __init__(self, client: HaClient) -> None:
self._client = client 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]: def read_entities(self) -> Sequence[HaEntitySummary]:
entities = self._client.list_entities() entities = self._client.list_entities()
@@ -112,6 +118,79 @@ class HaReader:
) -> Sequence[object]: ) -> Sequence[object]:
return self._client.call_service(domain, service, service_data) 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: def _optional_str(value: object) -> str | None:
if value is None or value == "": if value is None or value == "":
@@ -127,3 +206,13 @@ def _optional_datetime(value: object) -> datetime | None:
except ValueError: except ValueError:
return None return None
return parsed if parsed.tzinfo is not None else 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

View File

@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.5.2", version="0.7.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -97,7 +97,10 @@ def health() -> dict[str, str]:
@app.get("/") @app.get("/")
def root() -> FileResponse: 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: async def _periodic_reconciliation(app: FastAPI) -> None:

View File

@@ -21,7 +21,7 @@
.warn { color: #f3c969; } .warn { color: #f3c969; }
.bad { color: #ff8f8f; } .bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; } 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 { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; } button.secondary { background: #37495c; }
button.danger { background: #7b3434; } button.danger { background: #7b3434; }
@@ -77,8 +77,9 @@
<section> <section>
<h2>1. Gerät zum Lernen auswählen</h2> <h2>1. Gerät zum Lernen auswählen</h2>
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p> <p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
<label for="actuator-select">Gerät aus Home Assistant</label> <label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
<select id="actuator-select"></select> <input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
<datalist id="actuator-options"></datalist>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
</section> </section>
@@ -163,7 +164,7 @@ async function loadOverview() {
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select"); const options = document.getElementById("actuator-options");
try { try {
const [available, configured] = await Promise.all([ const [available, configured] = await Promise.all([
api("v1/actuators/discovery"), api("v1/actuators/discovery"),
@@ -171,16 +172,16 @@ async function loadActuatorDiscovery() {
]); ]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id)); const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
const choices = available.filter(entity => !configuredIds.has(entity.entity_id)); const choices = available.filter(entity => !configuredIds.has(entity.entity_id));
select.innerHTML = choices.length options.innerHTML = choices.map(entity =>
? choices.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`).join("") `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
: "<option value=''>Alle erkannten Aktoren sind ausgewählt</option>"; ).join("");
} catch (error) { } catch (error) {
select.innerHTML = `<option value="">${escapeHtml(error.message)}</option>`; options.innerHTML = "";
} }
} }
async function configureActuator() { 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"); const result = document.getElementById("actuator-config-result");
if (!actuatorId) return; if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ..."; result.textContent = "Kontext wird automatisch analysiert ...";
@@ -204,17 +205,23 @@ async function loadConfiguredActuators() {
const rows = await api("v1/actuators"); const rows = await api("v1/actuators");
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
<table> <table>
<tr><th>Gerät</th><th>Lernstatus</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr> <tr><th>Gerät</th><th>Lernstatus</th><th>Freigabe</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
${rows.map(record => ` ${rows.map(record => `
<tr> <tr>
<td>${escapeHtml(record.actuator_entity_id)}</td> <td>${escapeHtml(record.actuator_entity_id)}</td>
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td> <td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
<td class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</td>
<td>${record.behavior.sample_count}</td> <td>${record.behavior.sample_count}</td>
<td>${record.behavior.prediction <td>${record.behavior.prediction
? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)` ? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
: "-"}</td> : "-"}</td>
<td> <td>
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button> <button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button>
${record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
: record.behavior.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
: ""}
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button> <button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
</td> </td>
</tr> </tr>
@@ -229,7 +236,12 @@ async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
try { 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 = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids, ...record.assignment.selected_context_entity_ids,
@@ -239,21 +251,26 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const prediction = record.behavior.prediction; const prediction = record.behavior.prediction;
const requiredTrustedActions = 3;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
).length; ).length;
const missingTrustedActions = Math.max( const relatedAutomations = record.behavior.related_automations || [];
0,
requiredTrustedActions - record.behavior.high_confidence_sample_count,
);
const activationButton = record.behavior.mode === "active" const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
: record.behavior.status === "trained" && missingTrustedActions === 0 <button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>` : record.behavior.activation_ready
: record.behavior.status === "trained" ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen und passende HA-Automationen pausieren</button>
? `<p class='muted'>Freigabe noch gesperrt: ${missingTrustedActions} eindeutig zugeordnete manuelle oder automatisierte Handlung${missingTrustedActions === 1 ? "" : "en"} fehlen.</p>` <button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, false, false)">SillyHome parallel aktivieren</button>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>"; : `<p class='warn'>${escapeHtml(record.behavior.activation_reason)}</p>`;
const automationControls = relatedAutomations.length
? `<ul>${relatedAutomations.map(automation => `
<li>
<strong>${escapeHtml(automation.friendly_name)}</strong>
<code>${escapeHtml(automation.entity_id)}</code>:
<span class="${automation.enabled ? "ok" : "warn"}">${automation.enabled ? "aktiv" : "pausiert"}</span>
<button class="secondary" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
</li>`).join("")}</ul>`
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
box.innerHTML = ` box.innerHTML = `
<div class="grid-two"> <div class="grid-two">
<div> <div>
@@ -272,6 +289,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p> <p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p> <p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p> <p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
${activationButton} ${activationButton}
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button> <button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p> <p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
@@ -280,8 +298,11 @@ async function showActuator(actuatorId, evaluationMessage = "") {
</div> </div>
<h3>Was SillyHome aktuell vorhersagt</h3> <h3>Was SillyHome aktuell vorhersagt</h3>
${prediction ${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${prediction.executed ? "<span class='ok'>Ausgeführt.</span>" : "<span class='muted'>Nicht ausgeführt.</span>"}</p>` ? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"} : "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
`; `;
@@ -309,15 +330,41 @@ async function evaluateActuator(actuatorId) {
} }
} }
async function setActivation(actuatorId, active) { async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active const question = active
? `${actuatorId} wirklich für autonomes Lernen und Schalten freigeben?` ? pauseMatchingAutomations
: `${actuatorId} wieder in den Shadow-Modus setzen?`; ? `${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; if (!confirm(question)) return;
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, { await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
method: "POST", 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 loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);

73
docs/BEHAVIOR_ENGINE.md Normal file
View File

@@ -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.

47
docs/CONTROL_HANDOFF.md Normal file
View File

@@ -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
```

63
docs/DEBUGGING.md Normal file
View File

@@ -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
```

87
docs/OPERATIONS.md Normal file
View File

@@ -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.

View File

@@ -205,12 +205,44 @@ Shadow-Modus wird niemals geschaltet.
### `POST /v1/actuators/{actuator_entity_id}/activation` ### `POST /v1/actuators/{actuator_entity_id}/activation`
```json ```json
{"active": true} {
"active": true,
"pause_matching_automations": true,
"restore_paused_automations": false
}
``` ```
Aktiviert autonomes Schalten erst nach ausreichendem Training und nur für Aktiviert autonomes Schalten erst nach ausreichendem Training und nur für
erlaubte Aktor-Domains. Mit `false` wird der Aktor sofort wieder in den erlaubte Aktor-Domains. `pause_matching_automations` pausiert eindeutig
Shadow-Modus versetzt. 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 ## Betrieb

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.5.2" version = "0.7.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

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

View File

@@ -5,7 +5,13 @@ from pathlib import Path
import pytest 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.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
from app.config import Settings from app.config import Settings
@@ -14,7 +20,7 @@ from app.ha.history import (
StateHistoryPoint, StateHistoryPoint,
StateHistorySeries, StateHistorySeries,
) )
from app.ha.models import HaEntitySummary from app.ha.models import HaAutomationSummary, HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -30,6 +36,7 @@ class FakeBehaviorReader(HaReader):
self.history = history self.history = history
self.logbook = logbook self.logbook = logbook
self.service_calls: list[tuple[str, str, dict[str, object]]] = [] self.service_calls: list[tuple[str, str, dict[str, object]]] = []
self.automations: list[HaAutomationSummary] = []
def read_entities(self) -> list[HaEntitySummary]: def read_entities(self) -> list[HaEntitySummary]:
return list(self.entities) return list(self.entities)
@@ -59,6 +66,12 @@ class FakeBehaviorReader(HaReader):
self.service_calls.append((domain, service, service_data)) self.service_calls.append((domain, service, service_data))
return [] return []
def find_automations_for_entity(
self,
entity_id: str,
) -> list[HaAutomationSummary]:
return list(self.automations)
def _settings(tmp_path: Path) -> Settings: def _settings(tmp_path: Path) -> Settings:
return 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=[]) reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings) 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) 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( @pytest.mark.parametrize(
("domain", "state", "service"), ("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 history[0].points[0].state == "21.5"
assert logbook[0].context_user_id == "user-1" 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 "SillyHome Next" in response.text
assert "So gehst du vor" in response.text assert "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" 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 "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" 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 "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
assert "Freigabe noch gesperrt" in response.text assert "Freigabestatus" in response.text
assert "manuelle oder automatisierte Handlung" 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 "Davon erkannte HA-Automationen" in response.text
assert "Aktuelle Situation auswerten" in response.text assert "Aktuelle Situation auswerten" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert ( assert "record.behavior.activation_ready" in response.text
'record.behavior.status === "trained" && missingTrustedActions === 0'
in response.text
)
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text