Compare commits

..

32 Commits

Author SHA1 Message Date
c5f42a39a9 Fix realtime HA state-change execution
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 10:50:28 +02:00
309b33b812 Use fresh HA event state for behavior triggers
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 19:37:45 +02:00
9db7cde179 Fix HA websocket keepalive fallback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 19:30:02 +02:00
3140f65527 Fix HA websocket state change handling
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 18:15:14 +02:00
5727053951 fix: hide diagnostic context suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:47:14 +02:00
658516cd96 fix: narrow manual context suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:42:50 +02:00
8cd8f3e3b7 feat: improve actor-specific context selection
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:35:38 +02:00
09e14689a3 feat: add manual context assignment and fix actuator discovery
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:15:00 +02:00
d87d3abc00 fix: batch ha metadata and improve mobile dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 22:52:42 +02:00
2ae5576b8f fix: complete websocket delivery for v0.7.1
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 22:30:29 +02:00
51d23e0a9a feat: WebSocket-Healthcheck und Status-Tracking\n\n- Fügt _WsStatus-Klasse hinzu, die den aktuellen Verbindungsstatus verfolgt\n- Neuer Endpoint /health/websocket gibt Status zurück (connected/connecting/error)\n- Event-Listener aktualisiert den Status bei allen Zustandsänderungen\n- Fallback-Task wird korrekt im lifespan verwaltet\n- Bessere Fehlerbehandlung und Statusmeldungen\n\nImproves observability of the event-based architecture.
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 17:55:44 +02:00
c8f491ba1a feat: event-basierte Vorhersage via HA-WebSocket\n\n- Entfernt periodisches Prediction-Intervall (60s)\n- Fügt WebSocket-Listener hinzu, der bei jedem State Change sofort evaluiert\n- BehaviorEngine.handle_state_change() identifiziert betroffene Aktoren und löst evaluate() aus\n- Fallback periodische Vorhersage bleibt als Backup\n- pyproject: websockets dependency\n- tests: test_main.py für Event-Listener\n\nCloses #39
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 17:23:29 +02:00
f9c7c27e00 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
2026-06-14 16:22:18 +02:00
b3cf68eade CONTROL-001: add safe HA automation handoff
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 16:21:57 +02:00
77f328c4a8 Merge pull request 'v0.6.2: HA-Automationen gleichwertig lernen' (#39) from feature/automation-equality-v0.6.2 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:58:44 +02:00
7ad97320a2 BEHAVIOR-004: trust HA automation actions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:58:05 +02:00
58d3126a35 Merge pull request 'v0.6.1: sichtbare Rückmeldung bei Situationsprüfung' (#38) from fix/evaluation-feedback-v0.6.1 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:38:26 +02:00
1c5eab14b6 UI-003: show prediction evaluation feedback
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:38:11 +02:00
87ae051238 Merge pull request 'v0.6.0: kausales Shadow-Lernen aus Sensorwechseln' (#37) from feature/causal-shadow-v0.6.0 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:35:22 +02:00
fb76d89204 BEHAVIOR-003: learn causal shadow triggers
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:35:07 +02:00
1370d02c15 Merge pull request 'v0.5.4: korrekter Kontext- und Freigabestatus' (#36) from fix/context-status-v0.5.4 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:29:07 +02:00
100f5af578 UI-002: align context and activation status
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:28:52 +02:00
ede6b87dbd Merge pull request 'v0.5.3: sichere Sensorzuordnung für Aktoren' (#35) from fix/sensor-assignment-v0.5.3 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:19:55 +02:00
47e8c7e549 ASSIGN-001: reject unrelated actuator sensors
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:19:30 +02:00
ef7e0c5600 Merge pull request 'v0.5.2: Add-on-Build liefert zuverlässig aktuellen Code' (#34) from fix/addon-cache-v0.5.2 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 11:38:51 +02:00
8d070fc9ca BUILD-001: invalidate addon application cache per release
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 11:38:36 +02:00
ba15cc4d83 Merge pull request 'v0.5.1: verständliche Ingress-Führung und vereinfachte Add-on-Konfiguration' (#33) from fix/ingress-guidance-v0.5.1 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 11:03:20 +02:00
da51ac2063 UI-001: simplify addon setup and explain ingress workflow
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 11:02:38 +02:00
ce568056fc Merge pull request 'v0.5.0: behavior learning, shadow prediction and safe activation' (#32) from feature/actuator-sensor-lifecycle into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
Merge pull request v0.5.0 behavior learning and safe activation (#32)
2026-06-14 10:41:16 +02:00
da4603be17 BEHAVIOR-002: isolate per-actuator runtime failures
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 10:40:19 +02:00
b215f23dd9 Merge remote-tracking branch 'origin/main' into feature/actuator-sensor-lifecycle
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 10:38:33 +02:00
fa250216be BEHAVIOR-001: learn and predict actuator actions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 10:37:59 +02:00
45 changed files with 4605 additions and 527 deletions

View File

@@ -7,3 +7,9 @@ SILLYHOME_HISTORY_DAYS=14
SILLYHOME_MIN_TRAINING_POINTS=24 SILLYHOME_MIN_TRAINING_POINTS=24
SILLYHOME_RETRAIN_STALE_HOURS=24 SILLYHOME_RETRAIN_STALE_HOURS=24
SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 SILLYHOME_RECONCILE_INTERVAL_SECONDS=900
SILLYHOME_MIN_BEHAVIOR_ACTIONS=3
SILLYHOME_PREDICTION_CONFIDENCE=0.82
SILLYHOME_PREDICTION_WINDOW_MINUTES=30
SILLYHOME_PREDICTION_INTERVAL_SECONDS=60
SILLYHOME_EXECUTION_COOLDOWN_SECONDS=900
SILLYHOME_TIMEZONE=Europe/Berlin

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

@@ -1,13 +1,24 @@
# SillyHome Next — Architekturübersicht # SillyHome Next — Architekturübersicht
Ziel ist ein lokales, datensparsames, erklärbares Smart-Home-Intelligenzsystem für Home Assistant. Es analysiert Historie, erkennt Gewohnheiten, erstellt Vorhersagen, empfiehlt Automationen und kann auf Wunsch einfach in Automationen übersetzen. Vier Intelligenzebenen sind vorgesehen: regelbasiert, ML-gestützt, LLM-unterstützt und autonomer Hausagent. Ziel ist ein lokales, datensparsames und erklärbares Smart-Home-Intelligenzsystem
für Home Assistant. Nutzer wählen ausschließlich erlaubte Aktoren. Das System
ordnet Kontext automatisch zu, erkennt historische Nutzerhandlungen, trainiert
pro Aktor ein Verhaltensmodell und trifft zunächst nur Shadow-Vorhersagen.
Autonomes Schalten wird separat pro Aktor freigegeben.
## Leitentscheidungen ## Leitentscheidungen
- Lokal-first und datensparsam; keine Cloudpflicht. - Lokal-first und datensparsam; keine Cloudpflicht.
- Trennung von Datenintegration, Trainingspipeline, Vorhersageservice und Erklärungsschicht. - Trennung von Datenintegration, Kontextzuordnung, Verhaltenslernen,
- Standardintegration über MQTT und Home Assistant WebSocket plus REST. Vorhersage und Aktorausführung.
- Schnittstellen über FastAPI und OpenAI-kompatible Endpunkte. - Logbook-basierte Herkunftserkennung; eindeutig erkannte HA-Automationen
- Langzeitdaten in PostgreSQL und TimescaleDB; Vektoren für semantische Suche optional. zählen wie manuelle Bedienungen. Eigene SillyHome-Schaltungen werden nicht
- Deployment über Docker Compose; Kubernetes optional für erweiterte Betriebsgrößen. zurückgelernt.
- Ausführung nur für freigegebene, reversible Domains und Zustände sowie mit
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.
- Tests, Docs und Changelog sind Pflichtbestandteil jeder Änderung. - Tests, Docs und Changelog sind Pflichtbestandteil jeder Änderung.

View File

@@ -1,5 +1,152 @@
# Changelog # Changelog
## 0.7.10 - 2026-06-16
- WebSocket-State-Changes aktualisieren einen internen Home-Assistant-State-
Cache und werten Aktoren direkt gegen diesen frischen Event-Zustand aus.
- Event-Auswertungen lösen keine REST-Statusabfrage mehr aus, bevor sie
aktive Aktoren schalten.
## 0.7.9 - 2026-06-15
- Event-basierte Vorhersagen verwenden den frischen Sensorzustand direkt aus
dem Home-Assistant-WebSocket-Event, damit Kontextwechsel ohne REST-Race sofort
bewertet und geschaltet werden können
- Regressionstest stellt sicher, dass ein Türsensor-Event trotz veraltetem
HA-Snapshot direkt `light.turn_on` auslöst
## 0.7.8 - 2026-06-15
- Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive-
Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu
aufgebaut werden
- Fallback-Auswertung läuft bei getrenntem WebSocket kurzfristig alle 5 Sekunden,
damit übernommene Aktoren nicht ohne Steuerung bleiben
## 0.7.7 - 2026-06-15
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
## 0.7.6 - 2026-06-14
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
## 0.7.5 - 2026-06-14
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
gemeinsame Geräte/Tokens oder echte globale Außenwerte
- Diagnosewerte wie MQTT-, WiFi-, Restart- und Connect-Zähler werden nicht mehr
als fachliche Kontextvorschläge angeboten
## 0.7.4 - 2026-06-14
- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
Roh-Liste aller Sensoren und Zustände
- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und
durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden
- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne
Dropdown-Treffer gespeichert werden können
- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne
fachlichen Bezug nicht mehr als Standardvorschläge auf
## 0.7.3 - 2026-06-14
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
- Aktor-Auswahl auf tatsächlich sicher steuerbare Domains begrenzt:
`light`, `switch`, `cover`, `fan`, `humidifier`
- Neue manuelle Kontext-Zuordnung pro Aktor: Haupt-Messsensor optional setzen und
mehrere relevante Kontext-Entities wie PIR, Außenhelligkeit, Luftfeuchtigkeit
oder andere Lichtzustände auswählen
- Dashboard-Dropdown durch echtes Select plus Suche ersetzt; mobile Bedienung und
Aktor-Details enthalten Speichern/Neu-laden-Aktionen für manuelle Kontextwahl
## 0.7.2 - 2026-06-14
- Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA-
Installationen nicht mehr am Template-Ausgabe-Limit scheitern
- Nicht über die HA-Config-API exponierte Automationen werden leise übersprungen,
statt wiederholt Warnungen in die Logs zu schreiben
- Dashboard für mobile Nutzung optimiert: Sticky-Schnellnavigation, Karten statt
breiter Tabelle, größere Touch-Ziele und bessere Detail-/Menüführung
- WebSocket-Status ist direkt im Dashboard-Systemstatus sichtbar
## 0.7.1 - 2026-06-14
- Event-basierter Home-Assistant-WebSocket-Listener authentifiziert sich jetzt
mit dem echten HA-WebSocket-Protokoll (`auth_required` -> `auth` -> `auth_ok`)
- Kompatibilität mit aktuellen `websockets`-Versionen wiederhergestellt
- WebSocket-Healthcheck und Event-Listener-Tests laufen ohne zusätzliches
Async-Pytest-Plugin
- Add-on-Version angehoben, damit Home Assistant das aktualisierte Image baut
## 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
- Automationsmuster erhalten dieselbe Modellgewichtung wie manuelle Handlungen
- Oberfläche zeigt die gemeinsame Zahl als `eindeutig geregelt`; eine
ausdrückliche Aktivierung pro Aktor bleibt weiterhin erforderlich
## 0.6.1 - 2026-06-14
- Manuelle Prüfung als `Aktuelle Situation auswerten` eindeutig von Simulation
oder Aktorschaltung abgegrenzt
- Sichtbare Rückmeldung mit Prüfzeitpunkt, vorhergesagtem Zustand und Sicherheit
oder klarem Hinweis auf einen fehlenden frischen Sensorwechsel
## 0.6.0 - 2026-06-14
- Kausales Shadow-Lernen erkennt frische Kontextwechsel unmittelbar vor einer
Aktorhandlung, etwa `Tür geschlossen → offen` vor `Licht aus → an`
- Historische Home-Assistant-Automationen dürfen Vorhersagen begründen, zählen
aber weiterhin niemals als eindeutige Benutzerhandlung oder Ausführungsfreigabe
- Aktuelle `last_changed`-Zeitpunkte verhindern Vorhersagen aus längst
unveränderten Sensorzuständen
- Oberfläche trennt gelernte Benutzerhandlungen und erkannte HA-Automationen
## 0.5.4 - 2026-06-14
- Tür-, Bewegungs- und andere belastbare Kontextsensoren werden auch ohne
numerischen Sensor als vollständige automatische Kontextzuordnung angezeigt
- Status und Zuordnungssicherheit bilden das aktive Verhaltenslernen ab statt
eines optionalen numerischen Modells
- Ausführungsfreigabe erscheint erst, wenn genügend eindeutig manuelle
Bedienungen vorliegen; bis dahin nennt die Oberfläche die noch fehlende Anzahl
## 0.5.3 - 2026-06-14
- Verhindert fachlich falsche Sensorzuordnungen nur aufgrund generischer Namen wie
`Licht` oder `Lichtschalter`
- Übernimmt numerische Sensoren nur noch bei einem belastbaren absoluten Score und
einer eindeutigen Abgrenzung zum zweitbesten Kandidaten
- Begrenzt Zusatzkontext auf relevante Sensoren und bevorzugt bei Lichtaktoren
echte Beleuchtungsstärke gegenüber fremden Leistungs- oder Energiezählern
## 0.5.2 - 2026-06-14
- Add-on-Build invalidiert den Docker-Cache bei jeder Versionsänderung, damit
Versionsmetadaten und tatsächlich ausgelieferter Anwendungscode übereinstimmen
- Korrigierte Ingress-Oberfläche aus 0.5.1 dadurch erstmals zuverlässig ausgeliefert
## 0.5.1 - 2026-06-14
- Technische Modell-, Intervall- und Sicherheitsparameter aus der normalen
Home-Assistant-Add-on-Konfiguration entfernt; sichere Standardwerte bleiben aktiv
- Ingress um einen klaren Ablauf mit Aktorauswahl, Beobachtungsphase und späterer
Ausführungsfreigabe ergänzt
- Bedienelemente und Diagnosen in verständlicher Alltagssprache erklärt
## 0.5.0 - 2026-06-14
- Ingress auf reine Aktorauswahl, automatischen Lernstatus und Vorhersagen reduziert
- Automatische Kontextzuordnung ohne Sensor-Overrides oder Review-Blockade
- Historische Handlungserkennung aus HA-State-History und Logbook-Herkunft
- Persistentes Verhaltensmodell pro Aktor mit Zeit-, Wochentags- und Kontextmustern
- Shadow-Vorhersagen vor jeder Ausführungsfreigabe
- Explizite Aktivierung pro Aktor, Konfidenzschwelle, Cooldown und enge Service-Whitelist
- Schutz vor dem Lernen erkannter HA-Automationen und eigener Schaltvorgänge
- Automation-Proposal- und Override-Endpunkte aus dem aktiven Produkt entfernt
## 0.4.0 - 2026-06-13 ## 0.4.0 - 2026-06-13
- Aktuator-zentrierte Einrichtung: Nutzer wählen nur noch Aktuatoren, Sensoren werden deterministisch gefunden und bewertet - Aktuator-zentrierte Einrichtung: Nutzer wählen nur noch Aktuatoren, Sensoren werden deterministisch gefunden und bewertet
- Persistente automatische und manuelle Sensorzuordnungen mit Evidenz, Confidence, Review-Gating und Neustart-Sicherheit - Persistente automatische und manuelle Sensorzuordnungen mit Evidenz, Confidence, Review-Gating und Neustart-Sicherheit

View File

@@ -9,7 +9,13 @@ ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations \
SILLYHOME_HISTORY_DAYS=14 \ SILLYHOME_HISTORY_DAYS=14 \
SILLYHOME_MIN_TRAINING_POINTS=24 \ SILLYHOME_MIN_TRAINING_POINTS=24 \
SILLYHOME_RETRAIN_STALE_HOURS=24 \ SILLYHOME_RETRAIN_STALE_HOURS=24 \
SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 \
SILLYHOME_MIN_BEHAVIOR_ACTIONS=3 \
SILLYHOME_PREDICTION_CONFIDENCE=0.82 \
SILLYHOME_PREDICTION_WINDOW_MINUTES=30 \
SILLYHOME_PREDICTION_INTERVAL_SECONDS=60 \
SILLYHOME_EXECUTION_COOLDOWN_SECONDS=900 \
SILLYHOME_TIMEZONE=Europe/Berlin
WORKDIR /app WORKDIR /app

View File

@@ -1,13 +1,25 @@
# 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
Die aktuelle Entwicklungslinie ist aktor-zentriert: Nutzer konfigurieren nur Die aktuelle Entwicklungslinie ist vollständig aktor-zentriert: Nutzer wählen
noch Home-Assistant-Aktuatoren. SillyHome Next findet dazu passende numerische nur Home-Assistant-Aktuatoren aus. SillyHome Next findet Sensoren, Zustände und
Sensoren und Kontext-Entities, zeigt Evidenz und Review-Bedarf an und hält Kontext automatisch, wertet die vorhandene Historie aus und hält passende
passende Modelle lokal und autonom aktuell. lokale Modelle autonom aktuell. Es gibt keinen Regel-, Trigger-, Sensor- oder
YAML-Konfigurationsschritt.
## Motivation ## Motivation
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit. TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
@@ -16,7 +28,7 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Home Assistant und Sensoren/Aktoren verstehen - Home Assistant und Sensoren/Aktoren verstehen
- Historie auswerten und Gewohnheiten erkennen - Historie auswerten und Gewohnheiten erkennen
- Vorhersagen erstellen und erklären - Vorhersagen erstellen und erklären
- Automationen vorschlagen und direkt generieren - Persönliches Verhalten pro Aktor lernen und zukünftige Handlungen vorhersagen
- Lokal-first ohne Cloudpflicht - Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert - Erweiterbar, testbar, dokumentiert
@@ -46,12 +58,13 @@ uvicorn app.main:app --reload
- `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities - `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen - `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow - `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
- `POST http://127.0.0.1:8000/v1/actuators` - Aktuator registrieren, Sensorzuordnung prüfen und Modell-Lebenszyklus starten - `POST http://127.0.0.1:8000/v1/actuators` - Aktor freigeben; Kontextzuordnung und Modell-Lebenszyklus starten automatisch
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/evaluate` - Shadow-Vorhersage aktualisieren
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen
- `POST http://127.0.0.1:8000/v1/actuators/reconciliation/run` - globale Reconciliation manuell anstoßen - `POST http://127.0.0.1:8000/v1/actuators/reconciliation/run` - globale Reconciliation manuell anstoßen
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health - `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren - `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen - `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen
- `POST http://127.0.0.1:8000/v1/automations/proposals` - sicheren Entwurf anlegen
Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`. Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
@@ -70,12 +83,17 @@ dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden.
- `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`) - `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`)
- `SILLYHOME_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten - `SILLYHOME_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten - `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten
- `SILLYHOME_AUTOMATION_STORE` Verzeichnis für Automation-Entwürfe - `SILLYHOME_ACTUATOR_STORE` Verzeichnis für persistente Aktor-Zuordnungen und Reconciliation-Status
- `SILLYHOME_ACTUATOR_STORE` Verzeichnis für persistente Aktuator-Zuordnungen, Overrides und Reconciliation-Status
- `SILLYHOME_HISTORY_DAYS` Trainingsfenster für HA-History (1 bis 31 Tage) - `SILLYHOME_HISTORY_DAYS` Trainingsfenster für HA-History (1 bis 31 Tage)
- `SILLYHOME_MIN_TRAINING_POINTS` Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining - `SILLYHOME_MIN_TRAINING_POINTS` Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining
- `SILLYHOME_RETRAIN_STALE_HOURS` Staleness-Grenze für automatisches Retraining - `SILLYHOME_RETRAIN_STALE_HOURS` Staleness-Grenze für automatisches Retraining
- `SILLYHOME_RECONCILE_INTERVAL_SECONDS` Intervall für sichere periodische Reconciliation - `SILLYHOME_RECONCILE_INTERVAL_SECONDS` Intervall für sichere periodische Reconciliation
- `SILLYHOME_MIN_BEHAVIOR_ACTIONS` Mindestzahl gelernter Handlungen vor einer Freigabe
- `SILLYHOME_PREDICTION_CONFIDENCE` Mindestkonfidenz für autonomes Schalten
- `SILLYHOME_PREDICTION_WINDOW_MINUTES` Zeitfenster um gelernte Handlungsmuster
- `SILLYHOME_PREDICTION_INTERVAL_SECONDS` Intervall für Shadow-/Aktiv-Vorhersagen
- `SILLYHOME_EXECUTION_COOLDOWN_SECONDS` Mindestabstand zwischen eigenen Schaltungen
- `SILLYHOME_TIMEZONE` lokale Zeitzone für Tages- und Wochenmuster
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
Versionskontrollsystem. Versionskontrollsystem.
@@ -88,15 +106,26 @@ unter **Einstellungen → Add-ons → Add-on-Shop → Repositories** diese URL e
`http://192.168.6.31:3000/pino/sillyhome-next` `http://192.168.6.31:3000/pino/sillyhome-next`
Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingress Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingress
geöffnet. Das Add-on nutzt die Supervisor-API nur lesend; Automation-Entwürfe werden geöffnet. Dort werden ausschließlich erlaubte Aktoren ausgewählt; Kontext- und
lokal gespeichert und niemals automatisch ausgeführt. Lernentscheidungen erfolgen automatisch.
### Normaler Workflow ### Normaler Workflow
1. Im Dashboard oder per API einen Aktuator auswählen, zum Beispiel `light.abstellkammer`. 1. Im Dashboard einen Aktor auswählen, zum Beispiel `light.abstellkammer`.
2. SillyHome Next bewertet passende numerische Sensoren und binäre Kontext-Entities anhand von Bereich, Gerät, Namen, Domain und `device_class`. 2. SillyHome Next bewertet automatisch Messwerte, Anwesenheit, Bewegung,
3. Starke und eindeutige Zuordnungen werden automatisch genutzt; schwache oder knappe Kandidaten bleiben mit Review-Hinweis sichtbar. Bereiche, Gerätebeziehungen und weitere HA-Kontexte.
4. Manuelle Overrides haben Vorrang, bleiben persistent und überstehen Neustarts. 3. Das System verwendet selbstständig die beste verfügbare Zuordnung.
5. Sobald genügend numerische HA-Historie vorhanden ist, trainiert das System automatisch ein lokales Modell pro Aktuator-Zuordnung und retrainiert es bei relevanten Datenänderungen oder Staleness. Niedrige Sicherheit bleibt als Diagnose sichtbar, verlangt aber keine
manuelle Konfiguration.
4. Sobald genügend Historie vorhanden ist, trainiert und aktualisiert das
System das lokale Modell automatisch.
5. Vorhersagen laufen zunächst ausschließlich im Shadow-Modus.
6. Erst nach ausdrücklicher Freigabe pro Aktor werden hochkonfidente,
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** 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
@@ -107,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

@@ -4,13 +4,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
# The add-on version changes for every release. Copying its config before the
# clone makes Docker invalidate the application layer instead of reusing old code.
COPY config.yaml /tmp/addon-config.yaml
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git \ && apt-get install -y --no-install-recommends git \
&& git clone --depth 1 --branch main \ && git clone --depth 1 --branch main \
http://192.168.6.31:3000/pino/sillyhome-next.git /app \ http://192.168.6.31:3000/pino/sillyhome-next.git /app \
&& python -m pip install --upgrade pip \ && python -m pip install --upgrade pip \
&& python -m pip install /app \ && python -m pip install /app \
&& rm -rf /var/lib/apt/lists/* /app/.git && rm -rf /var/lib/apt/lists/* /app/.git /tmp/addon-config.yaml
COPY run.sh /run.sh COPY run.sh /run.sh
RUN chmod 0755 /run.sh RUN chmod 0755 /run.sh

View File

@@ -1,7 +1,7 @@
name: SillyHome Next name: SillyHome Next
version: "0.4.0" version: "0.7.10"
slug: sillyhome_next slug: sillyhome_next
description: Lokale HA-Analyse, Vorhersagen und sichere Automation-Entwürfe 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
arch: arch:
- amd64 - amd64
@@ -16,16 +16,6 @@ panel_admin: true
homeassistant_api: true homeassistant_api: true
hassio_api: false hassio_api: false
auth_api: false auth_api: false
options:
history_days: 14
min_training_points: 24
retrain_stale_hours: 24
reconcile_interval_seconds: 900
schema:
history_days: "int(1,31)"
min_training_points: "int(2,10000)"
retrain_stale_hours: "int(1,720)"
reconcile_interval_seconds: "int(60,86400)"
map: map:
- type: addon_config - type: addon_config
read_only: false read_only: false

View File

@@ -12,6 +12,12 @@ if [ -f /data/options.json ]; then
export SILLYHOME_MIN_TRAINING_POINTS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_training_points", 24))')" export SILLYHOME_MIN_TRAINING_POINTS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_training_points", 24))')"
export SILLYHOME_RETRAIN_STALE_HOURS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("retrain_stale_hours", 24))')" export SILLYHOME_RETRAIN_STALE_HOURS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("retrain_stale_hours", 24))')"
export SILLYHOME_RECONCILE_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("reconcile_interval_seconds", 900))')" export SILLYHOME_RECONCILE_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("reconcile_interval_seconds", 900))')"
export SILLYHOME_MIN_BEHAVIOR_ACTIONS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_behavior_actions", 3))')"
export SILLYHOME_PREDICTION_CONFIDENCE="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_confidence", 0.82))')"
export SILLYHOME_PREDICTION_WINDOW_MINUTES="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_window_minutes", 30))')"
export SILLYHOME_PREDICTION_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_interval_seconds", 60))')"
export SILLYHOME_EXECUTION_COOLDOWN_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("execution_cooldown_seconds", 900))')"
export SILLYHOME_TIMEZONE="$(python -c 'import json; print(json.load(open("/data/options.json")).get("timezone", "Europe/Berlin"))')"
fi fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE" mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"

View File

@@ -46,6 +46,9 @@ _STOPWORDS = frozenset(
"humidity", "humidity",
"illuminance", "illuminance",
"light", "light",
"licht",
"lichtschalter",
"monitoring",
"power", "power",
"sensor", "sensor",
"state", "state",
@@ -54,11 +57,53 @@ _STOPWORDS = frozenset(
"value", "value",
} }
) )
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78 _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_MAX_CONTEXT_SELECTIONS = 3 _CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20 _AUDIT_LIMIT = 20
_MANUAL_CONTEXT_DOMAINS = frozenset({
"binary_sensor",
"climate",
"cover",
"device_tracker",
"fan",
"humidifier",
"light",
"person",
"sensor",
"switch",
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 120
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"connect",
"count",
"diagnostic",
"firmware",
"gesehen",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"reason",
"restart",
"rssi",
"signal",
"ssid",
"status",
"uptime",
"wifi",
"zuletzt",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -85,26 +130,117 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord: def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id) return self._store.get(actuator_entity_id)
def set_override( def suggest_context_options(
self, self,
actuator_entity_id: str, actuator_entity_id: str,
override: ManualOverride | None, *,
) -> ActuatorRecord: limit: int = _CONTEXT_SUGGESTION_LIMIT,
record = self._store.get(actuator_entity_id) ) -> list[HaEntitySummary]:
updated = record.model_copy( entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
update={ discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
"manual_override": override, actuator = entities.get(actuator_entity_id)
"updated_at": datetime.now(timezone.utc), if actuator is None:
} raise KeyError("Aktuator-Konfiguration nicht gefunden.")
selected_ids = _selected_context_ids(self._store.get(actuator_entity_id))
ranked: list[tuple[float, str, HaEntitySummary]] = []
for entity in entities.values():
if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS:
continue
role = _manual_context_role(entity, discovered.get(entity.entity_id))
score, _ = _score_candidate(
actuator,
entity,
role,
context=role is not EntityRole.MEASUREMENT,
)
selected = entity.entity_id in selected_ids
if selected:
score = max(score, 1.0)
if not selected and (
_is_diagnostic_context(entity)
or not _has_context_relationship(actuator, entity)
):
continue
if not selected and score < 0.1:
continue
ranked.append((score, _context_sort_group(entity), entity))
ranked.sort(
key=lambda item: (
-item[0],
item[1],
item[2].area_name or "",
item[2].friendly_name or item[2].entity_id,
item[2].entity_id,
)
) )
self._store.upsert(updated) return [entity for _, _, entity in ranked[:limit]]
return self.reconcile_actuator(actuator_entity_id, trigger="override")
def delete_actuator(self, actuator_entity_id: str) -> None: def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id) model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id) self._registry.archive(model_id)
self._store.delete(actuator_entity_id) self._store.delete(actuator_entity_id)
def set_manual_assignment(
self,
actuator_entity_id: str,
*,
numeric_entity_id: str | None,
context_entity_ids: list[str],
note: str | None = None,
) -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
selected_context_ids = list(dict.fromkeys(context_entity_ids))
selected_ids = [
entity_id
for entity_id in [numeric_entity_id, *selected_context_ids]
if entity_id
]
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
if missing:
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(missing)}")
if actuator_entity_id in selected_ids:
raise ValueError("Der Aktor selbst kann nicht als Kontextsensor verwendet werden.")
override = ManualOverride(
numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids,
updated_at=now,
note=note,
)
assignment = self._manual_assignment(override)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
assignment=assignment,
lifecycle=record.lifecycle.model_copy(update={"last_reconciled_at": now}),
now=now,
)
updated = record.model_copy(
update={
"assignment": assignment,
"manual_override": override,
"numeric_candidates": _merge_manual_candidates(
record.numeric_candidates,
entities,
[numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
"context_candidates": _merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
),
"lifecycle": lifecycle,
"updated_at": now,
}
)
return self._store.upsert(updated)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy( state = self._store.load_reconciliation_state().model_copy(
update={ update={
@@ -132,7 +268,7 @@ class ActuatorReconciliationService:
last_summary=( last_summary=(
f"{len(refreshed)} Aktuatoren geprüft, " f"{len(refreshed)} Aktuatoren geprüft, "
f"{sum(1 for record in refreshed if record.assignment.review_required)} " f"{sum(1 for record in refreshed if record.assignment.review_required)} "
"mit Prüfbedarf." "mit niedriger Zuordnungssicherheit."
), ),
) )
self._store.save_reconciliation_state(summary) self._store.save_reconciliation_state(summary)
@@ -210,11 +346,14 @@ class ActuatorReconciliationService:
), ),
context=True, context=True,
) )
assignment = self._select_assignment( assignment = (
actuator=actuator, self._manual_assignment(record.manual_override)
numeric_candidates=numeric_candidates, if record.manual_override is not None
context_candidates=context_candidates, else self._select_assignment(
override=record.manual_override, actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
)
) )
lifecycle = self._reconcile_lifecycle( lifecycle = self._reconcile_lifecycle(
actuator=actuator, actuator=actuator,
@@ -225,6 +364,7 @@ class ActuatorReconciliationService:
updated = record.model_copy( updated = record.model_copy(
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": record.manual_override,
"numeric_candidates": numeric_candidates, "numeric_candidates": numeric_candidates,
"context_candidates": context_candidates, "context_candidates": context_candidates,
"lifecycle": lifecycle, "lifecycle": lifecycle,
@@ -240,42 +380,63 @@ class ActuatorReconciliationService:
) )
return updated return updated
@staticmethod
def _manual_assignment(override: ManualOverride) -> AssignmentSelection:
selected_context_ids = list(dict.fromkeys(override.context_entity_ids))
selected_count = len(selected_context_ids) + (1 if override.numeric_entity_id else 0)
return AssignmentSelection(
selected_numeric_entity_id=override.numeric_entity_id,
selected_context_entity_ids=selected_context_ids,
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_count else 0.0,
review_required=selected_count == 0,
reason=(
f"Manuell festgelegt: {selected_count} Kontext-Entity(s) werden verwendet."
if selected_count
else "Manuelle Zuordnung enthält noch keine Kontext-Entities."
),
)
def _select_assignment( def _select_assignment(
self, self,
*, *,
actuator: HaEntitySummary, actuator: HaEntitySummary,
numeric_candidates: list[AssignmentCandidate], numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate], context_candidates: list[AssignmentCandidate],
override: ManualOverride | None,
) -> AssignmentSelection: ) -> AssignmentSelection:
if override is not None: top_numeric = next(
selected_numeric = override.numeric_entity_id (candidate for candidate in numeric_candidates if candidate.auto_accepted),
selected_contexts = list(dict.fromkeys(override.context_entity_ids)) None,
return AssignmentSelection( )
selected_numeric_entity_id=selected_numeric, accepted_contexts = [
selected_context_entity_ids=selected_contexts, candidate
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_numeric else 0.6,
review_required=False,
reason=(
"Manuelle Zuordnung überschreibt die automatische Heuristik dauerhaft."
),
)
top_numeric = numeric_candidates[0] if numeric_candidates else None
top_contexts = [
candidate.entity_id
for candidate in context_candidates for candidate in context_candidates
if candidate.auto_accepted if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS] ][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if top_numeric is None: if top_numeric is None:
if accepted_contexts:
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Passender Schaltkontext automatisch erkannt. Für diese "
"Verhaltensvorhersage ist kein numerischer Sensor erforderlich."
),
)
return AssignmentSelection( return AssignmentSelection(
selected_numeric_entity_id=None, selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts, selected_context_entity_ids=top_contexts,
source=AssignmentSource.NONE, source=AssignmentSource.NONE,
confidence=0.0, confidence=0.0,
review_required=True, review_required=True,
reason=f"Kein numerischer Sensor konnte für {display_name(actuator)} bestimmt werden.", reason=(
f"Für {display_name(actuator)} ist noch kein nutzbarer numerischer "
"Kontext verfügbar. Die Zuordnung wird automatisch erneut geprüft."
),
) )
return AssignmentSelection( return AssignmentSelection(
@@ -285,9 +446,9 @@ class ActuatorReconciliationService:
confidence=top_numeric.confidence, confidence=top_numeric.confidence,
review_required=not top_numeric.auto_accepted, review_required=not top_numeric.auto_accepted,
reason=( reason=(
"Automatisch akzeptiert." "Kontext automatisch und eindeutig zugeordnet."
if top_numeric.auto_accepted if top_numeric.auto_accepted
else "Top-Kandidat gefunden, aber Zuordnung ist noch nicht eindeutig genug." else "Besten verfügbaren Kontext automatisch mit niedriger Sicherheit zugeordnet."
), ),
) )
@@ -306,14 +467,6 @@ class ActuatorReconciliationService:
"Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.", "Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.",
now=now, now=now,
) )
if assignment.review_required and assignment.source is not AssignmentSource.MANUAL:
return self._archive_state(
lifecycle,
"Zuordnung ist nicht eindeutig; Modell wartet auf Review.",
now=now,
status=LifecycleStatus.REVIEW_REQUIRED,
)
sensor_id = assignment.selected_numeric_entity_id sensor_id = assignment.selected_numeric_entity_id
series = self._read_history(sensor_id, now) series = self._read_history(sensor_id, now)
points = series.points if series is not None else [] points = series.points if series is not None else []
@@ -327,7 +480,7 @@ class ActuatorReconciliationService:
f"{len(points)} von mindestens {self._settings.min_training_points} " f"{len(points)} von mindestens {self._settings.min_training_points} "
f"Messpunkten für {sensor_id} vorhanden." f"Messpunkten für {sensor_id} vorhanden."
), ),
"next_action": "Mehr Historie sammeln und Reconciliation erneut ausführen.", "next_action": "Historie wird automatisch weiter gesammelt.",
"last_history_point_count": len(points), "last_history_point_count": len(points),
} }
), ),
@@ -369,7 +522,7 @@ class ActuatorReconciliationService:
"last_history_signature": signature, "last_history_signature": signature,
"last_history_point_count": len(points), "last_history_point_count": len(points),
"reason": retrain_reason, "reason": retrain_reason,
"next_action": "Automatisch überwachen und bei neuen Daten neu trainieren.", "next_action": "Neue Daten automatisch überwachen und nachtrainieren.",
} }
), ),
action="retrained" if result.replaced else "trained", action="retrained" if result.replaced else "trained",
@@ -384,8 +537,8 @@ class ActuatorReconciliationService:
"last_reconciled_at": now, "last_reconciled_at": now,
"last_history_signature": signature, "last_history_signature": signature,
"last_history_point_count": len(points), "last_history_point_count": len(points),
"reason": "Modell ist aktuell und passt zur bestätigten Sensorzuordnung.", "reason": "Modell ist aktuell und passt zur automatischen Kontextzuordnung.",
"next_action": "Auf neue Historie oder Staleness warten.", "next_action": "Neue Historie automatisch auswerten.",
} }
), ),
action="kept", action="kept",
@@ -423,7 +576,7 @@ class ActuatorReconciliationService:
"status": status, "status": status,
"last_reconciled_at": now, "last_reconciled_at": now,
"reason": reason, "reason": reason,
"next_action": "Review oder neue Zuordnung erforderlich.", "next_action": "Bei neuen Home-Assistant-Daten automatisch erneut zuordnen.",
} }
), ),
action="archived", action="archived",
@@ -470,8 +623,15 @@ class ActuatorReconciliationService:
confidence = candidate.score / highest if highest else 0.0 confidence = candidate.score / highest if highest else 0.0
margin = candidate.score - second_score if index == 0 else 0.0 margin = candidate.score - second_score if index == 0 else 0.0
auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE
auto_accepted = confidence >= auto_score and ( minimum_score = (
context or margin >= _NUMERIC_MIN_MARGIN _CONTEXT_AUTO_ACCEPT_MIN_SCORE
if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
)
auto_accepted = (
candidate.score >= minimum_score
and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN)
) )
sorted_candidates[index] = candidate.model_copy( sorted_candidates[index] = candidate.model_copy(
update={ update={
@@ -516,6 +676,73 @@ def _filter_candidates(
return result return result
def _selected_context_ids(record: ActuatorRecord) -> set[str]:
result = set(record.assignment.selected_context_entity_ids)
if record.assignment.selected_numeric_entity_id:
result.add(record.assignment.selected_numeric_entity_id)
if record.manual_override is not None:
result.update(record.manual_override.context_entity_ids)
if record.manual_override.numeric_entity_id:
result.add(record.manual_override.numeric_entity_id)
return result
def _manual_context_role(
entity: HaEntitySummary,
discovered: DiscoveredEntity | None,
) -> EntityRole:
if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED:
return discovered.role
if entity.domain == "sensor":
return EntityRole.MEASUREMENT
if entity.domain == "binary_sensor":
return EntityRole.BINARY_CONTEXT
return EntityRole.CONTEXT
def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class in {"motion", "occupancy", "presence"}:
return "01_presence"
if device_class in {"illuminance"}:
return "02_brightness"
if device_class in {"door", "garage_door", "opening", "window"}:
return "03_opening"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if device_class in {"power", "energy", "current", "voltage"}:
return "05_power"
if entity.domain in {"light", "switch"}:
return "06_states"
return f"20_{entity.domain}_{device_class}"
def _is_diagnostic_context(entity: HaEntitySummary) -> bool:
tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(tokens.intersection(_DIAGNOSTIC_TOKENS))
def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary) -> bool:
if (
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
):
return True
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
return True
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS)
and entity.device_class in {"illuminance", "humidity", "temperature"}
)
def _score_candidate( def _score_candidate(
actuator: HaEntitySummary, actuator: HaEntitySummary,
entity: HaEntitySummary, entity: HaEntitySummary,
@@ -531,7 +758,12 @@ def _score_candidate(
if overlap: if overlap:
score += min(0.4, 0.1 * len(overlap)) score += min(0.4, 0.1 * len(overlap))
evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}") evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}")
if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name: if (
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
):
score += 0.35 score += 0.35
evidence.append(f"Gleicher Bereich: {actuator.area_name}") evidence.append(f"Gleicher Bereich: {actuator.area_name}")
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id: if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
@@ -547,15 +779,68 @@ def _score_candidate(
if entity.device_class in preferred_device_classes: if entity.device_class in preferred_device_classes:
score += 0.2 score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}") evidence.append(f"Passende device_class: {entity.device_class}")
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
if not context and entity.unit_of_measurement is not None: if not context and entity.unit_of_measurement is not None:
score += 0.05 score += 0.05
evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}") evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}")
if context and role is EntityRole.BINARY_CONTEXT: if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05 score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.") evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
"illuminance",
"humidity",
"temperature",
}:
score += 0.1
evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.")
return round(min(score, 1.0), 4), evidence return round(min(score, 1.0), 4), evidence
def _merge_manual_candidates(
candidates: list[AssignmentCandidate],
entities: dict[str, HaEntitySummary],
selected_entity_ids: list[str],
*,
role: EntityRole,
) -> list[AssignmentCandidate]:
by_id = {candidate.entity_id: candidate for candidate in candidates}
for entity_id in selected_entity_ids:
existing = by_id.get(entity_id)
if existing is not None:
by_id[entity_id] = existing.model_copy(
update={
"auto_accepted": True,
"confidence": 1.0,
"evidence": [
*existing.evidence,
"Manuell vom Nutzer als relevant festgelegt.",
],
}
)
continue
entity = entities.get(entity_id)
if entity is None:
continue
by_id[entity_id] = AssignmentCandidate(
entity_id=entity.entity_id,
domain=entity.domain,
role=role,
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
score=1.0,
confidence=1.0,
auto_accepted=True,
evidence=["Manuell vom Nutzer als relevant festgelegt."],
)
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
@@ -571,7 +856,7 @@ def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
return frozenset(mapping.get(domain, {"power", "energy", "temperature"})) return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
def _metadata_tokens(entity: HaEntitySummary) -> set[str]: def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False) -> set[str]:
raw_values = [ raw_values = [
entity.entity_id, entity.entity_id,
entity.friendly_name, entity.friendly_name,
@@ -583,7 +868,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
if value is None: if value is None:
continue continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
if len(token) < 3 or token in _STOPWORDS: if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
continue continue
tokens.add(token) tokens.add(token)
return tokens return tokens

View File

@@ -25,6 +25,18 @@ class LifecycleStatus(StrEnum):
ARCHIVED = "archived" ARCHIVED = "archived"
class BehaviorMode(StrEnum):
SHADOW = "shadow"
ACTIVE = "active"
PAUSED = "paused"
class BehaviorStatus(StrEnum):
COLLECTING = "collecting"
TRAINED = "trained"
BLOCKED = "blocked"
class AssignmentCandidate(BaseModel): class AssignmentCandidate(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -71,10 +83,64 @@ class ModelLifecycleState(BaseModel):
last_history_signature: str | None = None last_history_signature: str | None = None
last_history_point_count: int = Field(default=0, ge=0) last_history_point_count: int = Field(default=0, ge=0)
reason: str = "Noch keine Trainingsdaten ausgewertet." reason: str = "Noch keine Trainingsdaten ausgewertet."
next_action: str = "Aktuator auswählen und Zuordnung prüfen." next_action: str = "Aktor auswählen; Kontext und Historie werden automatisch geprüft."
audit: list[LifecycleAuditEntry] = Field(default_factory=list) audit: list[LifecycleAuditEntry] = Field(default_factory=list)
class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100)
minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict)
trigger_entity_id: str | None = None
trigger_from_state: str | None = None
trigger_to_state: str | None = None
source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime
class BehaviorPrediction(BaseModel):
target_state: str
confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime
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):
target_state: str
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
approved_at: datetime | None = None
sample_count: int = Field(default=0, ge=0)
high_confidence_sample_count: int = Field(default=0, ge=0)
patterns: list[BehaviorPattern] = Field(default_factory=list)
prediction: BehaviorPrediction | None = None
last_trained_at: datetime | None = None
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."
class ActuatorRecord(BaseModel): class ActuatorRecord(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True enabled: bool = True
@@ -85,6 +151,7 @@ class ActuatorRecord(BaseModel):
numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list) numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list)
context_candidates: list[AssignmentCandidate] = Field(default_factory=list) context_candidates: list[AssignmentCandidate] = Field(default_factory=list)
lifecycle: ModelLifecycleState lifecycle: ModelLifecycleState
behavior: BehaviorState = Field(default_factory=BehaviorState)
class ReconciliationState(BaseModel): class ReconciliationState(BaseModel):

View File

@@ -4,10 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ManualOverride, ReconciliationState from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
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
@@ -19,11 +21,21 @@ class ConfigureActuatorRequest(BaseModel):
enabled: bool = True enabled: bool = True
class OverrideRequest(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
class ManualAssignmentRequest(BaseModel):
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
context_entity_ids: list[str] = Field(default_factory=list) context_entity_ids: list[str] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=300) note: str | None = Field(default=None, max_length=500)
clear: bool = False
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
@@ -36,6 +48,19 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("/context-options", response_model=list[HaEntitySummary])
def context_options(
request: Request,
actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"),
) -> list[HaEntitySummary]:
if actuator_entity_id is None:
return []
try:
return _service(request).suggest_context_options(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -44,10 +69,12 @@ def list_configured(request: Request) -> list[ActuatorRecord]:
@router.post("", response_model=ActuatorRecord, status_code=201) @router.post("", response_model=ActuatorRecord, status_code=201)
def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord: def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord:
try: try:
return _service(request).configure_actuator( record = _service(request).configure_actuator(
payload.actuator_entity_id, payload.actuator_entity_id,
enabled=payload.enabled, enabled=payload.enabled,
) )
_behavior(request).train(record.actuator_entity_id)
return _behavior(request).evaluate(record.actuator_entity_id)
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
@@ -65,34 +92,107 @@ def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id) _service(request).delete_actuator(actuator_entity_id)
@router.post("/{actuator_entity_id}/override", response_model=ActuatorRecord)
def set_override(
actuator_entity_id: str,
payload: OverrideRequest,
request: Request,
) -> ActuatorRecord:
override = None if payload.clear else ManualOverride(
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
try:
return _service(request).set_override(actuator_entity_id, override)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord)
def reconcile_actuator( def reconcile_actuator(
actuator_entity_id: str, actuator_entity_id: str,
request: Request, request: Request,
) -> ActuatorRecord: ) -> ActuatorRecord:
try: try:
return _service(request).reconcile_actuator(actuator_entity_id, trigger="manual") _service(request).reconcile_actuator(actuator_entity_id, trigger="manual")
_behavior(request).train(actuator_entity_id)
return _behavior(request).evaluate(actuator_entity_id)
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
@router.post("/{actuator_entity_id}/evaluate", response_model=ActuatorRecord)
def evaluate_actuator(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).evaluate(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation(
actuator_entity_id: str,
payload: ActivationRequest,
request: Request,
) -> ActuatorRecord:
try:
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}/assignment", response_model=ActuatorRecord)
def set_manual_assignment(
actuator_entity_id: str,
payload: ManualAssignmentRequest,
request: Request,
) -> ActuatorRecord:
try:
record = _service(request).set_manual_assignment(
actuator_entity_id,
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
_behavior(request).train(record.actuator_entity_id)
return _behavior(request).evaluate(record.actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, 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)
@@ -109,7 +209,10 @@ def run_reconciliation(
request: Request, request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"), trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState: ) -> ReconciliationState:
return _service(request).reconcile_all(trigger=trigger) state = _service(request).reconcile_all(trigger=trigger)
_behavior(request).train_all()
_behavior(request).evaluate_all()
return state
def _service(request: Request) -> ActuatorReconciliationService: def _service(request: Request) -> ActuatorReconciliationService:
@@ -120,3 +223,13 @@ def _service(request: Request) -> ActuatorReconciliationService:
detail="Actuator-Reconciliation nicht initialisiert.", detail="Actuator-Reconciliation nicht initialisiert.",
) )
return service return service
def _behavior(request: Request) -> BehaviorEngine:
engine = getattr(request.app.state, "behavior_engine", None)
if not isinstance(engine, BehaviorEngine):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verhaltenslernen ist nicht initialisiert.",
)
return engine

1
app/behavior/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Learning and prediction for actuator behavior."""

887
app/behavior/engine.py Normal file
View File

@@ -0,0 +1,887 @@
from __future__ import annotations
import logging
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from app.actuators.models import (
ActuatorRecord,
BehaviorMode,
BehaviorPattern,
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
ExecutionEvent,
RelatedAutomation,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
from app.ha.exceptions import HaClientError
from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
_MAX_PATTERNS = 500
_MAX_EXECUTION_EVENTS = 100
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
logger = logging.getLogger(__name__)
class BehaviorEngine:
def __init__(
self,
*,
ha_reader: HaReader,
store: ActuatorStore,
settings: Settings,
) -> None:
self._ha_reader = ha_reader
self._store = store
self._settings = settings
def train_all(self) -> list[ActuatorRecord]:
results: list[ActuatorRecord] = []
for record in self._store.list():
try:
results.append(self.train(record.actuator_entity_id))
except Exception:
logger.exception("Behavior training failed for %s", record.actuator_entity_id)
results.append(record)
return results
def train(self, actuator_entity_id: str) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
raw_context_ids = list(
dict.fromkeys(
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
)
context_ids = [
entity_id for entity_id in raw_context_ids if isinstance(entity_id, str)
]
if not context_ids:
return self._save_behavior(
record,
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.",
}
),
)
start = now - timedelta(days=self._settings.history_days)
history_ids = [actuator_entity_id, *context_ids]
try:
history = {
series.entity_id: series
for series in self._ha_reader.read_state_history(history_ids, start, now)
}
except (HaClientError, ValueError) as exc:
logger.warning("Behavior history unavailable for %s: %s", actuator_entity_id, exc)
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"status": BehaviorStatus.BLOCKED,
"last_trained_at": now,
"reason": f"Home-Assistant-Historie konnte nicht gelesen werden: {exc}",
}
),
)
actuator_history = history.get(actuator_entity_id)
if actuator_history is None or len(actuator_history.points) < 2:
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"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.",
}
),
)
try:
logbook = list(self._ha_reader.read_logbook(actuator_entity_id, start, now))
except (HaClientError, ValueError) as exc:
logger.warning("Logbook unavailable for %s: %s", actuator_entity_id, exc)
logbook = []
patterns = self._build_patterns(
actuator_history=actuator_history,
context_history=history,
context_ids=context_ids,
logbook=logbook,
own_executions=record.behavior.execution_events,
)
trusted_actions = sum(
1 for pattern in patterns if pattern.source in {"user", "automation"}
)
status = (
BehaviorStatus.TRAINED
if len(patterns) >= self._settings.min_behavior_actions
else BehaviorStatus.COLLECTING
)
reason = (
f"{len(patterns)} Handlungen mit automatisch erfasstem Kontext gelernt."
if status is BehaviorStatus.TRAINED
else (
f"{len(patterns)} von mindestens {self._settings.min_behavior_actions} "
"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,
}
)
return self._save_behavior(record, behavior)
def evaluate_all(self) -> list[ActuatorRecord]:
results: list[ActuatorRecord] = []
for record in self._store.list():
try:
results.append(self.evaluate(record.actuator_entity_id))
except Exception:
logger.exception("Behavior evaluation failed for %s", record.actuator_entity_id)
results.append(record)
return results
def evaluate(
self,
actuator_entity_id: str,
*,
context_state_overrides: dict[str, str | None] | None = None,
context_changed_at_overrides: dict[str, datetime | None] | None = None,
current_entities: Sequence[HaEntitySummary] | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
if current_entities is None:
try:
current_entities = self._ha_reader.read_entities()
except HaClientError as exc:
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"last_evaluated_at": now,
"prediction": None,
"reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}",
}
),
)
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id)
if actuator is None:
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"last_evaluated_at": now,
"prediction": None,
"reason": "Aktor ist aktuell nicht in Home Assistant verfügbar.",
}
),
)
current_context = {
entity_id: entities[entity_id].state
for entity_id in (
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
if entity_id and entity_id in entities and entities[entity_id].state is not None
}
current_context_changed_at = {
entity_id: entities[entity_id].last_changed
for entity_id in current_context
}
selected_context_ids = {
entity_id
for entity_id in (
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
if entity_id
}
for entity_id, state in (context_state_overrides or {}).items():
if entity_id in selected_context_ids and state is not None:
current_context[entity_id] = state
for entity_id, changed_at in (context_changed_at_overrides or {}).items():
if entity_id in current_context:
current_context_changed_at[entity_id] = changed_at or now
prediction = predict_behavior(
record.behavior.patterns,
current_context=current_context,
current_context_changed_at=current_context_changed_at,
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
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,
"prediction": prediction,
"reason": (
prediction.reason
if prediction is not None
else "Aktuell ist kein gelerntes Handlungsmuster fällig."
),
}
)
if (
prediction is not None
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,
prediction.target_state,
)
):
domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state)
if service is not None:
try:
self._ha_reader.call_service(
domain,
service,
{"entity_id": actuator_entity_id},
)
except (HaClientError, ValueError) as exc:
logger.error(
"Predicted action failed for %s: %s",
actuator_entity_id,
exc,
)
behavior = behavior.model_copy(
update={
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
}
)
return self._save_behavior(record, behavior)
event = ExecutionEvent(
target_state=prediction.target_state,
executed_at=now,
)
behavior = behavior.model_copy(
update={
"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,
event,
][-_MAX_EXECUTION_EVENTS:],
"reason": (
f"Vorhersage mit {prediction.confidence:.0%} Sicherheit ausgeführt."
),
}
)
else:
behavior = behavior.model_copy(
update={
"reason": (
f"Der vorhergesagte Zustand {prediction.target_state!r} "
"ist für autonomes Schalten nicht freigegeben."
)
}
)
return self._save_behavior(record, behavior)
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]
if domain not in _SAFE_ACTIVE_DOMAINS:
raise ValueError(
f"Automatisches Schalten ist für die Domain {domain} nicht freigegeben."
)
if record.behavior.status is not BehaviorStatus.TRAINED:
raise ValueError("Das Verhaltensmodell hat noch nicht genügend Handlungen gelernt.")
if not record.behavior.activation_ready:
raise ValueError(record.behavior.activation_reason)
mode = BehaviorMode.ACTIVE
approved_at = now
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; 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,
*,
actuator_history: StateHistorySeries,
context_history: dict[str, StateHistorySeries],
context_ids: list[str],
logbook: list[LogbookEntry],
own_executions: list[ExecutionEvent],
) -> list[BehaviorPattern]:
patterns: list[BehaviorPattern] = []
previous_state = actuator_history.points[0].state
for point in actuator_history.points[1:]:
if point.state == previous_state:
continue
previous_state = point.state
if _matches_own_execution(point, own_executions):
continue
source, weight = _action_source(point, logbook)
trigger = _recent_context_transition(
context_history,
context_ids,
point.timestamp,
)
contexts = {
entity_id: state
for entity_id in context_ids
if (state := _state_at(context_history.get(entity_id), point.timestamp)) is not None
}
local = point.timestamp.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=point.state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states=contexts,
trigger_entity_id=trigger[0] if trigger else None,
trigger_from_state=trigger[1] if trigger else None,
trigger_to_state=trigger[2] if trigger else None,
source=source,
weight=weight,
observed_at=point.timestamp,
)
)
return patterns
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,
record: ActuatorRecord,
behavior: BehaviorState,
) -> ActuatorRecord:
updated = record.model_copy(
update={
"behavior": behavior,
"updated_at": datetime.now(timezone.utc),
}
)
return self._store.upsert(updated)
def handle_state_change(
self,
entity_id: str,
new_state: dict[str, object] | None,
*,
current_entities: Sequence[HaEntitySummary] | None = None,
) -> None:
"""Wird bei jedem HA-State-Change aufgerufen und löst sofortige Vorhersage aus.
- Wenn entity_id ein Aktor ist: evaluate() direkt.
- Wenn entity_id ein Kontext-Entity ist: alle betroffenen Aktoren evaluieren.
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
"""
# Aktor direkt evaluieren
for record in self._store.list():
if record.actuator_entity_id == entity_id:
try:
self.evaluate(record.actuator_entity_id, current_entities=current_entities)
except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
return
event_state = _event_state(new_state)
event_changed_at = _event_changed_at(new_state) or datetime.now(timezone.utc)
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
affected_actuators = [
record.actuator_entity_id
for record in self._store.list()
if (
record.assignment.selected_numeric_entity_id == entity_id
or entity_id in record.assignment.selected_context_entity_ids
)
]
for actuator_entity_id in affected_actuators:
try:
self.evaluate(
actuator_entity_id,
context_state_overrides={entity_id: event_state},
context_changed_at_overrides={entity_id: event_changed_at},
current_entities=current_entities,
)
except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
def _event_state(new_state: dict[str, object] | None) -> str | None:
if not isinstance(new_state, dict):
return None
state = new_state.get("state")
return state if isinstance(state, str) else None
def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
if not isinstance(new_state, dict):
return None
value = new_state.get("last_changed") or new_state.get("last_updated")
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def predict_behavior(
patterns: list[BehaviorPattern],
*,
current_context: dict[str, str | None],
now: datetime,
min_support: int,
window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None,
causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None:
if not patterns:
return None
local = now.astimezone(ZoneInfo(timezone_name))
minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {}
causal_support_by_state: dict[str, int] = {}
for pattern in patterns:
if pattern.trigger_entity_id and pattern.trigger_to_state:
trigger_changed_at = changed_at.get(pattern.trigger_entity_id)
trigger_age = (
(now - trigger_changed_at).total_seconds()
if trigger_changed_at is not None
else None
)
if not (
current_context.get(pattern.trigger_entity_id)
== pattern.trigger_to_state
and trigger_age is not None
and 0 <= trigger_age <= causal_window_seconds
):
continue
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(
current_context[entity_id] == expected
for entity_id, expected in comparable
)
/ len(comparable)
if comparable
else 0.5
)
score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score)
causal_support_by_state[pattern.target_state] = (
causal_support_by_state.get(pattern.target_state, 0) + 1
)
continue
distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
if distance > window_minutes:
continue
time_score = 1.0 - (distance / max(window_minutes, 1))
weekday_score = (
1.0
if local.weekday() == pattern.weekday
else 0.5
if (local.weekday() >= 5) == (pattern.weekday >= 5)
else 0.0
)
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
/ len(comparable)
if comparable
else 0.5
)
score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
)
by_state.setdefault(pattern.target_state, []).append(score)
if not by_state:
return None
target_state, scores = max(
by_state.items(),
key=lambda item: (sum(item[1]), len(item[1]), item[0]),
)
support = len(scores)
causal_support = causal_support_by_state.get(target_state, 0)
confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
if confidence <= 0:
return None
return BehaviorPrediction(
target_state=target_state,
confidence=round(confidence, 4),
generated_at=now,
matching_patterns=support,
reason=(
(
f"{causal_support} historische Handlungen folgten demselben "
"frischen Sensorwechsel."
)
if causal_support
else f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
),
)
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
return None
def _state_at(series: StateHistorySeries | None, timestamp: datetime) -> str | None:
if series is None:
return None
state: str | None = None
for point in series.points:
if point.timestamp > timestamp:
break
state = point.state
return state
def _action_source(
point: StateHistoryPoint,
logbook: list[LogbookEntry],
) -> tuple[str, float]:
nearest = min(
logbook,
key=lambda item: abs(item.timestamp - point.timestamp),
default=None,
)
if nearest is None or abs(nearest.timestamp - point.timestamp) > _ACTION_LOGBOOK_TOLERANCE:
return "physical_or_unknown", 0.7
if nearest.context_user_id:
return "user", 1.0
if nearest.context_domain in _AUTOMATION_CONTEXT_DOMAINS:
return "automation", 1.0
return "physical_or_unknown", 0.7
def _matches_own_execution(
point: StateHistoryPoint,
own_executions: list[ExecutionEvent],
) -> bool:
return any(
event.target_state == point.state
and abs(event.executed_at - point.timestamp) <= _OWN_ACTION_TOLERANCE
for event in own_executions
)
def _recent_context_transition(
history: dict[str, StateHistorySeries],
context_ids: list[str],
timestamp: datetime,
) -> tuple[str, str, str] | None:
nearest: tuple[timedelta, str, str, str] | None = None
for entity_id in context_ids:
series = history.get(entity_id)
if series is None:
continue
previous_state: str | None = None
for point in series.points:
if point.timestamp > timestamp:
break
if previous_state is not None and point.state != previous_state:
age = timestamp - point.timestamp
if age <= _CONTEXT_TRIGGER_TOLERANCE and (
nearest is None or age < nearest[0]
):
nearest = (age, entity_id, previous_state, point.state)
previous_state = point.state
if nearest is None:
return None
return nearest[1], nearest[2], nearest[3]
def _circular_minute_distance(left: int, right: int) -> int:
direct = abs(left - right)
return min(direct, 1440 - direct)

View File

@@ -15,6 +15,12 @@ class Settings:
min_training_points: int = 24 min_training_points: int = 24
retrain_stale_hours: int = 24 retrain_stale_hours: int = 24
reconcile_interval_seconds: int = 900 reconcile_interval_seconds: int = 900
min_behavior_actions: int = 3
prediction_confidence: float = 0.82
prediction_window_minutes: int = 30
prediction_interval_seconds: int = 60
execution_cooldown_seconds: int = 900
timezone: str = "Europe/Berlin"
@property @property
def ha_configured(self) -> bool: def ha_configured(self) -> bool:
@@ -34,4 +40,19 @@ def load_settings() -> Settings:
reconcile_interval_seconds=max( reconcile_interval_seconds=max(
60, int(os.getenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "900")) 60, int(os.getenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "900"))
), ),
min_behavior_actions=max(2, int(os.getenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "3"))),
prediction_confidence=max(
0.5,
min(0.99, float(os.getenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.82"))),
),
prediction_window_minutes=max(
5, min(120, int(os.getenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "30")))
),
prediction_interval_seconds=max(
30, int(os.getenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "60"))
),
execution_cooldown_seconds=max(
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
),
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
) )

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import json import json
import re import re
from typing import Any
from urllib.parse import quote from urllib.parse import quote
import requests import requests
@@ -19,7 +20,9 @@ from app.ha.exceptions import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$") _ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_SERVICE_PART_PATTERN = re.compile(r"^[a-z0-9_]+$")
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60 _MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
_METADATA_BATCH_SIZE = 200
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -84,11 +87,72 @@ class HaClient:
) )
return payload return payload
def get_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[object]:
self._validate_period([entity_id], start_time, end_time)
start = quote(start_time.isoformat(), safe=":+")
payload = self._get_json(
f"/api/logbook/{start}",
params={
"entity": entity_id,
"end_time": end_time.isoformat(),
},
)
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Logbook-Antwort von Home Assistant hat unerwartetes Format."
)
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,
service: str,
service_data: dict[str, object],
) -> list[object]:
if not _SERVICE_PART_PATTERN.fullmatch(domain):
raise ValueError("Ungültige Service-Domain.")
if not _SERVICE_PART_PATTERN.fullmatch(service):
raise ValueError("Ungültiger Service-Name.")
payload = self._post_json(f"/api/services/{domain}/{service}", service_data)
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Service-Antwort von Home Assistant hat unerwartetes Format."
)
return payload
def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]: def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
if not entity_ids: if not entity_ids:
return {} return {}
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids): if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
raise ValueError("entity_id enthält ein ungültiges Format.") raise ValueError("entity_id enthält ein ungültiges Format.")
result: dict[str, dict[str, str | None]] = {}
for start in range(0, len(entity_ids), _METADATA_BATCH_SIZE):
result.update(
self._list_entity_metadata_batch(entity_ids[start:start + _METADATA_BATCH_SIZE])
)
return result
def _list_entity_metadata_batch(
self,
entity_ids: list[str],
) -> dict[str, dict[str, str | None]]:
template = _metadata_template(entity_ids) template = _metadata_template(entity_ids)
rendered = self._post_text("/api/template", {"template": template}) rendered = self._post_text("/api/template", {"template": template})
try: try:
@@ -153,6 +217,36 @@ class HaClient:
return payload return payload
def _post_json(self, path: str, payload: Any) -> object:
try:
response = self._session.post(
f"{self._settings.url.rstrip('/')}{path}",
json=payload,
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc:
raise HaHttpError(
getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in (401, 403):
raise HaAuthError(
response.status_code,
"Authentifizierung bei Home Assistant fehlgeschlagen.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
try:
return response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant ist kein gültiges JSON."
) from exc
def _post_text(self, path: str, payload: dict[str, str]) -> str: def _post_text(self, path: str, payload: dict[str, str]) -> str:
try: try:
response = self._session.post( response = self._session.post(
@@ -179,6 +273,25 @@ class HaClient:
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
return response.text return response.text
@staticmethod
def _validate_period(
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> None:
if not entity_ids:
raise ValueError("Mindestens eine entity_id ist erforderlich.")
if len(entity_ids) > 100:
raise ValueError("Es können höchstens 100 Entities abgefragt werden.")
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
raise ValueError("entity_id enthält ein ungültiges Format.")
if start_time.tzinfo is None or end_time.tzinfo is None:
raise ValueError("start_time und end_time müssen eine Zeitzone enthalten.")
if end_time <= start_time:
raise ValueError("end_time muss nach start_time liegen.")
if (end_time - start_time).total_seconds() > _MAX_HISTORY_SECONDS:
raise ValueError("History-Abfragen sind auf 31 Tage begrenzt.")
def _metadata_template(entity_ids: list[str]) -> str: def _metadata_template(entity_ids: list[str]) -> str:
ids = json.dumps(entity_ids, ensure_ascii=True) ids = json.dumps(entity_ids, ensure_ascii=True)

View File

@@ -82,18 +82,11 @@ _BINARY_CONTEXT_CLASSES = frozenset({
"window", "window",
}) })
_ACTUATOR_DOMAINS = frozenset({ _ACTUATOR_DOMAINS = frozenset({
"button",
"climate",
"cover", "cover",
"fan", "fan",
"humidifier", "humidifier",
"light", "light",
"lock",
"scene",
"select",
"siren",
"switch", "switch",
"valve",
}) })
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"}) _CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"}) _LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})

View File

@@ -18,6 +18,25 @@ class EntityHistorySeries(BaseModel):
points: list[NumericHistoryPoint] points: list[NumericHistoryPoint]
class StateHistoryPoint(BaseModel):
timestamp: datetime
state: str
class StateHistorySeries(BaseModel):
entity_id: str
points: list[StateHistoryPoint]
class LogbookEntry(BaseModel):
entity_id: str
timestamp: datetime
message: str = ""
context_user_id: str | None = None
context_domain: str | None = None
context_service: str | None = None
def normalize_history_payload(payload: object) -> list[EntityHistorySeries]: def normalize_history_payload(payload: object) -> list[EntityHistorySeries]:
if not isinstance(payload, list): if not isinstance(payload, list):
raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.") raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.")
@@ -33,6 +52,68 @@ def normalize_history_payload(payload: object) -> list[EntityHistorySeries]:
return sorted(normalized, key=lambda item: item.entity_id) return sorted(normalized, key=lambda item: item.entity_id)
def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]:
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.")
normalized: list[StateHistorySeries] = []
for raw_series in payload:
if not isinstance(raw_series, list):
raise HaUnexpectedPayloadError("History-Serie muss eine Liste sein.")
entity_id: str | None = None
points: list[StateHistoryPoint] = []
for raw_entry in raw_series:
if not isinstance(raw_entry, dict):
raise HaUnexpectedPayloadError("History-Eintrag muss ein Objekt sein.")
raw_entity_id = raw_entry.get("entity_id")
if raw_entity_id is not None:
if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
raise HaUnexpectedPayloadError(
"History-Eintrag enthält ungültige entity_id."
)
if entity_id is not None and entity_id != raw_entity_id:
raise HaUnexpectedPayloadError("History-Serie enthält mehrere Entities.")
entity_id = raw_entity_id
raw_state = raw_entry.get("state")
if not isinstance(raw_state, str) or raw_state in {"unknown", "unavailable"}:
continue
if entity_id is None:
raise HaUnexpectedPayloadError("History-Serie enthält keine entity_id.")
timestamp = _parse_timestamp(
raw_entry.get("last_changed") or raw_entry.get("last_updated")
)
if not points or points[-1].state != raw_state:
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state))
if entity_id is not None and points:
points.sort(key=lambda point: point.timestamp)
normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
return sorted(normalized, key=lambda item: item.entity_id)
def normalize_logbook_payload(payload: object, entity_id: str) -> list[LogbookEntry]:
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("Logbook-Payload muss eine Liste sein.")
entries: list[LogbookEntry] = []
for raw_entry in payload:
if not isinstance(raw_entry, dict):
raise HaUnexpectedPayloadError("Logbook-Eintrag muss ein Objekt sein.")
raw_entity_id = raw_entry.get("entity_id")
if raw_entity_id != entity_id:
continue
entries.append(
LogbookEntry(
entity_id=entity_id,
timestamp=_parse_timestamp(raw_entry.get("when")),
message=str(raw_entry.get("message") or ""),
context_user_id=_optional_string(raw_entry.get("context_user_id")),
context_domain=_optional_string(
raw_entry.get("context_domain") or raw_entry.get("domain")
),
context_service=_optional_string(raw_entry.get("context_service")),
)
)
return sorted(entries, key=lambda item: item.timestamp)
def _normalize_series(raw_series: list[object]) -> EntityHistorySeries | None: def _normalize_series(raw_series: list[object]) -> EntityHistorySeries | None:
entity_id: str | None = None entity_id: str | None = None
points: list[NumericHistoryPoint] = [] points: list[NumericHistoryPoint] = []
@@ -89,3 +170,9 @@ def _parse_timestamp(value: object) -> datetime:
if parsed.tzinfo is None: if parsed.tzinfo is None:
raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.") raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.")
return parsed return parsed
def _optional_string(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -14,6 +16,8 @@ class HaState(BaseModel):
class HaEntitySummary(BaseModel): class HaEntitySummary(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
state: str | None = None
last_changed: datetime | None = None
state_class: str | None = None state_class: str | None = None
device_class: str | None = None device_class: str | None = None
unit_of_measurement: str | None = None unit_of_measurement: str | None = None
@@ -22,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,16 +1,24 @@
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
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError, HaHttpError
from app.ha.client import HaClient from app.ha.client import HaClient
from app.ha.discovery import DiscoveredEntity, discover_entities from app.ha.discovery import DiscoveredEntity, discover_entities
from app.ha.history import EntityHistorySeries, normalize_history_payload from app.ha.history import (
from app.ha.models import HaEntitySummary EntityHistorySeries,
LogbookEntry,
StateHistorySeries,
normalize_history_payload,
normalize_logbook_payload,
normalize_state_history_payload,
)
from app.ha.models import HaAutomationSummary, HaEntitySummary
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,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()
@@ -45,6 +58,8 @@ class HaReader:
HaEntitySummary( HaEntitySummary(
entity_id=entity_id, entity_id=entity_id,
domain=domain, domain=domain,
state=_optional_str(item.get("state")),
last_changed=_optional_datetime(item.get("last_changed")),
state_class=_optional_str(attributes.get("state_class")), state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")), device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")), unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
@@ -77,8 +92,140 @@ class HaReader:
payload = self._client.get_history(entity_ids, start_time, end_time) payload = self._client.get_history(entity_ids, start_time, end_time)
return normalize_history_payload(payload) return normalize_history_payload(payload)
def read_state_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> Sequence[StateHistorySeries]:
payload = self._client.get_history(entity_ids, start_time, end_time)
return normalize_state_history_payload(payload)
def read_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> Sequence[LogbookEntry]:
payload = self._client.get_logbook(entity_id, start_time, end_time)
return normalize_logbook_payload(payload, entity_id)
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> 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 HaHttpError as exc:
if exc.status_code == 404:
logger.info(
"Automation config not exposed by Home Assistant for %s.",
raw_entity_id,
)
continue
logger.warning(
"Automation config unavailable for %s: %s",
raw_entity_id,
exc,
)
continue
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 == "":
return None return None
return str(value) return str(value)
def _optional_datetime(value: object) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
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

View File

@@ -1,9 +1,13 @@
import asyncio import asyncio
import json
import logging
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
import websockets
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -12,28 +16,44 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router from app.api.v1.actuators import router as actuators_router
from app.api.v1.entities import router as entities_router from app.api.v1.entities import router as entities_router
from app.api.v1.automations import router as automations_router from app.behavior.engine import BehaviorEngine
from app.automations.store import AutomationStore
from app.config import load_settings from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes from backend.routes.ml import init_ml_routes
logger = logging.getLogger(__name__)
class _WsStatus:
"""Einfacher Status-Tracker für den WebSocket-Listener.
Wird als Attribut an app.state gehängt und enthält:
- status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
- error: str | None (Fehlermeldung bei status=error)
"""
def __init__(self) -> None:
self.status: str = "disconnected"
self.error: str | None = None
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None client: HaClient | None = None
reconcile_task: asyncio.Task[None] | None = None reconcile_task: asyncio.Task[None] | None = None
event_listener_task: asyncio.Task[None] | None = None
fallback_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store) app.state.registry = ModelRegistry(settings.model_store)
app.state.automation_store = AutomationStore(settings.automation_store)
app.state.actuator_store = ActuatorStore(settings.actuator_store) app.state.actuator_store = ActuatorStore(settings.actuator_store)
if hasattr(app.state, "ha_reader"): if hasattr(app.state, "ha_reader"):
del app.state.ha_reader del app.state.ha_reader
if hasattr(app.state, "actuator_service"): if hasattr(app.state, "actuator_service"):
del app.state.actuator_service del app.state.actuator_service
if hasattr(app.state, "behavior_engine"):
del app.state.behavior_engine
if settings.ha_configured: if settings.ha_configured:
client = HaClient( client = HaClient(
settings=HaClientSettings( settings=HaClientSettings(
@@ -48,8 +68,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
registry=app.state.registry, registry=app.state.registry,
settings=settings, settings=settings,
) )
app.state.behavior_engine = BehaviorEngine(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
settings=settings,
)
app.state.ws_status = _WsStatus()
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup") await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
await asyncio.to_thread(app.state.behavior_engine.train_all)
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
reconcile_task = asyncio.create_task(_periodic_reconciliation(app)) reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
fallback_task = asyncio.create_task(_fallback_prediction(app))
try: try:
yield yield
finally: finally:
@@ -57,6 +87,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task.cancel() reconcile_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await reconcile_task await reconcile_task
if event_listener_task is not None:
event_listener_task.cancel()
with suppress(asyncio.CancelledError):
await event_listener_task
if fallback_task is not None:
fallback_task.cancel()
with suppress(asyncio.CancelledError):
await fallback_task
if client is not None: if client is not None:
client.close() client.close()
@@ -64,13 +102,12 @@ 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.4.0", version="0.7.10",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(entities_router) app.include_router(entities_router)
app.include_router(automations_router)
app.include_router(actuators_router) app.include_router(actuators_router)
init_ml_routes(app, model_store=app.state.settings.model_store) init_ml_routes(app, model_store=app.state.settings.model_store)
@@ -82,10 +119,26 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/health/websocket")
def websocket_health() -> dict[str, object]:
"""Gibt den aktuellen Status des WebSocket-Listeners zurück.
Antwort:
- status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
- error: str | None (nur bei status=error)
"""
ws_status = getattr(app.state, "ws_status", None)
if ws_status is None:
return {"status": "unavailable", "error": "WebSocket-Listener nicht initialisiert"}
return {"status": ws_status.status, "error": ws_status.error}
@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:
@@ -95,3 +148,200 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):
continue continue
await asyncio.to_thread(service.reconcile_all, "scheduled") await asyncio.to_thread(service.reconcile_all, "scheduled")
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all)
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
"""Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus."""
settings = app.state.settings
engine = app.state.behavior_engine
ha_reader = getattr(app.state, "ha_reader", None)
store = app.state.actuator_store
if (
not isinstance(engine, BehaviorEngine)
or not isinstance(store, ActuatorStore)
or not isinstance(ha_reader, HaReader)
):
logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert")
ws_status = getattr(app.state, "ws_status", None)
if ws_status is not None:
ws_status.status = "error"
ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert"
return
state_cache: dict[str, HaEntitySummary] = {}
ha_url = str(settings.ha_url).rstrip("/")
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None)
while True:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(ws_url, ping_interval=None) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required":
logger.error("Unerwartete WebSocket-Authentifizierungsaufforderung")
if ws_status is not None:
ws_status.status = "error"
ws_status.error = "Unerwartete Authentifizierungsaufforderung"
await asyncio.sleep(5)
continue
await websocket.send(json.dumps({"type": "auth", "access_token": auth_token}))
auth_result_msg = await websocket.recv()
auth_result_data = json.loads(auth_result_msg)
if auth_result_data.get("type") != "auth_ok":
logger.error("WebSocket-Authentifizierung fehlgeschlagen")
if ws_status is not None:
ws_status.status = "error"
ws_status.error = "Authentifizierung fehlgeschlagen"
await asyncio.sleep(5)
continue
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
if ws_status is not None:
ws_status.status = "connected"
ws_status.error = None
# Auf alle State Changes subscriben
subscribe_msg = {
"id": 1,
"type": "subscribe_events",
"event_type": "state_changed"
}
await websocket.send(json.dumps(subscribe_msg))
while True:
message = await websocket.recv()
try:
data = json.loads(message)
if data.get("type") != "event":
continue
event = data.get("event", {})
if event.get("event_type") != "state_changed":
continue
event_data = event.get("data", {})
if not isinstance(event_data, dict):
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
continue
entity_id = event_data.get("entity_id")
if not entity_id:
continue
new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state)
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread(
engine.handle_state_change,
entity_id,
new_state,
current_entities=list(state_cache.values()),
)
except json.JSONDecodeError:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc:
logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
except (websockets.exceptions.ConnectionClosed, OSError) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
if ws_status is not None:
ws_status.status = "reconnecting"
ws_status.error = str(exc)
await asyncio.sleep(5)
except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None:
ws_status.status = "error"
ws_status.error = str(exc)
await asyncio.sleep(5)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
async def _fallback_prediction(app: FastAPI) -> None:
"""Periodische Vorhersage als Fallback, wenn WebSocket-Listener nicht verbunden ist.
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
"""
while True:
ws_status = getattr(app.state, "ws_status", None)
websocket_connected = ws_status is not None and ws_status.status == "connected"
await asyncio.sleep(
app.state.settings.prediction_interval_seconds
if websocket_connected
else min(5, app.state.settings.prediction_interval_seconds)
)
# Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None)
if ws_status is None or ws_status.status != "connected":
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
logger.debug(
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
ws_status.status if ws_status else "unavailable",
)
await asyncio.to_thread(engine.evaluate_all)
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:
return {entity.entity_id: entity for entity in reader.read_entities()}
def _update_ha_state_cache(
state_cache: dict[str, HaEntitySummary],
entity_id: str,
new_state: object,
) -> None:
if not isinstance(new_state, dict):
state_cache.pop(entity_id, None)
return
state_cache[entity_id] = _ha_entity_from_event(
entity_id,
new_state,
state_cache.get(entity_id),
)
def _ha_entity_from_event(
entity_id: str,
new_state: dict[str, object],
previous: HaEntitySummary | None,
) -> HaEntitySummary:
attributes = new_state.get("attributes")
attr = attributes if isinstance(attributes, dict) else {}
state_class = _optional_event_string(attr.get("state_class"))
device_class = _optional_event_string(attr.get("device_class"))
unit_of_measurement = _optional_event_string(attr.get("unit_of_measurement"))
friendly_name = _optional_event_string(attr.get("friendly_name"))
return HaEntitySummary(
entity_id=entity_id,
domain=entity_id.split(".", 1)[0],
state=_optional_event_string(new_state.get("state")),
last_changed=_event_datetime(new_state.get("last_changed"))
or _event_datetime(new_state.get("last_updated")),
state_class=state_class or (previous.state_class if previous else None),
device_class=device_class or (previous.device_class if previous else None),
unit_of_measurement=unit_of_measurement
or (previous.unit_of_measurement if previous else None),
friendly_name=friendly_name or (previous.friendly_name if previous else None),
area_id=previous.area_id if previous else None,
area_name=previous.area_name if previous else None,
device_id=previous.device_id if previous else None,
device_name=previous.device_name if previous else None,
)
def _optional_event_string(value: object) -> str | None:
return value if isinstance(value, str) else None
def _event_datetime(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed

View File

@@ -5,162 +5,410 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title> <title>SillyHome Next</title>
<style> <style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; } :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
body { margin: 0; } body { margin: 0; font-size:16px; }
header { padding: 20px; background: linear-gradient(135deg,#142b3a,#193f36); } header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
h1,h2,h3 { margin: 0 0 12px; } h1,h2,h3 { margin: 0 0 12px; }
header p { margin: 4px 0; color: #b9c9d6; } header p { margin: 5px 0; color: #c3d1dc; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; } main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; } section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
.wide { grid-column: 1 / -1; } .wide { grid-column: 1 / -1; }
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
.quick-nav a.primary { background:#23715b; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
.step p { margin:5px 0; }
.ok { color: #66dfa9; } .ok { color: #66dfa9; }
.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; }
input,select,textarea,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 9px; background: #101820; color: #fff; } select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } select[multiple] { min-height:190px; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; } button.secondary { background: #37495c; }
pre { white-space: pre-wrap; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; } button.danger { background: #7b3434; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; } button.compact { width:auto; min-width:120px; margin-right:8px; }
td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; } table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; } ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; } .notice { border-left: 4px solid #66dfa9; padding-left: 10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:8px; } .grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; } .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; } .chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
.muted { color:#9fb0be; }
.card-list { display:grid; gap:12px; }
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
.entity-id { overflow-wrap:anywhere; font-weight:800; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; }
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; }
@media (max-width: 760px) {
header { padding:18px 14px; }
header h1 { font-size:1.55rem; }
main { display:block; padding:10px; }
section { margin-bottom:12px; padding:14px; border-radius:14px; }
.steps { grid-template-columns:1fr; }
.grid-two { grid-template-columns:1fr; }
.metric-grid { grid-template-columns:1fr 1fr; }
.actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
.quick-nav { padding:8px 10px; }
.quick-nav a { padding:10px 11px; }
}
@media (max-width: 430px) {
.metric-grid { grid-template-columns:1fr; }
body { font-size:15px; }
}
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>SillyHome Next</h1> <h1>SillyHome Next</h1>
<p>Aktuator-zentrierte Home-Assistant-Analyse mit nachvollziehbarer Sensorzuordnung und kontrolliertem Modell-Lebenszyklus.</p> <p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
<p class="notice">Sicherheitsmodus: SillyHome führt niemals selbst Aktor-Services aus. Automationen bleiben manuell freizugebende YAML-Entwürfe.</p> <p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</header> </header>
<nav class="quick-nav" aria-label="Schnellnavigation">
<a class="primary" href="#choose">Gerät wählen</a>
<a href="#observed">Beobachtet</a>
<a href="#detail">Details</a>
<a href="#status-section">Status</a>
<a href="#guide">Ablauf</a>
</nav>
<main> <main>
<section> <section class="wide" id="guide">
<h2>So gehst du vor</h2>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Unten im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
</div>
</section>
<section id="status-section">
<h2>Systemstatus</h2> <h2>Systemstatus</h2>
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
<div id="status">Prüfung läuft ...</div> <div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div> <div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Neu laden</button> <button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
<button onclick="runReconciliation()">Reconciliation ausführen</button>
</section> </section>
<section> <section id="choose">
<h2>Aktuator wählen</h2> <h2>1. Gerät zum Lernen auswählen</h2>
<label for="actuator-select">Home-Assistant-Aktor</label> <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>
<select id="actuator-select"></select> <label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
<button onclick="configureActuator()">Aktuator übernehmen</button> <input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
<pre id="actuator-config-result">Noch kein Aktuator konfiguriert.</pre> <datalist id="actuator-options"></datalist>
<div class="inline-controls">
<div>
<label for="actuator-domain-filter">Typ</label>
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
<option value="">Alle steuerbaren Typen</option>
<option value="light">Lichter</option>
<option value="switch">Schalter / Helper</option>
<option value="cover">Rollläden / Cover</option>
<option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option>
</select>
</div>
<div>
<label for="actuator-search">Liste durchsuchen</label>
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
</div>
</div>
<label for="actuator-select">Oder aus Liste wählen</label>
<select id="actuator-select" onchange="selectActuatorFromList()">
<option value="">Geräteliste wird geladen ...</option>
</select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
</section> </section>
<section class="wide"> <section class="wide" id="observed">
<h2>Konfigurierte Aktuatoren</h2> <h2>2. Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
<div id="configured-actuators">Noch nicht geladen.</div> <div id="configured-actuators">Noch nicht geladen.</div>
</section> </section>
<section class="wide"> <section class="wide" id="detail">
<h2>Zuordnung und Modellstatus</h2> <h2>3. Lernfortschritt und Freigabe</h2>
<div id="actuator-detail">Einen konfigurierten Aktuator auswählen.</div> <p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
</section> <div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
<section class="wide">
<h2>Automation-Entwurf</h2>
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p>
<div class="grid-two">
<div><label for="alias">Name</label><input id="alias" value="Licht bei Dunkelheit"></div>
<div><label for="trigger">Trigger-Entity</label><input id="trigger" placeholder="sensor.flur_illuminance"></div>
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div>
<div><label for="service">Dienst</label><select id="service"><option>light.turn_on</option><option>light.turn_off</option><option>switch.turn_on</option><option>switch.turn_off</option></select></div>
<div><label for="target">Ziel-Entity</label><input id="target" placeholder="light.flur"></div>
</div>
<button onclick="createProposal()">Entwurf speichern</button>
<button class="secondary" onclick="loadProposals()">Entwürfe aktualisieren</button>
<div id="proposals"></div>
</section> </section>
</main> </main>
<script> <script>
const pretty = value => JSON.stringify(value, null, 2); const escapeHtml = value => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
let currentActuatorId = null; let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
let manualContextState = {options: [], selected: new Set()};
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = await response.json().catch(() => ({})); const body = response.status === 204 ? null : await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.detail || `${response.status} ${response.statusText}`); if (!response.ok) throw new Error(body?.detail || `${response.status} ${response.statusText}`);
return body; return body;
} }
function lifecycleLabel(record) {
if (record.behavior.status === "trained") return "Kontext erkannt";
const labels = {
trained: "lernt",
pending_history: "sammelt Historie",
pending_assignment: "sucht Kontext",
review_required: "geringe Zuordnungssicherheit",
archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden",
};
return labels[record.lifecycle.status] || record.lifecycle.status;
}
function statusClass(record) { function statusClass(record) {
if (record.assignment.review_required) return "warn"; if (record.behavior.status === "trained") return "ok";
if (record.lifecycle.status === "trained") return "ok"; if (record.lifecycle.status === "trained") return "ok";
if (record.lifecycle.status === "review_required" || record.lifecycle.status === "invalid") return "warn"; if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
return "bad"; return "bad";
} }
function renderEvidence(evidence) { function behaviorLabel(record) {
return evidence.length ? `<ul>${evidence.map(item => `<li>${item}</li>`).join("")}</ul>` : "<span class='bad'>Keine Evidenz</span>"; if (record.behavior.mode === "active") return "aktiv freigegeben";
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
if (record.behavior.status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen";
}
function predictionLabel(record) {
return record.behavior.prediction
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
: "Keine fällige Aktion";
}
function entityLabel(entity) {
const area = entity.area_name || "Ohne Bereich";
const name = entity.friendly_name || entity.entity_id;
return `${area} - ${name} (${entity.entity_id})`;
}
function normalizedSearch(value) {
return String(value || "").toLowerCase().replaceAll("_", " ");
}
function matchesSearch(entity, query) {
if (!query) return true;
return normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
entity.device_class,
entity.domain,
].filter(Boolean).join(" ")).includes(query);
}
function categoryForEntity(entity) {
const cls = entity.device_class || "";
if (entity.domain === "light") return "Lichtzustände";
if (entity.domain === "switch") return "Schalter / Helper";
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
if (["illuminance"].includes(cls)) return "Helligkeit";
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
if (["temperature"].includes(cls)) return "Temperatur";
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
if (entity.domain === "sensor") return "Weitere Messsensoren";
return "Weitere Zustände";
}
function optionGroups(entities, selectedIds = new Set()) {
const groups = new Map();
for (const entity of entities) {
const category = categoryForEntity(entity);
if (!groups.has(category)) groups.set(category, []);
groups.get(category).push(entity);
}
return Array.from(groups.entries()).map(([label, items]) => `
<optgroup label="${escapeHtml(label)}">
${items.map(entity => `
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
${escapeHtml(entityLabel(entity))}
</option>
`).join("")}
</optgroup>
`).join("");
} }
async function loadOverview() { async function loadOverview() {
const status = document.getElementById("status"); const status = document.getElementById("status");
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
try { try {
const [health, ml, reconciliation, actuators] = await Promise.all([ const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
api("health"), api("health"),
api("health/websocket"),
api("ml/health"), api("ml/health"),
api("v1/actuators/reconciliation/state"), api("v1/actuators/reconciliation/state"),
api("v1/actuators"), api("v1/actuators"),
]); ]);
status.innerHTML = `<p class="ok">API und ML bereit</p><p>Letzte Reconciliation: ${reconciliation.last_completed_at || "noch nie"}</p><p>${reconciliation.last_summary}</p>`; status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
chips.innerHTML = [ chips.innerHTML = [
`<span class="chip">Health: ${health.status}</span>`, `<span class="chip">API: ${escapeHtml(health.status)}</span>`,
`<span class="chip">ML: ${ml.status}</span>`, `<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
`<span class="chip">Aktuatoren: ${actuators.length}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
`<span class="chip">Trainierte Modelle: ${reconciliation.trained_models}</span>`, `<span class="chip">Aktoren: ${actuators.length}</span>`,
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
status.innerHTML = `<p class="bad">${error.message}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators(), loadProposals()]); await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select"); const select = document.getElementById("actuator-select");
try { try {
const actuators = await api("v1/actuators/discovery"); const [available, configured] = await Promise.all([
select.innerHTML = actuators.length api("v1/actuators/discovery"),
? actuators.map(entity => `<option value="${entity.entity_id}">${entity.friendly_name || entity.entity_id}${entity.area_name ? ` (${entity.area_name})` : ""}</option>`).join("") api("v1/actuators"),
: "<option value=''>Keine Aktuatoren gefunden</option>"; ]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
).join("");
renderActuatorSelect();
} catch (error) { } catch (error) {
select.innerHTML = `<option value="">${error.message}</option>`; options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
} }
} }
function actuatorGroupLabel(domain) {
const labels = {
light: "Lichter",
switch: "Schalter / Helper",
cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren",
humidifier: "Befeuchter / Entfeuchter",
};
return labels[domain] || domain;
}
function renderActuatorSelect() {
const select = document.getElementById("actuator-select");
if (!select) return;
const domain = document.getElementById("actuator-domain-filter")?.value || "";
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
const filtered = actuatorChoices
.filter(entity => !domain || entity.domain === domain)
.filter(entity => matchesSearch(entity, query))
.slice(0, 120);
const domains = [...new Set(filtered.map(entity => entity.domain))].sort();
select.innerHTML = [
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`,
...domains.map(group => `
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
${filtered
.filter(entity => entity.domain === group)
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
.join("")}
</optgroup>
`),
].join("");
}
async function loadContextOptions(actuatorId) {
try {
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
} catch (error) {
contextOptions = [];
}
}
function selectActuatorFromList() {
const value = document.getElementById("actuator-select").value;
if (value) document.getElementById("actuator-input").value = value;
}
function renderManualContextSelect() {
const select = document.getElementById("manual-context-select");
if (!select) return;
const category = document.getElementById("manual-context-category")?.value || "";
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
const selectedNow = new Set([
...manualContextState.selected,
...Array.from(select.selectedOptions).map(option => option.value),
]);
const filtered = manualContextState.options
.filter(entity => !category || categoryForEntity(entity) === category)
.filter(entity => matchesSearch(entity, query))
.slice(0, 80);
select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`;
}
function parseEntityIds(value) {
return String(value || "")
.split(/[\s,;]+/)
.map(item => item.trim())
.filter(Boolean);
}
async function configureActuator() { async function configureActuator() {
const actuatorId = document.getElementById("actuator-select").value; const actuatorId = (
const box = document.getElementById("actuator-config-result"); document.getElementById("actuator-input").value.trim()
|| document.getElementById("actuator-select").value.trim()
);
const result = document.getElementById("actuator-config-result");
if (!actuatorId) return; if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ...";
try { try {
const record = await api("v1/actuators", { const record = await api("v1/actuators", {
method: "POST", method: "POST",
body: JSON.stringify({actuator_entity_id: actuatorId}), body: JSON.stringify({actuator_entity_id: actuatorId}),
}); });
currentActuatorId = record.actuator_entity_id; currentActuatorId = record.actuator_entity_id;
box.textContent = pretty(record); result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
await loadOverview(); await loadOverview();
await showActuator(record.actuator_entity_id); await showActuator(record.actuator_entity_id);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
box.textContent = error.message; result.textContent = error.message;
}
}
async function runReconciliation() {
try {
await api("v1/actuators/reconciliation/run", {method: "POST"});
await loadOverview();
if (currentActuatorId) await showActuator(currentActuatorId);
} catch (error) {
alert(error.message);
} }
} }
@@ -169,186 +417,278 @@ async function loadConfiguredActuators() {
try { try {
const rows = await api("v1/actuators"); const rows = await api("v1/actuators");
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
<table> <div class="card-list">
<tr><th>Aktuator</th><th>Numerischer Sensor</th><th>Review</th><th>Modellstatus</th><th>Letztes Training</th><th>Aktion</th></tr>
${rows.map(record => ` ${rows.map(record => `
<tr> <article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
<td>${record.actuator_entity_id}</td> <div class="card-title">
<td>${record.assignment.selected_numeric_entity_id || "-"}</td> <div>
<td class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nein"}</td> <div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<td class="${statusClass(record)}">${record.lifecycle.status}</td> <div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
<td>${record.lifecycle.last_trained_at || "-"}</td> </div>
<td><button onclick="showActuator('${record.actuator_entity_id}')">Details</button></td> <span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</tr> </div>
<div class="metric-grid">
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div>
<div class="actions">
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</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>
</div>
</article>
`).join("")} `).join("")}
</table>` : "<p>Keine konfigurierten Aktuatoren.</p>"; </div>` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
} }
async function showActuator(actuatorId) { 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;
const numericRows = record.numeric_candidates.map(candidate => ` try {
<tr> record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
<td>${candidate.entity_id}</td> } catch (_) {
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td> record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>review</span>"}</td> }
<td>${renderEvidence(candidate.evidence)}</td> await loadContextOptions(actuatorId);
</tr> const contexts = [
`).join(""); record.assignment.selected_numeric_entity_id,
const contextRows = record.context_candidates.map(candidate => ` ...record.assignment.selected_context_entity_ids,
<tr> ].filter(Boolean);
<td>${candidate.entity_id}</td> const evidence = [...record.numeric_candidates, ...record.context_candidates]
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td> .filter(candidate => contexts.includes(candidate.entity_id))
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>optional</span>"}</td> .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
<td>${renderEvidence(candidate.evidence)}</td> .join("");
</tr> const prediction = record.behavior.prediction;
`).join(""); const learnedAutomationActions = record.behavior.patterns.filter(
box.innerHTML = ` pattern => pattern.source === "automation",
<div class="grid-two"> ).length;
<div> const relatedAutomations = record.behavior.related_automations || [];
<h3>Auswahl</h3> const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
<p><strong>Aktuator:</strong> ${record.actuator_entity_id}</p> const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
<p><strong>Numerischer Sensor:</strong> ${record.assignment.selected_numeric_entity_id || "-"}</p> const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
<p><strong>Kontext:</strong> ${record.assignment.selected_context_entity_ids.join(", ") || "-"}</p> const manualOnlyIds = [
<p><strong>Quelle:</strong> ${record.assignment.source}</p> record.assignment.selected_numeric_entity_id,
<p><strong>Review:</strong> <span class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nicht erforderlich"}</span></p> ...manualContextIds,
<p><strong>Begruendung:</strong> ${record.assignment.reason}</p> ].filter(entityId => entityId && !suggestedIds.has(entityId));
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
const manualAssignment = `
<div class="manual-context">
<h3>Kontext selbst festlegen</h3>
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select">
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
</select>
<div class="inline-controls">
<div>
<label for="manual-context-category">Kategorie</label>
<select id="manual-context-category" onchange="renderManualContextSelect()">
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
</select>
</div>
<div>
<label for="manual-context-filter">Vorschläge durchsuchen</label>
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
</div>
</div> </div>
<div> <label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
<h3>Modell-Lebenszyklus</h3> <select id="manual-context-select" multiple>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${record.lifecycle.status}</span></p> ${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
<p><strong>Letztes Training:</strong> ${record.lifecycle.last_trained_at || "-"}</p> </select>
<p><strong>Messpunkte:</strong> ${record.lifecycle.last_history_point_count}</p> <label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
<p><strong>Grund:</strong> ${record.lifecycle.reason}</p> <textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<p><strong>Nächste Aktion:</strong> ${record.lifecycle.next_action}</p> <div class="actions">
<button onclick="reconcileActuator('${record.actuator_entity_id}')">Diesen Aktuator erneut prüfen</button> <button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
</div> </div>
</div> </div>
<div class="grid-two">
<div>
<h3>Manuelle Overrides</h3>
<label for="override-numeric">Numerischer Sensor</label>
<input id="override-numeric" value="${record.manual_override?.numeric_entity_id || record.assignment.selected_numeric_entity_id || ""}">
<label for="override-context">Kontext-Entities (kommagetrennt)</label>
<textarea id="override-context">${(record.manual_override?.context_entity_ids || record.assignment.selected_context_entity_ids || []).join(", ")}</textarea>
<label for="override-note">Notiz</label>
<input id="override-note" value="${record.manual_override?.note || ""}">
<button onclick="saveOverride('${record.actuator_entity_id}')">Override speichern</button>
<button class="secondary" onclick="clearOverride('${record.actuator_entity_id}')">Override löschen</button>
</div>
<div>
<h3>Audit</h3>
<pre>${pretty(record.lifecycle.audit)}</pre>
</div>
</div>
<h3>Numerische Kandidaten</h3>
${numericRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${numericRows}</table>` : "<p>Keine Kandidaten.</p>"}
<h3>Kontext-Kandidaten</h3>
${contextRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${contextRows}</table>` : "<p>Keine Kandidaten.</p>"}
`; `;
const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
: record.behavior.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen und passende HA-Automationen pausieren</button>
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, false, false)">SillyHome parallel aktivieren</button>`
: `<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 compact" 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 = `
<div class="detail-header">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
</div>
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button>
</div>
<div class="grid-two">
<div>
<h3>Zuordnung</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div>
<div>
<h3>Lernfortschritt</h3>
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</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>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>
<div class="actions">${activationButton}</div>
<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>
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
</div>
</div>
<h3>Was SillyHome aktuell vorhersagt</h3>
${prediction
? `<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>"}
<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>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
${manualAssignment}
`;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
} }
async function reconcileActuator(actuatorId) { async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from(
document.getElementById("manual-context-select").selectedOptions,
).map(option => option.value).filter(value => value.includes("."));
const freeformContextIds = parseEntityIds(
document.getElementById("manual-context-freeform").value,
);
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
.filter(entityId => entityId !== numericEntityId);
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/reconcile`, {method: "POST"}); await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function saveOverride(actuatorId) {
const numeric = document.getElementById("override-numeric").value.trim() || null;
const contexts = document.getElementById("override-context").value
.split(",")
.map(item => item.trim())
.filter(Boolean);
const note = document.getElementById("override-note").value.trim() || null;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
numeric_entity_id: numeric, numeric_entity_id: numericEntityId,
context_entity_ids: contexts, context_entity_ids: contextEntityIds,
note, note: "Manuell im Dashboard gesetzt",
}), }),
}); });
await loadOverview(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function clearOverride(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, { const record = await api(
`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`,
{method: "POST"},
);
const checkedAt = new Date(
record.behavior.last_evaluated_at || Date.now(),
).toLocaleString("de-DE");
const message = record.behavior.prediction
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
await loadConfiguredActuators();
await showActuator(actuatorId, message);
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active
? 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", method: "POST",
body: JSON.stringify({clear: true}), body: JSON.stringify({
active,
pause_matching_automations: pauseMatchingAutomations,
restore_paused_automations: restorePausedAutomations,
}),
}); });
await loadOverview(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function createProposal() { async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
const action = enabled ? "fortsetzen" : "pausieren";
if (!confirm(`${automationEntityId} wirklich ${action}?`)) return;
try { try {
await api("v1/automations/proposals", {method: "POST", body: JSON.stringify({ await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/control`, {
alias: document.getElementById("alias").value, method: "POST",
description: "Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.", body: JSON.stringify({
trigger: {entity_id: document.getElementById("trigger").value, below: Number(document.getElementById("below").value)}, automation_entity_id: automationEntityId,
action: {service: document.getElementById("service").value, entity_id: document.getElementById("target").value, data: {}} enabled,
})}); }),
await loadProposals(); });
await loadConfiguredActuators();
await showActuator(actuatorId);
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function decide(id, revision, action) { async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try { try {
await api(`v1/automations/proposals/${id}/${action}`, {method: "POST", body: JSON.stringify({expected_revision: revision})}); await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
await loadProposals(); if (currentActuatorId === actuatorId) {
currentActuatorId = null;
document.getElementById("actuator-detail").textContent = "Öffne bei einem beobachteten Gerät die Details.";
}
await loadOverview();
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function loadProposals() {
const box = document.getElementById("proposals");
try {
const rows = await api("v1/automations/proposals");
box.innerHTML = rows.length ? `
<table>
<tr><th>Name</th><th>Status</th><th>Aktion</th></tr>
${rows.map(item => `
<tr>
<td>${item.alias}</td>
<td>${item.status}</td>
<td>${item.status === "draft"
? `<button onclick="decide('${item.proposal_id}',${item.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${item.proposal_id}',${item.revision},'reject')">Ablehnen</button>`
: item.status === "approved"
? `<a href="v1/automations/proposals/${item.proposal_id}/yaml">YAML laden</a>`
: "-"}</td>
</tr>
`).join("")}
</table>` : "<p>Keine Entwürfe.</p>";
} catch (error) {
box.textContent = error.message;
}
}
loadOverview(); loadOverview();
</script> </script>
</body> </body>

View File

@@ -14,6 +14,12 @@ services:
SILLYHOME_MIN_TRAINING_POINTS: 24 SILLYHOME_MIN_TRAINING_POINTS: 24
SILLYHOME_RETRAIN_STALE_HOURS: 24 SILLYHOME_RETRAIN_STALE_HOURS: 24
SILLYHOME_RECONCILE_INTERVAL_SECONDS: 900 SILLYHOME_RECONCILE_INTERVAL_SECONDS: 900
SILLYHOME_MIN_BEHAVIOR_ACTIONS: 3
SILLYHOME_PREDICTION_CONFIDENCE: 0.82
SILLYHOME_PREDICTION_WINDOW_MINUTES: 30
SILLYHOME_PREDICTION_INTERVAL_SECONDS: 60
SILLYHOME_EXECUTION_COOLDOWN_SECONDS: 900
SILLYHOME_TIMEZONE: Europe/Berlin
volumes: volumes:
- model-data:/app/data/models - model-data:/app/data/models
- automation-data:/app/data/automations - automation-data:/app/data/automations

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

@@ -1,14 +1,6 @@
# Automation-Vorschläge # Keine manuell erzeugten Automationen
SillyHome Next führt Automationen niemals automatisch aus. Der Workflow ist: Seit `v0.5.0` erstellt SillyHome Next keine YAML-Automationen und bietet keinen
Regel- oder Trigger-Editor mehr an. Der produktive Ablauf besteht aus
1. Vorschlag als `draft` erstellen. Aktorauswahl, automatischem Verhaltenslernen, Shadow-Vorhersage und einer
2. Inhalt und Ziel-Entity prüfen. separaten Ausführungsfreigabe pro Aktor.
3. Mit aktueller Revision explizit freigeben oder ablehnen.
4. Nur freigegebene Vorschläge als Home-Assistant-YAML exportieren.
5. Das YAML außerhalb von SillyHome Next in Home Assistant importieren.
Erlaubt sind numerische Sensor-Trigger und Aktionsdienste aus den Domains
`light`, `switch`, `climate`, `fan` und `cover`. Shell-Kommandos, Skripte und
beliebige Service-Domains werden abgewiesen. Eine einmal getroffene Entscheidung
kann nicht überschrieben werden; Änderungen benötigen einen neuen Vorschlag.

View File

@@ -178,9 +178,10 @@ Listet unterstützte Aktuatoren mit angereicherter HA-Metadatenbasis.
### `POST /v1/actuators` ### `POST /v1/actuators`
Registriert einen Aktuator, ermittelt passende numerische Sensoren und Registriert einen Aktor. Das System ermittelt passende Messwerte und
Kontext-Entities, trainiert bei ausreichender History automatisch ein Modell und Kontext-Entities vollständig automatisch, trainiert bei ausreichender Historie
liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück. ein Modell und liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zur
Diagnose zurück.
**Request** **Request**
```json ```json
@@ -190,21 +191,63 @@ liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück.
} }
``` ```
### `POST /v1/actuators/{actuator_entity_id}/override`
Persistiert manuelle Overrides. Diese haben Vorrang vor der automatischen
Heuristik und überstehen Neustarts.
### `POST /v1/actuators/reconciliation/run` ### `POST /v1/actuators/reconciliation/run`
Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife
ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home
Assistant. Assistant.
### `POST /v1/actuators/{actuator_entity_id}/evaluate`
Erstellt aus aktuellem Kontext eine neue Shadow- oder Aktiv-Vorhersage. Im
Shadow-Modus wird niemals geschaltet.
### `POST /v1/actuators/{actuator_entity_id}/activation`
```json
{
"active": true,
"pause_matching_automations": true,
"restore_paused_automations": false
}
```
Aktiviert autonomes Schalten erst nach ausreichendem Training und nur für
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 ## Betrieb
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktuator-, Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktor- und
Override- und Reconciliation-Zustände liegen atomisch in Reconciliation-Zustände liegen atomisch in
`SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`, `SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`,
`RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API `RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API
sollte nur in einem vertrauenswürdigen Netz oder hinter einem sollte nur in einem vertrauenswürdigen Netz oder hinter einem

View File

@@ -1,92 +1,51 @@
# ML Training- und Evaluations-Workflow # Verhaltenslernen und Vorhersage
SillyHome Next trainiert ein lokales statistisches Baseline-Modell pro Sensor Seit `v0.5.0` ist der produktive Lernpfad aktor-zentriert. Nutzer wählen nur
und Merkmal. Es benötigt keine Cloud und keine externe ML-Laufzeit. einen Aktor; Sensoren, Kontext und Modelle werden automatisch verwaltet.
Seit `v0.4.0` ist der bevorzugte Weg aktor-zentriert: ein bestätigter Aktuator ## Datengrundlage
wird mit einem numerischen Primärsensor verknüpft, die Historie dieses Sensors
wird automatisch geladen und in ein deterministisches Artefakt überführt.
## 1. Daten sammeln Für jeden Aktor lädt SillyHome Next:
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen. - dessen Zustandswechsel aus der Home-Assistant-Historie
- Logbook-Einträge zur Herkunft der Handlung
- automatisch zugeordnete Mess- und Kontext-Entities
- deren Zustand zum Zeitpunkt der Handlung
Im Normalbetrieb erzeugt die Reconciliation diese Vektoren selbst aus realer Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen und im Logbuch
Home-Assistant-History. Das Trainingsmerkmal heißt dabei immer `value`. erkannte Automations- oder Script-Aktionen erhalten das höchste Gewicht.
Binäre Kontextsensoren bleiben Kontext und werden nicht als numerische Samples Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das Shadow-Modell
missverstanden. ergänzen, reichen allein aber nicht zur Aktivierung.
## 2. Statistisches Artefakt erzeugen ## Modell
```python Das lokale Modell speichert pro beobachteter Handlung:
store = FeatureStore()
store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0}))
pipeline = TrainingPipeline(store)
artifact = pipeline.run("my_artifact")
pipeline.export("my_artifact")
```
`TrainingPipeline.run(...)` berechnet für jedes numerische Merkmal: - Zielzustand
- lokale Tageszeit
- Wochentag
- Kontextzustände
- Herkunft und Gewicht
- Stichprobenzahl Eine Vorhersage bewertet zeitliche Nähe, Wochentagsmuster und aktuellen
- Mittelwert und Standardabweichung Kontext. Mehrere passende historische Handlungen erhöhen die Confidence.
- Minimum und Maximum
- linearen Trend mit Steigung und Achsenabschnitt
Die nächste Vorhersage kombiniert den letzten beobachteten Wert mit der ## Betriebsstufen
trainierten Trendsteigung. Die Confidence berücksichtigt Datenmenge und
Stabilität.
## 3. Modell evaluieren 1. `collecting`: Noch nicht genügend Handlungen vorhanden.
2. `shadow`: Modell ist trainiert; Vorhersagen werden angezeigt, aber nicht ausgeführt.
3. `active`: Nutzer hat den Aktor ausdrücklich freigegeben.
```python Die Aktivierung verlangt genügend eindeutig zugeordnete manuelle oder
evaluator = Evaluator(pipeline) automatisierte Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
report = evaluator.evaluate(artifact.artifact_id, validation_samples) `light`, `switch`, `fan`, `humidifier` und `cover`.
```
Der Report enthält echte numerische Vergleichsmetriken: ## Schutzmechanismen
- `artifact_id`
- `sample_size`
- `mae` (Mean Absolute Error)
- `rmse` (Root Mean Squared Error)
- `coverage` für den Anteil auswertbarer Merkmale
## 4. Modell registrieren - explizite Freigabe pro Aktor
- konfigurierbare Mindestkonfidenz
Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifact)` bereitgestellt werden. Die ML-Serving-API stellt es unter `/ml/predict` und `/ml/batch` zur Verfügung. - Cooldown zwischen Schaltungen
- keine Ausführung bei bereits erreichtem Zielzustand
## 5. Retraining ausführen - keine Ausführung unbekannter Zustände oder riskanter Domains
- eigene Schaltungen werden beim nächsten Training herausgefiltert
`RetrainingService.retrain(...)` führt genau einen Trainingslauf aus und ersetzt - Automation-/Script-Aktionen zählen nur bei eindeutiger Herkunft im HA-Logbuch
ein vorhandenes Artefakt mit derselben ID atomisch in der Registry:
```python
service = RetrainingService(registry)
result = service.retrain("home-model", vectors)
```
Scheduler, Cronjobs oder Home-Assistant-Automationen können alternativ die
zustandslose Funktion `retrain_model(registry, artifact_id, vectors)` aufrufen.
Der Service startet bewusst keinen eigenen Hintergrundprozess. Über
`POST /ml/retrain` kann derselbe Ablauf per API angestoßen werden.
## 6. Autonomer Lebenszyklus
Der `ActuatorReconciliationService` verwaltet pro konfiguriertem Aktuator:
- die automatische Sensor- und Kontextzuordnung mit Score, Confidence und Evidenz
- persistente manuelle Overrides
- den Modellstatus (`trained`, `pending_history`, `review_required`, `archived`, ...)
- ein Audit-Protokoll mit Gründen für Training, Retraining oder Archivierung
Retraining erfolgt nur, wenn:
- genügend nutzbare numerische Historie vorliegt
- die aktuelle Zuordnung eindeutig oder manuell bestätigt ist
- die Historie sich materiell verändert hat oder das Modell als stale gilt
## Hinweise
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
- Nur endliche numerische Werte werden trainiert.
- `coverage` bleibt im Bereich 0 bis 1.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.4.0" version = "0.7.10"
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 = [
@@ -12,6 +12,7 @@ dependencies = [
"uvicorn[standard]>=0.29.0", "uvicorn[standard]>=0.29.0",
"pydantic>=2.6.0", "pydantic>=2.6.0",
"requests>=2.31.0", "requests>=2.31.0",
"websockets>=12.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -7,7 +7,6 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ( from app.actuators.models import (
AssignmentSource, AssignmentSource,
LifecycleStatus, LifecycleStatus,
ManualOverride,
model_id_for_actuator, model_id_for_actuator,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
@@ -79,7 +78,7 @@ def _service(
model_store=str(tmp_path / "models"), model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"), automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"), actuator_store=str(tmp_path / "actuators"),
history_days=14, history_days=31,
min_training_points=5, min_training_points=5,
retrain_stale_hours=24, retrain_stale_hours=24,
reconcile_interval_seconds=900, reconcile_interval_seconds=900,
@@ -142,7 +141,7 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: Path) -> None: def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -182,10 +181,103 @@ def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: P
record = service.configure_actuator("switch.garage_pump") record = service.configure_actuator("switch.garage_pump")
assert record.assignment.review_required is True assert record.assignment.review_required is True
assert record.lifecycle.status is LifecycleStatus.REVIEW_REQUIRED assert record.assignment.selected_numeric_entity_id is None
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None: def test_reconciliation_does_not_cross_assign_other_room_light_energy(
tmp_path: Path,
) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id=(
"light.lichtschalter_abstellraum_"
"lichtschalter_abstellraum_s1"
),
domain="light",
friendly_name="Licht Abstellraum",
),
HaEntitySummary(
entity_id="sensor.licht_badezimmer_energy",
domain="sensor",
device_class="energy",
state_class="total_increasing",
unit_of_measurement="kWh",
friendly_name="Lichtschalter_Badezimmer Licht Badezimmer energy",
),
HaEntitySummary(
entity_id="binary_sensor.abstellraum_ture",
domain="binary_sensor",
device_class="door",
friendly_name="Abstellraum Türe",
),
HaEntitySummary(
entity_id="binary_sensor.briefkasten_open",
domain="binary_sensor",
device_class="opening",
friendly_name="Briefkasten open",
),
]
service = _service(
tmp_path,
entities,
{"sensor.licht_badezimmer_energy": _points(8, start, 1.0)},
)
record = service.configure_actuator(
"light.lichtschalter_abstellraum_lichtschalter_abstellraum_s1"
)
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.abstellraum_ture"
]
assert record.assignment.source is AssignmentSource.AUTOMATIC
assert record.assignment.confidence == 1.0
assert record.assignment.review_required is False
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
tmp_path: Path,
) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Licht Abstellkammer",
area_name="Monitoring",
),
HaEntitySummary(
entity_id="binary_sensor.disk_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Max. fehlerhafte Sektoren ueberschritten",
area_name="Monitoring",
),
HaEntitySummary(
entity_id="sensor.router_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Router Leistung",
area_name="Monitoring",
),
]
service = _service(tmp_path, entities, {"sensor.router_power": _points(8, start, 1.0)})
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == []
assert record.assignment.review_required is True
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -219,20 +311,17 @@ def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None
} }
service = _service(tmp_path, entities, history) service = _service(tmp_path, entities, history)
service.configure_actuator("light.abstellkammer") service.configure_actuator("light.abstellkammer")
service.set_manual_assignment(
updated = service.set_override(
"light.abstellkammer", "light.abstellkammer",
ManualOverride( numeric_entity_id="sensor.abstellkammer_power",
numeric_entity_id="sensor.abstellkammer_power", context_entity_ids=["sensor.abstellkammer_illuminance"],
context_entity_ids=[], note="Manuell wichtiger Sensor",
note="Manuelle Leistungs-Zuordnung",
),
) )
restarted = _service(tmp_path, entities, history) restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer") record = restarted.reconcile_actuator("light.abstellkammer")
assert updated.assignment.source is AssignmentSource.MANUAL
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power" assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
assert record.assignment.source is AssignmentSource.MANUAL
assert record.manual_override is not None assert record.manual_override is not None
assert record.manual_override.numeric_entity_id == "sensor.abstellkammer_power"

View File

@@ -7,11 +7,17 @@ from fastapi.testclient import TestClient
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint from app.ha.history import (
from app.ha.models import HaEntitySummary EntityHistorySeries,
LogbookEntry,
NumericHistoryPoint,
StateHistorySeries,
)
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
@@ -54,6 +60,36 @@ class FakeHaReader(HaReader):
if entity_id in self._history if entity_id in self._history
] ]
def read_state_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[StateHistorySeries]:
return []
def read_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[LogbookEntry]:
return []
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
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 = [
@@ -79,6 +115,14 @@ def _install_service(tmp_path: Path) -> None:
friendly_name="Abstellkammer Bewegung", friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer", area_name="Abstellkammer",
), ),
HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes",
domain="sensor",
device_class="data_size",
state_class="measurement",
unit_of_measurement="KiB",
friendly_name="pfSense Interface VPN inbytes",
),
] ]
settings = Settings( settings = Settings(
ha_url="http://ha.local", ha_url="http://ha.local",
@@ -103,9 +147,14 @@ def _install_service(tmp_path: Path) -> None:
registry=app.state.registry, registry=app.state.registry,
settings=settings, settings=settings,
) )
app.state.behavior_engine = BehaviorEngine(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
settings=settings,
)
def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None: def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
@@ -118,18 +167,63 @@ def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> Non
listed = client.get("/v1/actuators") listed = client.get("/v1/actuators")
assert listed.status_code == 200 assert listed.status_code == 200
assert listed.json()[0]["lifecycle"]["status"] == "trained" assert listed.json()[0]["lifecycle"]["status"] == "trained"
assert listed.json()[0]["behavior"]["mode"] == "shadow"
override = client.post( evaluation = client.post("/v1/actuators/light.abstellkammer/evaluate")
"/v1/actuators/light.abstellkammer/override", assert evaluation.status_code == 200
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance", premature_activation = client.post(
"context_entity_ids": ["binary_sensor.abstellkammer_motion"], "/v1/actuators/light.abstellkammer/activation",
"note": "Explizit bestaetigt", json={"active": True},
},
) )
assert override.status_code == 200 assert premature_activation.status_code == 409
assert override.json()["assignment"]["source"] == "manual"
reconciliation = client.post("/v1/actuators/reconciliation/run") reconciliation = client.post("/v1/actuators/reconciliation/run")
assert reconciliation.status_code == 200 assert reconciliation.status_code == 200
assert reconciliation.json()["trained_models"] == 1 assert reconciliation.json()["trained_models"] == 1
removed = client.delete("/v1/actuators/light.abstellkammer")
assert removed.status_code == 204
assert client.get("/v1/actuators").json() == []
def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
"note": "Manuell gesetzt",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["assignment"]["source"] == "manual"
assert payload["assignment"]["selected_numeric_entity_id"] == (
"sensor.abstellkammer_illuminance"
)
assert payload["assignment"]["selected_context_entity_ids"] == [
"binary_sensor.abstellkammer_motion"
]
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get(
"/v1/actuators/context-options",
params={"actuator_entity_id": "light.abstellkammer"},
)
assert response.status_code == 200
entity_ids = {item["entity_id"] for item in response.json()}
assert "sensor.abstellkammer_illuminance" in entity_ids
assert "binary_sensor.abstellkammer_motion" in entity_ids
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids

View File

@@ -1,59 +1,17 @@
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.automations.store import AutomationStore
from app.main import app from app.main import app
def _payload() -> dict[str, object]: def test_automation_api_is_not_exposed() -> None:
return {
"alias": "Licht bei Dunkelheit",
"description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.",
"trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
"action": {
"service": "light.turn_on",
"entity_id": "light.hall",
"data": {"brightness_pct": 40},
},
}
def test_proposal_requires_explicit_approval_before_yaml(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path) response = client.post(
created = client.post("/v1/automations/proposals", json=_payload()) "/v1/automations/proposals",
proposal_id = created.json()["proposal_id"] json={
blocked = client.get(f"/v1/automations/proposals/{proposal_id}/yaml") "alias": "Nicht mehr verfügbar",
approved = client.post( "trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
f"/v1/automations/proposals/{proposal_id}/approve", "action": {"service": "light.turn_on", "entity_id": "light.hall"},
json={"expected_revision": 1}, },
) )
exported = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
assert created.status_code == 201
assert created.json()["status"] == "draft"
assert blocked.status_code == 409
assert approved.json()["status"] == "approved"
assert "service: light.turn_on" in exported.text
assert response.status_code == 404
def test_proposal_rejects_unsafe_service_domain(tmp_path: Path) -> None:
payload = _payload()
payload["action"] = {
"service": "shell_command.run",
"entity_id": "light.hall",
"data": {},
}
with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path)
response = client.post("/v1/automations/proposals", json=payload)
assert response.status_code == 422
def test_proposal_requires_numeric_threshold(tmp_path: Path) -> None:
payload = _payload()
payload["trigger"] = {"entity_id": "sensor.hall_illuminance"}
with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path)
response = client.post("/v1/automations/proposals", json=payload)
assert response.status_code == 422

View File

@@ -73,9 +73,11 @@ def test_entities_returns_reader_data() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [ assert response.json() == [
{ {
"entity_id": "sensor.temperature", "entity_id": "sensor.temperature",
"domain": "sensor", "domain": "sensor",
"state_class": None, "state": None,
"last_changed": None,
"state_class": None,
"device_class": None, "device_class": None,
"unit_of_measurement": None, "unit_of_measurement": None,
"friendly_name": None, "friendly_name": None,

View File

@@ -0,0 +1,659 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
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
from app.ha.history import (
LogbookEntry,
StateHistoryPoint,
StateHistorySeries,
)
from app.ha.models import HaAutomationSummary, HaEntitySummary
from app.ha.reader import HaReader
class FakeBehaviorReader(HaReader):
def __init__(
self,
*,
entities: list[HaEntitySummary],
history: list[StateHistorySeries],
logbook: list[LogbookEntry],
) -> None:
self.entities = entities
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)
def read_state_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[StateHistorySeries]:
return [series for series in self.history if series.entity_id in entity_ids]
def read_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[LogbookEntry]:
return [entry for entry in self.logbook if entry.entity_id == entity_id]
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
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(
actuator_store=str(tmp_path / "actuators"),
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
history_days=14,
min_behavior_actions=3,
prediction_confidence=0.8,
prediction_window_minutes=30,
execution_cooldown_seconds=900,
timezone="Europe/Berlin",
)
def _reader(now: datetime) -> FakeBehaviorReader:
actuator_points: list[StateHistoryPoint] = []
logbook: list[LogbookEntry] = []
for days_ago in (3, 2, 1):
action_at = now - timedelta(days=days_ago)
actuator_points.extend(
[
StateHistoryPoint(timestamp=action_at - timedelta(minutes=1), state="off"),
StateHistoryPoint(timestamp=action_at, state="on"),
StateHistoryPoint(timestamp=action_at + timedelta(hours=6), state="off"),
]
)
logbook.extend(
[
LogbookEntry(
entity_id="light.office",
timestamp=action_at,
message="turned on",
context_user_id="user-1",
),
LogbookEntry(
entity_id="light.office",
timestamp=action_at + timedelta(hours=6),
message="turned off",
context_domain="automation",
context_service="trigger",
),
]
)
actuator_points.sort(key=lambda point: point.timestamp)
context_points = [
StateHistoryPoint(timestamp=now - timedelta(days=7), state="on"),
]
return FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[
StateHistorySeries(entity_id="light.office", points=actuator_points),
StateHistorySeries(
entity_id="binary_sensor.office_presence",
points=context_points,
),
],
logbook=logbook,
)
def _engine(tmp_path: Path, now: datetime) -> tuple[BehaviorEngine, FakeBehaviorReader]:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
store.upsert(
record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
)
}
)
)
reader = _reader(now)
return (
BehaviorEngine(ha_reader=reader, store=store, settings=settings),
reader,
)
def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
engine, reader = _engine(tmp_path, now)
trained = engine.train("light.office")
shadow = engine.evaluate("light.office")
assert trained.behavior.status is BehaviorStatus.TRAINED
assert trained.behavior.sample_count == 6
assert trained.behavior.high_confidence_sample_count == 6
assert shadow.behavior.mode is BehaviorMode.SHADOW
assert shadow.behavior.prediction is not None
assert shadow.behavior.prediction.target_state == "on"
assert reader.service_calls == []
engine.set_active("light.office", active=True)
active = engine.evaluate("light.office")
assert active.behavior.mode is BehaviorMode.ACTIVE
assert active.behavior.prediction is not None
assert active.behavior.prediction.executed is True
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.office"})
]
def test_engine_counts_known_automation_actions_like_manual_actions(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
engine, _ = _engine(tmp_path, now)
trained = engine.train("light.office")
assert {pattern.target_state for pattern in trained.behavior.patterns} == {
"on",
"off",
}
assert {pattern.source for pattern in trained.behavior.patterns} == {
"user",
"automation",
}
assert trained.behavior.high_confidence_sample_count == 6
assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0}
def test_engine_learns_causal_automation_with_activation_credit(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
actuator_points: list[StateHistoryPoint] = []
door_points: list[StateHistoryPoint] = []
logbook: list[LogbookEntry] = []
for days_ago in (3, 2, 1):
action_at = now - timedelta(days=days_ago)
actuator_points.extend(
[
StateHistoryPoint(
timestamp=action_at - timedelta(minutes=1),
state="off",
),
StateHistoryPoint(timestamp=action_at, state="on"),
]
)
door_points.extend(
[
StateHistoryPoint(
timestamp=action_at - timedelta(minutes=1),
state="off",
),
StateHistoryPoint(
timestamp=action_at - timedelta(seconds=1),
state="on",
),
]
)
logbook.append(
LogbookEntry(
entity_id="light.storage",
timestamp=action_at,
message="turned on",
context_domain="automation",
context_service="trigger",
)
)
actuator_points.sort(key=lambda point: point.timestamp)
door_points.sort(key=lambda point: point.timestamp)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
store.upsert(
record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.storage_door"
],
}
)
}
)
)
reader = FakeBehaviorReader(
entities=[],
history=[
StateHistorySeries(
entity_id="light.storage",
points=actuator_points,
),
StateHistorySeries(
entity_id="binary_sensor.storage_door",
points=door_points,
),
],
logbook=logbook,
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
trained = engine.train("light.storage")
automation_patterns = [
pattern
for pattern in trained.behavior.patterns
if pattern.source == "automation"
]
assert len(automation_patterns) == 3
assert trained.behavior.high_confidence_sample_count == 3
assert {pattern.weight for pattern in automation_patterns} == {1.0}
assert {
(
pattern.trigger_entity_id,
pattern.trigger_from_state,
pattern.trigger_to_state,
)
for pattern in automation_patterns
} == {("binary_sensor.storage_door", "off", "on")}
active = engine.set_active("light.storage", active=True)
assert active.behavior.mode is BehaviorMode.ACTIVE
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("lock.front_door")
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={"status": BehaviorStatus.TRAINED}
)
}
)
)
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
with pytest.raises(ValueError, match="nicht freigegeben"):
engine.set_active("lock.front_door", active=True)
def test_active_mode_requires_trusted_manual_or_automation_actions(tmp_path: Path) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"status": BehaviorStatus.TRAINED,
"sample_count": 3,
"high_confidence_sample_count": 0,
}
)
}
)
)
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
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"),
[
("light", "on", "turn_on"),
("switch", "off", "turn_off"),
("cover", "open", "open_cover"),
("cover", "closed", "close_cover"),
("lock", "unlocked", None),
],
)
def test_service_for_state_is_strictly_allowlisted(
domain: str,
state: str,
service: str | None,
) -> None:
assert service_for_state(domain, state) == service
def test_prediction_requires_temporal_support() -> None:
assert predict_behavior(
[],
current_context={},
now=datetime.now(timezone.utc),
min_support=3,
window_minutes=30,
) is None
def test_prediction_uses_fresh_causal_context_transition_outside_time_window() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=0.7,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
]
prediction = predict_behavior(
patterns,
current_context={"binary_sensor.storage_door": "on"},
current_context_changed_at={
"binary_sensor.storage_door": now - timedelta(seconds=10)
},
now=now,
min_support=3,
window_minutes=30,
)
assert prediction is not None
assert prediction.target_state == "on"
assert prediction.matching_patterns == 3
assert prediction.confidence == 0.7
assert "frischen Sensorwechsel" in prediction.reason
def test_prediction_ignores_stale_causal_context_state() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
pattern = BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=0.7,
observed_at=now - timedelta(days=1),
)
assert predict_behavior(
[pattern],
current_context={"binary_sensor.storage_door": "on"},
current_context_changed_at={
"binary_sensor.storage_door": now - timedelta(minutes=5)
},
now=now,
min_support=1,
window_minutes=30,
) is None
def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": ["binary_sensor.storage_door"],
}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="off",
last_changed=now - timedelta(minutes=5),
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
engine.handle_state_change(
"binary_sensor.storage_door",
{"state": "on", "last_changed": now.isoformat()},
)
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"})
]
def test_state_change_uses_event_cache_without_rest_state_query(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": ["binary_sensor.storage_door"],
}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[],
history=[],
logbook=[],
)
def fail_read_entities() -> list[HaEntitySummary]:
raise AssertionError("Event-Auswertung darf keinen REST-State lesen.")
reader.read_entities = fail_read_entities # type: ignore[method-assign]
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
engine.handle_state_change(
"binary_sensor.storage_door",
{"state": "on", "last_changed": now.isoformat()},
current_entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="on",
last_changed=now,
),
],
)
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"})
]

View File

@@ -108,6 +108,56 @@ def test_list_entity_metadata_calls_template_api() -> None:
} }
def test_list_entity_metadata_batches_template_calls() -> None:
responses = []
for index in range(3):
response = _response()
response.text = (
f'[{{"entity_id":"sensor.test_{index}",'
f'"area_name":"Area {index}","device_name":"Device {index}"}}]'
)
responses.append(response)
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.post = Mock(side_effect=responses) # type: ignore[method-assign]
entity_ids = [f"sensor.test_{index}" for index in range(401)]
metadata = client.list_entity_metadata(entity_ids)
assert client._session.post.call_count == 3
assert metadata["sensor.test_0"]["area_name"] == "Area 0"
assert metadata["sensor.test_1"]["device_name"] == "Device 1"
assert metadata["sensor.test_2"]["device_name"] == "Device 2"
def test_get_logbook_filters_entity_and_period() -> None:
response = _response(payload=[{"entity_id": "light.office"}])
client = _client_with_response(response)
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
payload = client.get_logbook("light.office", start, end)
assert payload == [{"entity_id": "light.office"}]
call = client._session.get.call_args # type: ignore[attr-defined]
assert "/api/logbook/2026-06-01T00:00:00+00:00" in call.args[0]
assert call.kwargs["params"]["entity"] == "light.office"
def test_call_service_posts_to_home_assistant() -> None:
response = _response(payload=[])
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.post = Mock(return_value=response) # type: ignore[method-assign]
result = client.call_service("light", "turn_on", {"entity_id": "light.office"})
assert result == []
client._session.post.assert_called_once_with(
"http://ha.local/api/services/light/turn_on",
json={"entity_id": "light.office"},
timeout=10,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entity_ids", "start", "end"), ("entity_ids", "start", "end"),
[ [

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.exceptions import HaHttpError
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -15,6 +16,7 @@ class FakeHaClient(HaClient):
{ {
"entity_id": "sensor.temperature", "entity_id": "sensor.temperature",
"state": "21.5", "state": "21.5",
"last_changed": "2026-06-14T12:00:00+00:00",
"attributes": { "attributes": {
"state_class": "measurement", "state_class": "measurement",
"device_class": "temperature", "device_class": "temperature",
@@ -54,6 +56,29 @@ class FakeHaClient(HaClient):
} }
} }
def get_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[object]:
return [
{
"entity_id": entity_id,
"when": start_time.isoformat(),
"message": "turned on",
"context_user_id": "user-1",
}
]
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
return []
def test_ha_reader_returns_summaries() -> None: def test_ha_reader_returns_summaries() -> None:
reader = HaReader(FakeHaClient()) reader = HaReader(FakeHaClient())
@@ -63,6 +88,8 @@ def test_ha_reader_returns_summaries() -> None:
assert domains == {"sensor", "light"} assert domains == {"sensor", "light"}
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature") sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C" assert sensor.unit_of_measurement == "°C"
assert sensor.state == "21.5"
assert sensor.last_changed == datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc)
assert sensor.area_name == "Kueche" assert sensor.area_name == "Kueche"
assert sensor.device_name == "Thermometer" assert sensor.device_name == "Thermometer"
@@ -87,3 +114,60 @@ def test_ha_reader_normalizes_history() -> None:
assert history[0].entity_id == "sensor.temperature" assert history[0].entity_id == "sensor.temperature"
assert history[0].points[0].value == 21.5 assert history[0].points[0].value == 21.5
def test_ha_reader_normalizes_state_history_and_logbook() -> None:
reader = HaReader(FakeHaClient())
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
history = reader.read_state_history(["light.living_room"], start, end)
logbook = reader.read_logbook("light.living_room", start, end)
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
def test_ha_reader_ignores_automation_configs_not_exposed_by_ha() -> 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: (_ for _ in ()).throw( # type: ignore[method-assign]
HaHttpError(404, "Resource not found")
)
reader = HaReader(client)
assert reader.find_automations_for_entity("light.storage") == []

View File

@@ -5,7 +5,11 @@ from datetime import datetime, timezone
import pytest import pytest
from app.ha.exceptions import HaUnexpectedPayloadError from app.ha.exceptions import HaUnexpectedPayloadError
from app.ha.history import normalize_history_payload from app.ha.history import (
normalize_history_payload,
normalize_logbook_payload,
normalize_state_history_payload,
)
def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None: def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None:
@@ -90,3 +94,46 @@ def test_normalize_history_payload_rejects_malformed_structure(payload: object)
def test_normalize_history_payload_accepts_empty_series() -> None: def test_normalize_history_payload_accepts_empty_series() -> None:
assert normalize_history_payload([[]]) == [] assert normalize_history_payload([[]]) == []
def test_normalize_state_history_keeps_categorical_changes() -> None:
result = normalize_state_history_payload(
[
[
{
"entity_id": "light.office",
"state": "off",
"last_changed": "2026-06-01T08:00:00+00:00",
},
{
"state": "on",
"last_changed": "2026-06-01T08:05:00+00:00",
},
{
"state": "on",
"last_changed": "2026-06-01T08:06:00+00:00",
},
]
]
)
assert [point.state for point in result[0].points] == ["off", "on"]
def test_normalize_logbook_preserves_action_origin() -> None:
result = normalize_logbook_payload(
[
{
"entity_id": "light.office",
"when": "2026-06-01T08:05:00+00:00",
"message": "turned on",
"context_user_id": "user-1",
"context_domain": "light",
"context_service": "turn_on",
}
],
"light.office",
)
assert result[0].context_user_id == "user-1"
assert result[0].context_service == "turn_on"

View File

@@ -0,0 +1,18 @@
from pathlib import Path
def test_addon_does_not_expose_internal_learning_parameters() -> None:
config = Path("addon/config.yaml").read_text(encoding="utf-8")
assert "\noptions:" not in config
assert "\nschema:" not in config
assert "prediction_confidence" not in config
assert "execution_cooldown_seconds" not in config
def test_addon_version_invalidates_application_build_layer() -> None:
dockerfile = Path("addon/Dockerfile").read_text(encoding="utf-8")
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone

View File

@@ -15,6 +15,12 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12") monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12")
monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48") monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48")
monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600") monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600")
monkeypatch.setenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "4")
monkeypatch.setenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.9")
monkeypatch.setenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "20")
monkeypatch.setenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "45")
monkeypatch.setenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "1200")
monkeypatch.setenv("SILLYHOME_TIMEZONE", "Europe/Berlin")
settings = load_settings() settings = load_settings()
@@ -27,4 +33,10 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
assert settings.min_training_points == 12 assert settings.min_training_points == 12
assert settings.retrain_stale_hours == 48 assert settings.retrain_stale_hours == 48
assert settings.reconcile_interval_seconds == 600 assert settings.reconcile_interval_seconds == 600
assert settings.min_behavior_actions == 4
assert settings.prediction_confidence == 0.9
assert settings.prediction_window_minutes == 20
assert settings.prediction_interval_seconds == 45
assert settings.execution_cooldown_seconds == 1200
assert settings.timezone == "Europe/Berlin"
assert settings.ha_configured assert settings.ha_configured

View File

@@ -9,4 +9,28 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "Automation-Entwurf" 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 "Oder aus Liste wählen" in response.text
assert "Liste durchsuchen" 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 "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 "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text
assert "manual-context-freeform" in response.text
assert "Diese Kontext-Auswahl speichern" in response.text
assert "manual-context-select" 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.activation_ready" in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text

148
tests/test_main.py Normal file
View File

@@ -0,0 +1,148 @@
import asyncio
from collections.abc import Sequence
from pathlib import Path
from unittest.mock import MagicMock, patch
import anyio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import _ha_event_listener, app as fastapi_app, lifespan
class _FakeWebSocket:
def __init__(self, messages: list[str | BaseException]) -> None:
self._messages = messages
self.sent: list[dict[str, object]] = []
async def __aenter__(self) -> "_FakeWebSocket":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def recv(self) -> str:
message = self._messages.pop(0)
if isinstance(message, BaseException):
raise message
return message
async def send(self, message: str) -> None:
import json
self.sent.append(json.loads(message))
class _RecordingBehaviorEngine(BehaviorEngine):
def __init__(self, tmp_path: Path) -> None:
super().__init__(
ha_reader=MagicMock(),
store=ActuatorStore(tmp_path / "actuators"),
settings=MagicMock(),
)
self.state_changes: list[
tuple[str, dict[str, object] | None, Sequence[HaEntitySummary] | None]
] = []
def handle_state_change(
self,
entity_id: str,
new_state: dict[str, object] | None,
*,
current_entities: Sequence[HaEntitySummary] | None = None,
) -> None:
self.state_changes.append((entity_id, new_state, current_entities))
class _FakeHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> list[HaEntitySummary]:
return [
HaEntitySummary(
entity_id="light.test",
domain="light",
state="off",
)
]
def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
async def run_test() -> None:
fake_ws = _FakeWebSocket(
[
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"data":{"entity_id":"light.test","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws) as connect:
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=None,
)
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
]
mock_app = MagicMock()
mock_app.state.settings = MagicMock()
mock_app.state.settings.ha_url = "http://homeassistant:8123"
mock_app.state.settings.ha_token = "test-token"
mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()
anyio.run(run_test)
assert len(mock_engine.state_changes) == 1
entity_id, new_state, current_entities = mock_engine.state_changes[0]
assert entity_id == "light.test"
assert new_state == {"state": "on"}
assert current_entities == [
HaEntitySummary(entity_id="light.test", domain="light", state="on")
]
assert mock_app.state.ws_status.status == "connected"
assert mock_app.state.ws_status.error is None
def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI()
app.state.settings = MagicMock()
app.state.settings.ha_configured = False
async def run_test() -> None:
async with lifespan(app):
pass
anyio.run(run_test)
def test_websocket_health_returns_unavailable_without_listener() -> None:
with TestClient(fastapi_app) as client:
response = client.get("/health/websocket")
assert response.status_code == 200
assert response.json() == {
"status": "unavailable",
"error": "WebSocket-Listener nicht initialisiert",
}