Compare commits

..

38 Commits

Author SHA1 Message Date
5ca0c53f6a Reduce websocket reconnect load
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:17:50 +02:00
8070a85b52 Add actuator simulation tuning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:06:47 +02:00
575211f0db Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 11:53:53 +02:00
d9dc186f9b Fix HA websocket keepalive regression
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 07:48:46 +02:00
214b384b70 Release v1.6.0 dashboard architecture cleanup
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 01:02:29 +02:00
6323b93f23 Fix ingress logging and dashboard cache navigation
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:30:12 +02:00
1d176cce45 Add SQLite dashboard cache
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:07:47 +02:00
bd087728e1 Limit rollback snapshots and relax HA timeouts
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:44:21 +02:00
10f9113547 Stabilize dashboard loading hotfix
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:19:22 +02:00
47fa8eb0ce Split dashboard views and compact detail loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 22:34:38 +02:00
bc4e33ddd8 Localize and streamline dashboard loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 21:56:24 +02:00
9419a9cd8c Add anomaly and performance monitoring
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:58:32 +02:00
2ec2c64cba Add adaptive learning and model rollback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:41:03 +02:00
0101596e93 Add safety dashboard and decision transparency
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:26:49 +02:00
ca253d1e6c Fix dashboard text overflow and close v1 docs gaps
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:53:25 +02:00
b9b5def7bb Add actuator sensor weighting controls
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:41:46 +02:00
94530d3ecf Stream dashboard loading and header menu
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:55:28 +02:00
63b8684197 Document v1 acceptance and dashboard stats
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:45:30 +02:00
f8801e469a Polish v1 dashboard loading and layout
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:33:30 +02:00
787516ac67 Avoid per-request discovery classification in dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:19:59 +02:00
7ba9807a4e Prepare SillyHome Next 1.0.0 dashboard and API rework
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:13:43 +02:00
4db4276b95 Rework dashboard loading and cache entity metadata
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:41:50 +02:00
98a2b2cc38 Fix dashboard summary status rendering
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:21:14 +02:00
387e027fe2 Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:09:35 +02:00
f8bee92e64 Optimize dashboard categories and context loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:02:19 +02:00
9ddb065f62 Speed up HA event processing
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:58:58 +02:00
8222f24ebe Group configured actuator overview
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:51:02 +02:00
a7a2f8c78a Make SillyHome startup resilient
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:43:41 +02:00
faf4099756 Load actuator suggestions asynchronously
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:14:51 +02:00
1b2b76455a Tighten context onboarding and actuator suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:06:03 +02:00
18999ff68a Limit actuator picker results
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:43:00 +02:00
e2826e92ec Improve SillyHome discovery and feedback learning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:38:32 +02:00
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
37 changed files with 6960 additions and 305 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ __pycache__/
.env
.env.local
.env.*
/.actuator_store/
/MagicMock/

View File

@@ -1,5 +1,243 @@
# Changelog
## 1.7.0 - 2026-06-18
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
Event-Latenzmessungen und Dry-run pro Aktor.
- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und
sichtbare Job-Historie.
- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und
`never_automate` speichern; `never_automate` setzt eine manuelle Sperre.
- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und
lokale Agent-Insights aus vorhandenen Daten.
- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro
Home-Assistant-State-Change.
## 1.6.1 - 2026-06-18
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
## 1.6.0 - 2026-06-18
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
materialisieren.
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
der Aktor-IDs.
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
Versions-Chip im Einrichtungsbereich.
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
anderen beobachteten Geraets.
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
## 1.5.4 - 2026-06-18
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
laden nur bei expliziter Aktualisierung neu.
## 1.2.0 - 2026-06-17
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
wertet sie vorsichtig ab.
- Modell-Snapshots mit aktivem Modellstand und Rollback-API ergaenzt.
- Dashboard zeigt Modell-Snapshots, Rollback, Zeitprofile,
adaptive Gewichtungsupdates und Automation-Konflikte.
- Automation-Refresh markiert Konflikte, wenn SillyHome aktiv ist und passende
HA-Automationen parallel aktiv bleiben.
- Zeitprofile fuer Nacht, Morgen, Tag, Abend und Wochenende werden aus
gelernten Handlungen gebildet.
## 1.1.0 - 2026-06-17
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
Unsicherheiten und Beitragsfaktoren pro Aktor.
- Lokales Safety-Profil pro Aktor eingefuehrt: manuelle Sperre,
Freigabestufe, Mindest-Confidence und optionaler Cooldown werden vor
autonomem Schalten ausgewertet.
- Sofort-Schaltpfad bleibt schnell: Safety prueft nur lokale Daten; der
Home-Assistant-Serviceaufruf wird nicht durch Discovery, Training oder
Statistik blockiert.
- Sichtbare Job-Queue fuer Discovery, Reconciliation, Training, Evaluation
und Automation-Refresh mit Status, Dauer, Fehler und Zusammenfassung.
- Entscheidungsstatistik erweitert: Sensor-/Kontextfaktoren, aktive
Gewichtungen, Sample-/Confidence-Trends und Feedbackzaehler werden
persistiert.
## 1.0.5 - 2026-06-17
- Lange Friendly Names, Entity-IDs, Chips, Tabellenwerte und Metriken brechen
im Dashboard responsiv um und laufen nicht mehr aus Karten oder Boxen.
- Automatisierter Performance-Budget-Test fuer Root-HTML und
`/v1/actuators/dashboard` gegen das 5-Sekunden-Limit ergaenzt.
- HA-/Ingress-Verifikation mit Supervisor-Status, Backup, Watchdog,
Hard-Reload und Rollback im Operating Guide dokumentiert.
## 1.0.4 - 2026-06-17
- Sensor-Relevanz ist in der Aktor-Detailansicht sichtbar: automatische
Relevanz, aktive Gewichtung und Score werden pro verwendetem Sensor/Zustand
angezeigt.
- Gewichtungen koennen im Dashboard korrigiert und per API unter
`/v1/actuators/{actuator_entity_id}/weights` gespeichert werden.
- Gruppen-Gewichtungen buendeln mehrere Sensoren/Zustaende fuer einen Aktor,
damit verbundene Kontextsignale gemeinsam bewertet werden koennen.
## 1.0.3 - 2026-06-17
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.
- Dashboard startet in Phasen: leere Bedienoberflaeche, dann Status, danach
Geraetedaten.
- Detailansicht oeffnet streamartiger: zuerst Basis-Shell, dann Aktorwerte,
danach Kontextvorschlaege.
## 1.0.2 - 2026-06-17
- v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte,
teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar.
- Dashboard-Startstatistik erweitert: Freigabebereitschaft, Aktiv/Shadow,
Gelernt/Wartet und gelernte Handlungen werden direkt im Startbereich
zusammengefasst.
## 1.0.1 - 2026-06-17
- Dashboard-UI nach v1-Korrektur neu strukturiert: feste Steuerungsleiste,
separate Geräteübersicht, klare Freigabe-/Detailfläche und Statusbereich.
- Orange bleibt Primärfarbe; Cyan ist die sichtbare Komplementärfarbe. Rote
Aktions- und Fehlerflächen wurden aus der Oberfläche entfernt.
- Startpfad weiter beschleunigt: Dashboard lädt nur noch lokale Startdaten.
HA-Discovery, Vorschläge und Automation-Refresh laufen erst nach Nutzeraktion.
- Detailansicht öffnet ohne automatische Automation-Discovery. Passende
Automationen können gezielt per Button neu gesucht werden.
## 1.0.0 - 2026-06-17
- Neuer blockweiser Dashboard-Start über `/v1/actuators/dashboard`: lokale
Store-/Cache-Daten laden sofort, HA-Discovery und Vorschläge laufen
nachgelagert.
- Discovery liest Entities pro Anfrage nur noch einmal und klassifiziert aus
diesem Snapshot weiter. Dadurch entfallen doppelte HA-Vollabfragen.
- Persistenter JSON-Entity-Cache wird für Friendly Name, Raum, Gerät,
Discovery-Gruppen und schnelle Summaries genutzt.
- Dashboard mit Orange als Primärfarbe, kompakter Navigation, aufklappbarer
Anleitung, aufklappbaren Gerätegruppen und Cache-/Systemstatistik.
- Aktor-/Sensor-Kategorien erweitert: Feuchte, Wetter, Helligkeit, Bewegung,
Tür/Fenster, Präsenz, Lichtzustände, Schalter, Steckdosen, Lüftung, Heizung,
Cover, Helper, PV/Akku/Einspeisung.
- Kontextvorschläge vermeiden weitere doppelte HA-Discovery und sortieren
aktortypbezogen nach relevanten Bereichen.
## 0.7.21 - 2026-06-17
- Dashboard-Ladepfad getrennt: beobachtete Geräte laden sofort über
`/v1/actuators/summary`; Status, Discovery und Vorschläge laufen unabhängig
nachgelagert und blockieren die Übersicht nicht mehr.
- Systemstatus nutzt Timeouts und bleibt auch bei langsamem ML-/HA-Status
bedienbar.
- HA-Entity-Metadaten werden als JSON-Cache gespeichert und für Friendly Name,
Raum und Gerät in schlanken Summaries wiederverwendet.
- Anleitung, Gerätegruppen und manuelle Kontextauswahl sind aufklappbar und
kompakter für Smartphone- und Desktopansichten.
## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
## 0.7.19 - 2026-06-17
- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt
statt voller Lernmuster und kompletter HA-Entityliste.
- Nach Aktionen werden Dashboard-Caches gezielt invalidiert, damit keine
stale oder doppelt geladenen Einträge entstehen.
## 0.7.18 - 2026-06-16
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.
- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie
"Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen.
- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände,
Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer
kategorisiert und per Suche/Kategorie erreichbar.
- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur,
Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter.
## 0.7.17 - 2026-06-16
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
dem teuren State-Cache-Listenbau verworfen.
- WebSocket nutzt Keepalive und reconnectet nach Abbrüchen nach 1s statt 5s.
## 0.7.16 - 2026-06-16
- Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und
mit Friendly Name angezeigt.
## 0.7.15 - 2026-06-16
- Add-on-Start ist robust gegen Home-Assistant-Core-502 beim Systemboot:
API und WebSocket-Listener starten trotzdem, Reconciliation/Training werden
im Hintergrund mit Retry nachgeholt.
- Periodische Reconciliation und Fallback-Auswertung beenden den Dienst nicht
mehr bei temporären HA-Fehlern.
- Add-on-Watchdog prüft `/health`, damit Supervisor den Dienst nach Absturz
wieder starten kann.
## 0.7.14 - 2026-06-16
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
## 0.7.13 - 2026-06-16
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
übernommen.
- Verwendete Kontext-Entities können pro Aktor direkt entfernt und damit als
manuelle Zuordnung überschrieben werden.
- Onboarding-Vorschläge zeigen passende, noch nicht eingerichtete Aktoren aus
bestehenden Automationen und naheliegenden Kontexten.
- TV-/Medien-Aktoren über `media_player` und Fernbedienungen über `remote`
werden in Discovery und Auswahl berücksichtigt.
## 0.7.12 - 2026-06-16
- Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei
größeren Mengen zum Eingrenzen per Suche oder Typfilter auf.
## 0.7.11 - 2026-06-16
- Aktor-Discovery erkennt weitere steuerbare HA-Domains wie Buttons, Helper,
Heizungen, Schlösser, Ventile und numerische Helper.
- Aktor-Auswahl dedupliziert Licht-/Schalter-Doppelungen pro Gerät und gruppiert
zusätzliche Typen im Dashboard.
- Discovery liefert Kategorien für Mess-, Binär-, Kontext- und Aktor-Entities.
- Nutzerfeedback kann Vorhersagen als korrekt oder falsch markieren und direkt
als Lernsignal speichern.
## 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

View File

@@ -11,6 +11,28 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md)
- Entwickeln, testen, veröffentlichen und installieren:
[`docs/OPERATIONS.md`](docs/OPERATIONS.md)
- Version 1.0.0 bedienen und prüfen:
[`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md)
- Version 1.0.x Abnahme und offene Punkte:
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
- Version 1.1.0 Safety, Transparenz und Job-Queue:
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
- Version 1.3.0 Anomalie- und Performance-Überwachung:
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
- Version 1.5.2 Rollback-Speicher und HA-Timeouts:
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad
@@ -58,6 +80,8 @@ uvicorn app.main:app --reload
- `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/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
- `http://127.0.0.1:8000/v1/actuators/dashboard` - schnelle Dashboard-Startdaten aus Store und JSON-Cache
- `http://127.0.0.1:8000/v1/actuators/summary` - schlanke Liste beobachteter Aktoren
- `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

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "0.7.4"
version: "1.7.2"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next
@@ -7,6 +7,7 @@ arch:
- amd64
startup: application
boot: auto
watchdog: http://[HOST]:[PORT:8000]/health
init: false
ingress: true
ingress_port: 8000

View File

@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000

130
app/actuators/cache_db.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from threading import RLock
from app.ha.models import HaEntitySummary
class DashboardCache:
def __init__(self, path: str | Path) -> None:
self._path = Path(path).resolve()
self._path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._init()
def load_entities_payload(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"select entity_id, payload from ha_entities order by entity_id"
).fetchall()
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entities": [json.loads(row[1]) for row in rows],
}
def load_status(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
entity_count = connection.execute("select count(*) from ha_entities").fetchone()[0]
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entity_count": int(entity_count or 0),
}
def load_entity_map(self, entity_ids: set[str]) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
placeholders = ",".join("?" for _ in entity_ids)
with self._lock, self._connect() as connection:
rows = connection.execute(
f"select entity_id, payload from ha_entities where entity_id in ({placeholders})",
tuple(sorted(entity_ids)),
).fetchall()
result: dict[str, HaEntitySummary] = {}
for entity_id, payload in rows:
try:
result[str(entity_id)] = HaEntitySummary.model_validate(json.loads(payload))
except (TypeError, ValueError):
continue
return result
def save_entities_payload(
self,
*,
entities: list[HaEntitySummary],
discovery_groups: list[dict[str, object]],
) -> None:
now = datetime.now(timezone.utc).isoformat()
rows = [
(entity.entity_id, entity.model_dump_json())
for entity in entities
]
with self._lock, self._connect() as connection:
connection.execute("delete from ha_entities")
connection.executemany(
"insert into ha_entities(entity_id, payload) values (?, ?)",
rows,
)
self._set_meta(connection, "ha_entities_updated_at", now)
self._set_meta(
connection,
"discovery_groups",
json.dumps(discovery_groups, ensure_ascii=True, sort_keys=True),
)
def _init(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists ha_entities (
entity_id text primary key,
payload text not null
)
"""
)
connection.execute(
"""
create table if not exists cache_meta (
key text primary key,
value text
)
"""
)
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self._path, timeout=30)
@staticmethod
def _get_meta(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"select value from cache_meta where key = ?",
(key,),
).fetchone()
return str(row[0]) if row is not None and row[0] is not None else None
@staticmethod
def _set_meta(connection: sqlite3.Connection, key: str, value: str) -> None:
connection.execute(
"""
insert into cache_meta(key, value) values (?, ?)
on conflict(key) do update set value = excluded.value
""",
(key, value),
)

View File

@@ -16,11 +16,12 @@ from app.actuators.models import (
ManualOverride,
ModelLifecycleState,
ReconciliationState,
SensorWeightGroup,
model_id_for_actuator,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
@@ -57,7 +58,7 @@ _STOPWORDS = frozenset(
"value",
}
)
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"})
_GENERIC_AREA_NAMES = frozenset({"energie", "monitoring", "power", "strom", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18
@@ -72,13 +73,71 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"fan",
"humidifier",
"input_boolean",
"input_number",
"input_select",
"light",
"media_player",
"person",
"remote",
"scene",
"sensor",
"sun",
"switch",
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 120
_CONTEXT_SUGGESTION_LIMIT = 500
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"bytes",
"connect",
"count",
"data",
"diagnostic",
"firmware",
"gesehen",
"heat",
"inbytes",
"interface",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"outbytes",
"pfsense",
"reason",
"restart",
"rssi",
"signal",
"ssid",
"status",
"overheat",
"overheating",
"overload",
"uptime",
"vpn",
"uberhitzung",
"ueberhitzung",
"ueberlast",
"überhitzung",
"überlast",
"wifi",
"zuletzt",
})
_AUTO_CONTEXT_CLASSES = frozenset({
"door",
"garage_door",
"illuminance",
"motion",
"occupancy",
"opening",
"presence",
"window",
})
class ActuatorReconciliationService:
@@ -112,7 +171,7 @@ class ActuatorReconciliationService:
limit: int = _CONTEXT_SUGGESTION_LIMIT,
) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
@@ -131,8 +190,10 @@ class ActuatorReconciliationService:
selected = entity.entity_id in selected_ids
if selected:
score = max(score, 1.0)
if not selected and score < 0.1:
if not selected and _is_diagnostic_context(entity):
continue
if not selected and not _has_context_relationship(actuator, entity):
score = max(score, 0.01)
ranked.append((score, _context_sort_group(entity), entity))
ranked.sort(
key=lambda item: (
@@ -179,6 +240,10 @@ class ActuatorReconciliationService:
override = ManualOverride(
numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids,
sensor_weights=record.manual_override.sensor_weights if record.manual_override else {},
sensor_weight_groups=(
record.manual_override.sensor_weight_groups if record.manual_override else []
),
updated_at=now,
note=note,
)
@@ -193,17 +258,23 @@ class ActuatorReconciliationService:
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,
"numeric_candidates": _apply_weight_overrides(
_merge_manual_candidates(
record.numeric_candidates,
entities,
[numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
override,
),
"context_candidates": _merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
"context_candidates": _apply_weight_overrides(
_merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
),
override,
),
"lifecycle": lifecycle,
"updated_at": now,
@@ -211,6 +282,65 @@ class ActuatorReconciliationService:
)
return self._store.upsert(updated)
def set_weight_overrides(
self,
actuator_entity_id: str,
*,
sensor_weights: dict[str, float],
sensor_weight_groups: list[SensorWeightGroup],
note: str | None = None,
) -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
selected_ids.update(sensor_weights)
for group in sensor_weight_groups:
selected_ids.update(group.entity_ids)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
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(sorted(missing))}")
previous = record.manual_override
override = ManualOverride(
numeric_entity_id=(
previous.numeric_entity_id
if previous is not None
else record.assignment.selected_numeric_entity_id
),
context_entity_ids=(
previous.context_entity_ids
if previous is not None
else record.assignment.selected_context_entity_ids
),
sensor_weights={entity_id: round(weight, 4) for entity_id, weight in sensor_weights.items()},
sensor_weight_groups=sensor_weight_groups,
updated_at=now,
note=note,
)
updated = record.model_copy(
update={
"manual_override": override,
"numeric_candidates": _apply_weight_overrides(
record.numeric_candidates,
override,
),
"context_candidates": _apply_weight_overrides(
record.context_candidates,
override,
),
"updated_at": now,
}
)
return self._store.upsert(updated)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy(
update={
@@ -316,6 +446,9 @@ class ActuatorReconciliationService:
),
context=True,
)
if record.manual_override is not None:
numeric_candidates = _apply_weight_overrides(numeric_candidates, record.manual_override)
context_candidates = _apply_weight_overrides(context_candidates, record.manual_override)
assignment = (
self._manual_assignment(record.manual_override)
if record.manual_override is not None
@@ -598,8 +731,12 @@ class ActuatorReconciliationService:
if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
)
can_auto_accept_context = (
not context or _eligible_for_auto_context(actuator, candidate)
)
auto_accepted = (
candidate.score >= minimum_score
can_auto_accept_context
and candidate.score >= minimum_score
and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN)
)
@@ -672,6 +809,11 @@ def _manual_context_role(
def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
text = " ".join(
value.lower().replace("_", " ")
for value in [entity.entity_id, entity.friendly_name, entity.area_name, entity.device_name]
if value
)
if device_class in {"motion", "occupancy", "presence"}:
return "01_presence"
if device_class in {"illuminance"}:
@@ -680,13 +822,72 @@ def _context_sort_group(entity: HaEntitySummary) -> str:
return "03_opening"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if device_class in {"temperature"}:
return "05_temperature"
if any(token in text for token in {"pv", "solar", "akku", "batterie", "battery", "einspeisung"}):
return "06_pv_battery"
if device_class in {"power", "energy", "current", "voltage"}:
return "05_power"
return "07_power"
if entity.domain in {"weather"}:
return "08_weather"
if entity.domain in {"fan", "humidifier"}:
return "09_ventilation"
if entity.domain in {"climate"}:
return "10_heating"
if entity.domain in {"cover"}:
return "11_cover"
if entity.domain in {"light", "switch"}:
return "06_states"
return "12_states"
if entity.domain.startswith("input_"):
return "13_helper"
if entity.domain in {"person", "device_tracker"}:
return "14_people"
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 _eligible_for_auto_context(
actuator: HaEntitySummary,
candidate: AssignmentCandidate,
) -> bool:
device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES:
return True
if (
actuator.device_name
and candidate.device_name
and actuator.device_name == candidate.device_name
and candidate.domain in {"light", "switch"}
):
return True
return False
def _score_candidate(
actuator: HaEntitySummary,
entity: HaEntitySummary,
@@ -732,8 +933,7 @@ def _score_candidate(
if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
outdoor_tokens = {"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"}
if entity_tokens.intersection(outdoor_tokens) and entity.device_class in {
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
"illuminance",
"humidity",
"temperature",
@@ -754,12 +954,17 @@ def _merge_manual_candidates(
for entity_id in selected_entity_ids:
existing = by_id.get(entity_id)
if existing is not None:
evidence = [
item
for item in existing.evidence
if item != "Manuell vom Nutzer als relevant festgelegt."
]
by_id[entity_id] = existing.model_copy(
update={
"auto_accepted": True,
"confidence": 1.0,
"evidence": [
*existing.evidence,
*evidence,
"Manuell vom Nutzer als relevant festgelegt.",
],
}
@@ -786,22 +991,73 @@ def _merge_manual_candidates(
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
def _apply_weight_overrides(
candidates: list[AssignmentCandidate],
override: ManualOverride,
) -> list[AssignmentCandidate]:
if not override.sensor_weights and not override.sensor_weight_groups:
return candidates
group_weights: dict[str, float] = {}
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
group_weights[entity_id] = max(group_weights.get(entity_id, 0.0), group.weight)
weighted: list[AssignmentCandidate] = []
for candidate in candidates:
explicit = override.sensor_weights.get(candidate.entity_id)
group_weight = group_weights.get(candidate.entity_id)
manual_weight = explicit if explicit is not None else group_weight
effective_weight = manual_weight if manual_weight is not None else 1.0
evidence = [
item
for item in candidate.evidence
if not item.startswith("Manuelle Gewichtung:")
]
if manual_weight is not None:
evidence.append(f"Manuelle Gewichtung: {round(manual_weight * 100)} %.")
weighted.append(
candidate.model_copy(
update={
"manual_weight": manual_weight,
"effective_weight": round(effective_weight, 4),
"evidence": evidence,
}
)
)
return sorted(weighted, key=lambda item: (-item.confidence * item.effective_weight, item.entity_id))
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
mapping = {
"climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"},
"cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "illuminance", "motion", "occupancy", "opening", "presence", "window"},
"media_player": {"occupancy", "presence"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
}
return frozenset(
mapping.get(
domain,
{"door", "garage_door", "motion", "occupancy", "opening", "presence"},
)
)
mapping = {
"climate": {"temperature", "humidity", "power"},
"climate": {"temperature", "humidity"},
"cover": {"illuminance", "temperature", "wind_speed"},
"fan": {"temperature", "humidity", "power"},
"humidifier": {"humidity", "temperature", "power"},
"light": {"illuminance", "power", "energy"},
"fan": {"temperature", "humidity", "moisture"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"illuminance"},
"media_player": {"power", "energy"},
"remote": {"battery"},
"switch": {"power", "energy", "current"},
"valve": {"temperature", "pressure", "humidity"},
}
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 = [
entity.entity_id,
entity.friendly_name,
@@ -813,7 +1069,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
if value is None:
continue
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
tokens.add(token)
return tokens

View File

@@ -37,6 +37,29 @@ class BehaviorStatus(StrEnum):
BLOCKED = "blocked"
class SafetyStage(StrEnum):
OBSERVE = "observe"
SUGGEST = "suggest"
SHADOW = "shadow"
PARTIAL = "partial"
ACTIVE = "active"
class JobStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class FeedbackKind(StrEnum):
CORRECT = "correct"
WRONG = "wrong"
TOO_EARLY = "too_early"
TOO_LATE = "too_late"
NEVER_AUTOMATE = "never_automate"
class AssignmentCandidate(BaseModel):
entity_id: str
domain: str
@@ -49,6 +72,8 @@ class AssignmentCandidate(BaseModel):
device_name: str | None = None
score: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.0)
manual_weight: float | None = Field(default=None, ge=0.0, le=1.0)
effective_weight: float = Field(default=1.0, ge=0.0, le=1.0)
auto_accepted: bool = False
evidence: list[str] = Field(default_factory=list)
@@ -62,9 +87,18 @@ class AssignmentSelection(BaseModel):
reason: str = "Noch keine Zuordnung vorhanden."
class SensorWeightGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
entity_ids: list[str] = Field(default_factory=list)
weight: float = Field(default=1.0, ge=0.0, le=1.0)
class ManualOverride(BaseModel):
numeric_entity_id: str | None = None
context_entity_ids: list[str] = Field(default_factory=list)
sensor_weights: dict[str, float] = Field(default_factory=dict)
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
note: str | None = None
@@ -110,11 +144,148 @@ class BehaviorPrediction(BaseModel):
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
class DecisionFactor(BaseModel):
entity_id: str | None = None
label: str
factor_type: str = Field(max_length=40)
state: str | None = None
weight: float = Field(default=1.0, ge=0.0, le=1.0)
contribution: float = Field(default=0.0, ge=0.0, le=1.0)
evidence: list[str] = Field(default_factory=list)
class SimulationOutcome(BaseModel):
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
actuator_entity_id: str
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
prediction: BehaviorPrediction | None = None
decision_factors: list[DecisionFactor] = Field(default_factory=list)
would_execute: bool = False
blockers: list[str] = Field(default_factory=list)
score: float = Field(default=0.0, ge=0.0, le=1.0)
recommendation: str = Field(default="", max_length=700)
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
trigger_state: str | None = None
target_state: str | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
executed: bool = False
blocked: bool = False
reason: str = Field(default="", max_length=700)
blockers: list[str] = Field(default_factory=list)
duration_ms: int | None = Field(default=None, ge=0)
class LatencyMeasurement(BaseModel):
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
event_to_decision_ms: int | None = Field(default=None, ge=0)
decision_to_service_ms: int | None = Field(default=None, ge=0)
event_to_done_ms: int | None = Field(default=None, ge=0)
executed: bool = False
source: str = Field(default="manual", max_length=40)
class AdaptiveWeightUpdate(BaseModel):
entity_id: str
previous_weight: float = Field(ge=0.0, le=1.0)
new_weight: float = Field(ge=0.0, le=1.0)
reason: str = Field(max_length=300)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class SafetyRule(BaseModel):
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=160)
enabled: bool = True
blocking: bool = True
reason: str = Field(default="", max_length=300)
def default_safety_rules() -> list[SafetyRule]:
return [
SafetyRule(
rule_id="activation_ready",
label="Nur nach Lernfreigabe aktiv schalten",
reason="Der Aktor muss genug eindeutiges Verhalten gelernt haben.",
),
SafetyRule(
rule_id="confidence_threshold",
label="Mindest-Sicherheit einhalten",
reason="Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus.",
),
SafetyRule(
rule_id="cooldown",
label="Sicherheits-Cooldown gegen Hin-und-her-Schalten",
reason="Gleiche Zielzustände werden nicht zu schnell wiederholt.",
),
SafetyRule(
rule_id="manual_block",
label="Manuelle Sperre respektieren",
reason="Nutzer können jeden Aktor sofort blockieren.",
),
]
class SafetyProfile(BaseModel):
stage: SafetyStage = SafetyStage.SHADOW
manual_block: bool = False
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
min_confidence_on: float | None = Field(default=None, ge=0.0, le=1.0)
min_confidence_off: float | None = Field(default=None, ge=0.0, le=1.0)
cooldown_seconds: int | None = Field(default=None, ge=0)
rules: list[SafetyRule] = Field(default_factory=default_safety_rules)
updated_at: datetime | None = None
note: str | None = Field(default=None, max_length=500)
class ExecutionEvent(BaseModel):
target_state: str
executed_at: datetime
class ModelSnapshot(BaseModel):
version_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
sample_count: int = Field(default=0, ge=0)
high_confidence_sample_count: int = Field(default=0, ge=0)
average_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
incorrect_feedback_count: int = Field(default=0, ge=0)
patterns: list[BehaviorPattern] = Field(default_factory=list)
reason: str = Field(default="", max_length=500)
class AutomationConflict(BaseModel):
automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
severity: str = Field(default="info", max_length=20)
status: str = Field(default="open", max_length=40)
reason: str = Field(max_length=500)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AnomalyEvent(BaseModel):
anomaly_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
category: str = Field(max_length=40)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=500)
detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
resolved: bool = False
class TimeProfile(BaseModel):
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=80)
sample_count: int = Field(default=0, ge=0)
dominant_state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class RelatedAutomation(BaseModel):
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
config_id: str = Field(min_length=1, max_length=120)
@@ -122,6 +293,32 @@ class RelatedAutomation(BaseModel):
enabled: bool
class ActuatorGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
area_name: str | None = Field(default=None, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
reason: str = Field(default="", max_length=300)
class SceneSuggestion(BaseModel):
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str = Field(default="", max_length=500)
last_seen_at: datetime | None = None
class AgentInsight(BaseModel):
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=700)
action: str | None = Field(default=None, max_length=300)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING
@@ -139,6 +336,32 @@ class BehaviorState(BaseModel):
related_automations: list[RelatedAutomation] = Field(default_factory=list)
paused_automation_entity_ids: list[str] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert."
safety: SafetyProfile = Field(default_factory=SafetyProfile)
decision_factors: list[DecisionFactor] = Field(default_factory=list)
knowledge: list[str] = Field(default_factory=list)
assumptions: list[str] = Field(default_factory=list)
uncertainties: list[str] = Field(default_factory=list)
safety_blockers: list[str] = Field(default_factory=list)
sample_trend: list[int] = Field(default_factory=list)
confidence_trend: list[float] = Field(default_factory=list)
correct_feedback_count: int = Field(default=0, ge=0)
incorrect_feedback_count: int = Field(default=0, ge=0)
model_snapshots: list[ModelSnapshot] = Field(default_factory=list)
active_model_version: str | None = None
adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list)
anomalies: list[AnomalyEvent] = Field(default_factory=list)
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
feedback_log: list[FeedbackKind] = Field(default_factory=list)
dry_run_enabled: bool = False
dry_run_started_at: datetime | None = None
dry_run_sample_count: int = Field(default=0, ge=0)
dry_run_hit_count: int = Field(default=0, ge=0)
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
agent_insights: list[AgentInsight] = Field(default_factory=list)
class ActuatorRecord(BaseModel):
@@ -165,5 +388,22 @@ class ReconciliationState(BaseModel):
last_summary: str = "Noch keine Reconciliation ausgeführt."
class JobQueueItem(BaseModel):
job_id: str = Field(min_length=1, max_length=120)
kind: str = Field(min_length=1, max_length=40)
target: str | None = Field(default=None, max_length=160)
trigger: str = Field(default="manual", max_length=40)
status: JobStatus = JobStatus.PENDING
started_at: datetime | None = None
completed_at: datetime | None = None
duration_ms: int | None = Field(default=None, ge=0)
error: str | None = Field(default=None, max_length=500)
summary: str = Field(default="", max_length=500)
class JobQueueState(BaseModel):
jobs: list[JobQueueItem] = Field(default_factory=list)
def model_id_for_actuator(actuator_entity_id: str) -> str:
return f"actuator.{actuator_entity_id}"

View File

@@ -8,6 +8,9 @@ from threading import RLock
from app.actuators.models import (
ActuatorRecord,
JobQueueItem,
JobQueueState,
JobStatus,
LifecycleStatus,
ModelLifecycleState,
ReconciliationState,
@@ -22,6 +25,7 @@ class ActuatorStore:
self._actuators_root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._reconciliation_state_path = self._root / "reconciliation_state.json"
self._job_queue_path = self._root / "job_queue.json"
def list(self) -> list[ActuatorRecord]:
with self._lock:
@@ -85,6 +89,80 @@ class ActuatorStore:
self._persist_reconciliation_state(state)
return state
def load_job_queue(self) -> JobQueueState:
with self._lock:
if not self._job_queue_path.exists():
return JobQueueState()
try:
return JobQueueState.model_validate_json(
self._job_queue_path.read_text(encoding="utf-8")
)
except ValueError as exc:
raise ValueError("Ungültiger Job-Queue-Status.") from exc
def save_job_queue(self, queue: JobQueueState) -> JobQueueState:
with self._lock:
self._persist_job_queue(queue)
return queue
def start_job(
self,
*,
kind: str,
trigger: str,
target: str | None = None,
summary: str = "",
) -> JobQueueItem:
now = datetime.now(timezone.utc)
job = JobQueueItem(
job_id=f"{now.strftime('%Y%m%d%H%M%S%f')}-{kind}-{target or 'all'}",
kind=kind,
target=target,
trigger=trigger,
status=JobStatus.RUNNING,
started_at=now,
summary=summary,
)
with self._lock:
queue = self.load_job_queue()
queue.jobs = [*queue.jobs, job][-50:]
self._persist_job_queue(queue)
return job
def finish_job(
self,
job_id: str,
*,
status: JobStatus,
summary: str = "",
error: str | None = None,
) -> JobQueueItem | None:
now = datetime.now(timezone.utc)
with self._lock:
queue = self.load_job_queue()
updated_job: JobQueueItem | None = None
jobs: list[JobQueueItem] = []
for job in queue.jobs:
if job.job_id != job_id:
jobs.append(job)
continue
duration_ms = None
if job.started_at is not None:
duration_ms = max(0, int((now - job.started_at).total_seconds() * 1000))
updated_job = job.model_copy(
update={
"status": status,
"completed_at": now,
"duration_ms": duration_ms,
"summary": summary or job.summary,
"error": error,
}
)
jobs.append(updated_job)
queue.jobs = jobs[-50:]
self._persist_job_queue(queue)
return updated_job
def _target(self, actuator_entity_id: str) -> Path:
if "." not in actuator_entity_id:
raise ValueError("Ungültige actuator_entity_id.")
@@ -108,6 +186,14 @@ class ActuatorStore:
)
os.replace(temporary, self._reconciliation_state_path)
def _persist_job_queue(self, state: JobQueueState) -> None:
temporary = self._job_queue_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, self._job_queue_path)
@staticmethod
def _load(path: Path) -> ActuatorRecord:
try:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,8 @@ class Settings:
prediction_interval_seconds: int = 60
execution_cooldown_seconds: int = 900
timezone: str = "Europe/Berlin"
ha_timeout_seconds: int = 25
dashboard_cache_refresh_seconds: int = 3600
@property
def ha_configured(self) -> bool:
@@ -55,4 +57,8 @@ def load_settings() -> Settings:
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
),
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
ha_timeout_seconds=max(5, int(os.getenv("SILLYHOME_HA_TIMEOUT_SECONDS", "25"))),
dashboard_cache_refresh_seconds=max(
300, int(os.getenv("SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS", "3600"))
),
)

View File

@@ -21,6 +21,7 @@ class DiscoveredEntity(BaseModel):
device_class: str | None = None
state_class: str | None = None
unit_of_measurement: str | None = None
category: str
role: EntityRole
learnable: bool
reason: str
@@ -82,14 +83,42 @@ _BINARY_CONTEXT_CLASSES = frozenset({
"window",
})
_ACTUATOR_DOMAINS = frozenset({
"button",
"climate",
"cover",
"fan",
"humidifier",
"input_boolean",
"input_button",
"lock",
"light",
"media_player",
"number",
"remote",
"scene",
"siren",
"switch",
"valve",
})
_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"input_boolean",
"input_datetime",
"input_number",
"input_select",
"person",
"sun",
"weather",
"zone",
})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"input_boolean",
"input_number",
"input_select",
"person",
"weather",
})
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})
_NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"})
@@ -102,6 +131,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result(
entity,
EntityRole.MEASUREMENT,
category=_measurement_category(entity),
learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.",
)
@@ -110,6 +140,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result(
entity,
EntityRole.BINARY_CONTEXT,
category=_binary_category(entity),
learnable=True,
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
)
@@ -119,6 +150,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result(
entity,
EntityRole.CONTEXT,
category=_context_category(entity),
learnable=learnable,
reason=(
"Kontextquelle für Training und Erklärungen."
@@ -131,6 +163,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result(
entity,
EntityRole.ACTUATOR,
category=_actuator_category(entity),
learnable=False,
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
)
@@ -138,6 +171,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result(
entity,
EntityRole.UNSUPPORTED,
category="unsupported",
learnable=False,
reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.",
)
@@ -162,6 +196,7 @@ def _result(
entity: HaEntitySummary,
role: EntityRole,
*,
category: str,
learnable: bool,
reason: str,
) -> DiscoveredEntity:
@@ -171,7 +206,117 @@ def _result(
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
category=category,
role=role,
learnable=learnable,
reason=reason,
)
def _actuator_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain == "light":
return "light"
if entity.domain == "switch":
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket"
return "switch_socket"
if entity.domain == "button" or entity.domain == "input_button":
return "button"
if entity.domain == "cover":
return "cover_shutter"
if entity.domain == "climate":
return "heating"
if entity.domain == "lock":
return "lock"
if entity.domain == "fan":
return "fan"
if entity.domain in {"media_player", "remote"}:
return "media_tv"
if entity.domain == "scene":
return "scene"
if entity.domain in {"input_boolean", "number"}:
return "helper"
return entity.domain
def _measurement_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
text = _entity_text(entity)
if any(
token in text
for token in {
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
}
):
return "pv_battery_grid"
if entity.domain == "weather":
return "weather"
if device_class == "illuminance":
return "brightness"
if device_class == "temperature":
return "temperature"
if device_class in {"humidity", "moisture"}:
return "humidity"
if device_class in {"power", "energy", "current", "voltage", "apparent_power"}:
return "energy_power"
if device_class in {"battery", "signal_strength"}:
return "diagnostic"
return "measurement"
def _binary_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class in {"motion", "occupancy", "presence"}:
return "presence_motion"
if device_class in {"door", "garage_door", "opening", "window"}:
return "opening"
if device_class in {"smoke", "safety", "problem"}:
return "safety"
if device_class in {"lock"}:
return "lock_state"
return "binary"
def _context_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain.startswith("input_"):
return "helper"
if entity.domain in {"person", "device_tracker", "zone"}:
return "presence_location"
if entity.domain == "weather":
return "weather"
if entity.domain in {"light"}:
return "light_state"
if entity.domain in {"switch"}:
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket_state"
return "switch_state"
if entity.domain in {"climate"}:
return "heating_state"
if entity.domain in {"fan", "humidifier"}:
return "ventilation_state"
if entity.domain in {"cover"}:
return "cover_state"
return entity.domain
def _entity_text(entity: HaEntitySummary) -> str:
return " ".join(
value.lower().replace("_", " ")
for value in [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
if value
)

View File

@@ -3,6 +3,7 @@ import json
import logging
from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from pathlib import Path
from typing import cast
@@ -11,6 +12,7 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router
@@ -19,6 +21,8 @@ from app.behavior.engine import BehaviorEngine
from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.discovery import discover_entities
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes
@@ -41,11 +45,16 @@ class _WsStatus:
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings
client: HaClient | None = None
startup_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
cache_refresh_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store)
app.state.actuator_store = ActuatorStore(settings.actuator_store)
app.state.dashboard_cache = DashboardCache(
Path(settings.actuator_store).resolve() / "dashboard_cache.sqlite3"
)
if hasattr(app.state, "ha_reader"):
del app.state.ha_reader
if hasattr(app.state, "actuator_service"):
@@ -57,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=HaClientSettings(
url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token),
timeout_seconds=settings.ha_timeout_seconds,
)
)
app.state.ha_reader = HaReader(client=client)
@@ -72,15 +82,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=settings,
)
app.state.ws_status = _WsStatus()
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)
startup_task = asyncio.create_task(_startup_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))
cache_refresh_task = asyncio.create_task(_periodic_dashboard_cache_refresh(app))
try:
yield
finally:
if startup_task is not None:
startup_task.cancel()
with suppress(asyncio.CancelledError):
await startup_task
if reconcile_task is not None:
reconcile_task.cancel()
with suppress(asyncio.CancelledError):
@@ -93,6 +106,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
fallback_task.cancel()
with suppress(asyncio.CancelledError):
await fallback_task
if cache_refresh_task is not None:
cache_refresh_task.cancel()
with suppress(asyncio.CancelledError):
await cache_refresh_task
if client is not None:
client.close()
@@ -100,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.4",
version="1.7.2",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -145,33 +162,107 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
service = getattr(app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
continue
await asyncio.to_thread(service.reconcile_all, "scheduled")
try:
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)
except Exception:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
async def _periodic_dashboard_cache_refresh(app: FastAPI) -> None:
await asyncio.sleep(2)
while True:
await _refresh_dashboard_cache(app, trigger="scheduled")
await asyncio.sleep(app.state.settings.dashboard_cache_refresh_seconds)
async def _refresh_dashboard_cache(app: FastAPI, *, trigger: str) -> None:
ha_reader = getattr(app.state, "ha_reader", None)
cache = getattr(app.state, "dashboard_cache", None)
if not isinstance(ha_reader, HaReader) or not isinstance(cache, DashboardCache):
return
try:
entities = await asyncio.to_thread(ha_reader.read_entities)
groups = _discovery_group_payload(list(entities))
await asyncio.to_thread(
cache.save_entities_payload,
entities=list(entities),
discovery_groups=groups,
)
logger.info("Dashboard-Cache aktualisiert (%s): %d Entities", trigger, len(entities))
except Exception as exc:
logger.warning("Dashboard-Cache konnte nicht aktualisiert werden (%s): %s", trigger, exc)
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
group_counts: dict[tuple[str, str], int] = {}
for entity in discover_entities(entities):
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
return [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
]
async def _startup_reconciliation(app: FastAPI) -> None:
delay_seconds = 5
while True:
service = getattr(app.state, "actuator_service", None)
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
if not isinstance(service, ActuatorReconciliationService) or not isinstance(
engine,
BehaviorEngine,
):
return
try:
await asyncio.to_thread(service.reconcile_all, "startup")
await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
return
except Exception as exc:
logger.warning(
"Startup-Reconciliation verschoben: %s. Neuer Versuch in %ss.",
exc,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
delay_seconds = min(delay_seconds * 2, 60)
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):
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)
reconnect_delay = 1.0
relevant_entity_ids: set[str] = set()
relevant_loaded_at = 0.0
while True:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(ws_url) as websocket:
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":
@@ -194,6 +285,10 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
continue
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = asyncio.get_running_loop().time()
reconnect_delay = 1.0
if ws_status is not None:
ws_status.status = "connected"
ws_status.error = None
@@ -213,28 +308,56 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
event = data.get("event", {})
if event.get("event_type") != "state_changed":
continue
entity_id = event.get("entity_id")
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
loop_time = asyncio.get_running_loop().time()
if loop_time - relevant_loaded_at >= 10:
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = loop_time
if entity_id not in relevant_entity_ids:
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, event.get("new_state"))
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)
except (
websockets.exceptions.ConnectionClosed,
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
delay = reconnect_delay
logger.warning(
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
exc,
delay,
)
if ws_status is not None:
ws_status.status = "reconnecting"
ws_status.error = str(exc)
await asyncio.sleep(5)
await asyncio.sleep(delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
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)
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -244,7 +367,13 @@ async def _fallback_prediction(app: FastAPI) -> None:
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
"""
while True:
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
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 max(30, 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":
@@ -254,4 +383,81 @@ async def _fallback_prediction(app: FastAPI) -> None:
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
ws_status.status if ws_status else "unavailable",
)
await asyncio.to_thread(engine.evaluate_all)
try:
await asyncio.to_thread(engine.evaluate_all)
except Exception:
logger.exception("Fallback-Vorhersage fehlgeschlagen.")
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 _relevant_entity_ids(store: ActuatorStore) -> set[str]:
result: set[str] = set()
for record in store.list():
result.add(record.actuator_entity_id)
if record.assignment.selected_numeric_entity_id:
result.add(record.assignment.selected_numeric_entity_id)
result.update(record.assignment.selected_context_entity_ids)
return result
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
# SillyHome Next 1.0.0 Operating Guide
Diese Version stabilisiert den produktiven Kern: schnelle Dashboard-Nutzung,
lokales Caching, klare Aktor-/Sensor-Kategorien und nachvollziehbare Freigabe
gelernter Aktionen.
Die detaillierte Abnahme steht in
[`V1_0_ACCEPTANCE.md`](V1_0_ACCEPTANCE.md). Dort sind erledigte, teilweise
erledigte und fuer v1.0.x offene Punkte getrennt dokumentiert.
## Grundprinzip
- Home Assistant bleibt die Quelle fuer aktuelle States und Services.
- SillyHome cached schwere Entity-/Discovery-Metadaten lokal als JSON.
- Die Startansicht liest nur lokale Store-/Cache-Daten.
- Vollstaendige Discovery, Vorschlaege und Detailanalysen laden blockweise nach.
- Es gibt keine externen Pings oder Cloud-Abfragen im Dashboard-Startpfad.
## Wichtige Endpunkte
- `GET /health`
Lokaler API-Status ohne externe Abfrage.
- `GET /health/websocket`
Status des Home-Assistant-WebSocket-Listeners.
- `GET /v1/actuators/dashboard`
Schnelle Dashboard-Startdaten aus Store und JSON-Cache.
- `GET /v1/actuators/summary`
Schlanke Liste beobachteter Aktoren ohne Lernmuster-Payload.
- `GET /v1/actuators/discovery`
Aktor-Auswahl aus gecachten oder frisch geladenen HA-Entities.
- `GET /v1/actuators/context-options?actuator_entity_id=...`
Sensor-/Kontextvorschlaege fuer einen konkreten Aktor.
- `POST /v1/actuators/{entity_id}/assignment`
Manuelle Sensor-/Kontextzuordnung speichern.
- `POST /v1/actuators/{entity_id}/activation`
Freigabe oder Stop des automatischen Schaltens.
## Cache
Der Entity-Cache liegt neben dem Aktor-Store als `ha_entity_cache.json`.
Er enthaelt HA-Entity-Metadaten wie Friendly Name, Bereich, Device und
Kategoriegrundlagen.
Der Cache wird geschrieben, wenn Discovery frische HA-Entities liest. Danach
koennen Dashboard und Summary ohne erneute HA-Vollabfrage Namen, Raeume und
Gruppen anzeigen.
## Dashboard-Nutzung
1. Startansicht oeffnen.
2. `System & Cache` zeigt API, WebSocket, Cache-Groesse und geladene
Discovery-Gruppen.
3. `Geraet zum Lernen auswaehlen` nutzt Suche, Typfilter und direkte
Entity-ID-Eingabe.
4. `Beobachtete Geraete` zeigt gelernte Aktoren nach Raum oder Typ gruppiert.
5. `Details` zeigt Lernfortschritt, Freigabe, Vorhersage, verwendete
Sensoren/Zustaende und Entscheidungsgruende.
## Kategorien
Aktoren:
- Licht, LED, Lampen
- Schalter, Steckdosen, Helper
- Lueftung, Ventilatoren, Befeuchter/Entfeuchter
- Heizungen/Klima
- Rolllaeden/Cover
- TV/Medien/Fernbedienungen
- Szenen, Buttons, Schloesser, Ventile
Sensoren und Kontext:
- Luftfeuchtigkeit und Feuchte
- Temperatur
- Wetter
- Helligkeit/Lux
- Bewegung, Praesenz, Anwesenheit
- Tuer/Fenster/Oeffnung
- Licht-/Schalter-/Steckdosenstatus
- Strom, Leistung, Energie, Einspeisung
- PV, Akku, Wechselrichter
- Helper und Szenen
## Qualitaetspruefung
Vor Release:
```bash
.venv/bin/pytest -q
.venv/bin/ruff check .
.venv/bin/mypy app backend tests
git diff --check
```
Live nach Installation:
```bash
wget -qO- http://58adbe1e-sillyhome-next:8000/health
wget -qO- http://58adbe1e-sillyhome-next:8000/health/websocket
wget -qO /tmp/summary.json http://58adbe1e-sillyhome-next:8000/v1/actuators/summary
wget -qO /tmp/dashboard.json http://58adbe1e-sillyhome-next:8000/v1/actuators/dashboard
```
Wenn der Add-on-Container aus dem Agent-Host nicht direkt routbar ist, gilt der
Home-Assistant-Supervisor als Verifikationsquelle:
- Add-on-Info pruefen: Version, `version_latest`, `update_available`, `state`,
`boot` und `watchdog`.
- Vor Updates eine Home-Assistant-Teil-Sicherung fuer **SillyHome Next**
erstellen.
- Nach einem Store-Reload und Update muss `version == version_latest`,
`update_available == false`, `state == started`, `boot == auto` und
`watchdog == true` gelten.
- Den HA-/Ingress-Tab nach jedem Update hart neu laden, weil Home Assistant
sonst alte HTML-/JavaScript-Ressourcen aus dem bestehenden Tab verwenden kann.
- Rollback erfolgt ueber die vorherige Add-on-Teil-Sicherung oder den letzten
Git-Tag; beide Referenzen im Release-/Abnahmeprotokoll notieren.
## Rollback
Der stabile Vor-1.0-Stand ist `v0.7.21`. Vor dem 1.0.0-Umbau wurde ein
Git-Bundle-Backup erstellt:
`/root/.openclaw/workspace/backups/sillyhome-next/`
Bei Problemen kann auf `v0.7.21` zurueck installiert werden.

82
docs/V1_0_ACCEPTANCE.md Normal file
View File

@@ -0,0 +1,82 @@
# SillyHome Next v1.0 Acceptance
Stand: 2026-06-17
Diese Abnahme trennt belegte Umsetzung von offenen v1.0.x-Nacharbeiten. Der
Funktionskern bleibt aktorzentriert: Nutzer waehlen Aktoren, SillyHome lernt
Kontext und Verhalten, laeuft zuerst im Shadow-Modus und schaltet erst nach
expliziter Freigabe.
## Erfuellt
- Versioniert, gepusht und installiert:
- `v1.0.0`: API-/Cache-Umbau
- `v1.0.1`: Dashboard-/Performance-Korrektur
- Startpfad:
- `/v1/actuators/dashboard` liefert lokale Startdaten aus Store und Cache.
- Dashboard blockiert nicht mehr auf Discovery, Vorschlaegen oder
Automation-Refresh.
- Frontend bricht den Startdaten-Request nach 4,5 Sekunden ab und bleibt
bedienbar.
- Cache:
- HA-Entity-Metadaten werden als `ha_entity_cache.json` gespeichert.
- Summary und Dashboard verwenden Friendly Name, Area und Device aus Cache.
- Keine externen Abfragen im Dashboard-Startpfad:
- Kein Cloud-Ping, keine Fremd-API.
- HA-Zugriffe bleiben lokal gegen Home Assistant.
- Dashboard:
- Orange ist Primaerfarbe.
- Cyan ist sichtbare Komplementaerfarbe.
- Rote UI-Flaechen wurden entfernt.
- Steuerung, beobachtete Geraete, Lernfortschritt/Freigabe und Systemstatus
sind getrennte Bereiche.
- Discovery, Vorschlaege und Automation-Suche laden erst bei Nutzeraktion.
- Lernfortschritt und Freigabe:
- Karten zeigen Modus, Status, Handlungen, Vorhersage und Freigabestatus.
- Detailansicht zeigt Zuordnung, Sicherheit, Lernstand, Vorhersage,
Feedback, passende HA-Automationen und verwendete Sensoren/Zustaende.
- Direkte HA-Nutzung:
- Aktor-Schaltungen laufen ueber Home-Assistant-Serviceaufrufe.
- Automation-Steuerung nutzt Home-Assistant-Endpunkte und gecachte
Automation-Metadaten.
- Qualitaet:
- `pytest -q`
- `ruff check .`
- `mypy app backend tests`
- `git diff --check`
- Performance-Budget:
- Automatisierter Test prueft Root-HTML und `/v1/actuators/dashboard` gegen
das 5-Sekunden-Budget mit kontrollierten Fake-HA-/Cache-Daten.
- HA-/Ingress-Verifikation:
- Supervisor-Update, Add-on-Status, Watchdog, Backup, Ingress-Hard-Reload
und Rollback sind im Operating Guide dokumentiert.
## Teilweise Erfuellt
- Bessere Statistik:
- Startbereich zeigt Aktoren, Freigabebereitschaft, Aktiv/Shadow,
Gelernt/Wartet, gelernte Handlungen, Discovery-Gruppen und Cache-Zeitpunkt.
- Noch offen: Verlaufsgrafiken, p95-Latenzen und Trendstatistik je Aktor.
- Kontrollierte Abarbeitung und Queue:
- Reconciliation/Training laufen kontrolliert im Prozess und sind testbar.
- Noch offen: sichtbare Job-Queue mit Laufzeit, Fehlern und Retry-Status im
Dashboard.
- Saubere Issues:
- v1.0.0-Issues #41 bis #47 wurden geschlossen.
- Rueckblickend waren sie zu grob; v1.0.x bekommt feinere Folgeissues fuer
Statistik, Queue-Sichtbarkeit und Performance-Budgets.
## Offen Fuer v1.0.x
- Dashboard-Jobstatus fuer Reconciliation, Training, Discovery und
Automation-Refresh.
- Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark
beigetragen haben, wie sich Confidence und Sample Count entwickeln.
## Rollback
- Git-Bundle-Backups liegen unter
`/root/.openclaw/workspace/backups/sillyhome-next/`.
- Vor `v1.0.1` wurde ein Home-Assistant-Teilbackup des Add-ons angelegt.
Referenz: `18a5b387`.
- Letzter Vor-1.0-Stand: `v0.7.21`.

View File

@@ -0,0 +1,72 @@
# SillyHome Next v1.1.0 Operating Guide
## Ziel
v1.1.0 macht das Dashboard zur Zentrale fuer Visualisierung, Einrichtung,
Sicherheit und manuelles Gegensteuern. Autonomes Schalten bleibt ein kurzer
lokaler Pfad: Vorhersage und Safety-Profil werden aus bereits vorhandenen Daten
bewertet, danach folgt direkt der Home-Assistant-Serviceaufruf.
## Sicherheitsmodell
Jeder Aktor hat ein Safety-Profil:
- `stage`: Beobachten, Vorschlagen, Shadow, Teilaktiv oder Aktiv.
- `manual_block`: harte manuelle Sperre.
- `min_confidence`: Mindest-Sicherheit fuer autonomes Schalten.
- `cooldown_seconds`: optionaler Aktor-Cooldown gegen schnelles Hin-und-her.
- Safety-Regeln: Freigabe, Confidence, Cooldown und manuelle Sperre.
Ein Aktor schaltet nur, wenn alle lokalen Safety-Regeln frei sind, der
Behavior-Modus aktiv ist, die Freigabe bereit ist, die Confidence passt, der
Zielzustand noch nicht erreicht ist und der Cooldown abgelaufen ist.
## Transparenz
Die Aktor-Detailansicht trennt:
- Wissen: belegte Fakten aus Historie, Zuordnung und Automationen.
- Annahmen: heuristische Schluesse wie Zeit-/Kontext-Aehnlichkeit.
- Unsicherheiten: geringe Datenmenge, unklare Quellen, Review-Bedarf oder
negatives Feedback.
- Beitragsfaktoren: Sensoren, Kontextsignale, aktive Gewichtung und Beitrag.
- Safety-Blocker: Gruende, warum nicht geschaltet wird.
## Job-Queue
Das Dashboard zeigt die letzten Jobs mit Status, Dauer, Fehler und
Zusammenfassung. Sichtbar sind:
- Discovery
- Reconciliation
- Training
- Evaluation
- Automation-Refresh
Die Queue ist persistent in `job_queue.json` und dient als Betriebsanzeige. Sie
blockiert nicht den Startpfad und nicht den Schaltpfad.
## Manuelles Gegensteuern
Im Dashboard koennen pro Aktor gesetzt werden:
- manuelle Sicherheitssperre
- Freigabestufe
- Mindest-Confidence
- optionaler Cooldown
- Sensor-Gewichtungen und Gruppen-Gewichtungen
- Kontextauswahl
- Feedback: Vorhersage korrekt/falsch
- HA-Automationen pausieren/fortsetzen
## Qualitaetspruefung
Vor Release:
```bash
.venv/bin/pytest -q
.venv/bin/ruff check .
.venv/bin/mypy app backend tests
git diff --check
node --check /tmp/sillyhome-dashboard.js
```

View File

@@ -0,0 +1,62 @@
# SillyHome Next v1.2.0 Operating Guide
## Ziel
v1.2.0 erweitert die sichere v1.1-Grundlage um adaptive Lernfunktionen. Diese
Funktionen laufen bei Feedback, Training oder Automation-Refresh und blockieren
nicht den direkten Schaltpfad.
## Adaptive Gewichtung
Feedback passt die Gewichtung aktuell beteiligter Kontextsignale vorsichtig an:
- korrektes Feedback: +3 Prozentpunkte bis maximal 100 %
- falsches Feedback: -8 Prozentpunkte bis minimal 10 %
Die Aenderungen werden als `adaptive_weight_updates` gespeichert und im
Dashboard angezeigt. Manuelle Gewichtungen bleiben weiter direkt korrigierbar.
## Modell-Snapshots und Rollback
Bei jedem Training wird ein Snapshot gespeichert:
- Version-ID
- Sample Count
- eindeutig zugeordnete Handlungen
- durchschnittliche Confidence
- negative Feedbacks
- Musterliste
- Begruendung
Ueber das Dashboard kann auf einen frueheren Snapshot zurueckgerollt werden.
## Automation-Konflikte
Beim Automation-Refresh markiert SillyHome Konflikte, wenn:
- SillyHome fuer einen Aktor aktiv ist
- eine passende Home-Assistant-Automation ebenfalls aktiv bleibt
Pausierte Automationen werden als kontrolliert markiert.
## Zeitprofile
SillyHome bildet Profile fuer:
- Nacht
- Morgen
- Tag
- Abend
- Wochenende
Diese Profile zeigen Sample Count, dominanten Zielzustand und Profilklarheit.
## Performance-Grenze
v1.2-Funktionen duerfen den Schaltmoment nicht verlangsamen. Der direkte
Schaltpfad bleibt:
1. vorhandene aktuelle States nutzen
2. lokale Safety-Pruefung
3. direkter Home-Assistant-Serviceaufruf
4. Persistenz der Entscheidung

View File

@@ -0,0 +1,68 @@
# SillyHome Next v1.3.0 Operating Guide
v1.3.0 ergänzt die v1.2-Lernfunktionen um Anomalie-Erkennung und
Performance-Überwachung. Das Dashboard bleibt Visualisierung und Einrichtung;
der direkte Schaltpfad bleibt kurz und führt vor dem Home-Assistant-Service-Call
keine Discovery, kein Training und keine Modellanalyse aus.
## Performance-Budget
- Dashboard-Start und `/v1/actuators/dashboard` haben ein Budget von 3000 ms.
- Das Dashboard zeigt die eigene Ladezeit, das aktive Budget, Job-p95 und die
Anzahl langsamer Jobs.
- Jobs ab 3000 ms werden in der Job-Queue als langsam markiert.
- Der automatisierte API-Test prüft den Root- und Dashboard-Startpfad gegen das
3-Sekunden-Budget.
## Anomalie-Erkennung
Anomalien werden pro Aktor gespeichert und im Aktor-Detail angezeigt. Erkannt
werden aktuell:
- fehlender Sensor-/Kontextbezug
- zu wenige Lernbeispiele
- unklare Quellen historischer Schaltungen
- veraltetes Training
- Vorhersagen unter der Sicherheitsgrenze
- aktive manuelle Sicherheitssperren
- Safety-Blocker
- parallele HA-Automationen bei aktivem SillyHome
- hohe negative Feedbackquote
Die Anomalien sind Hinweise für Setup und manuelles Gegensteuern. Sie lösen
keine automatische Eskalation und keine langsamere Schaltung aus.
## API
- `GET /v1/actuators/dashboard` liefert jetzt zusätzlich:
- `performance_budget_ms`
- `job_p95_duration_ms`
- `slow_job_count`
- `performance_status`
- `anomaly_count`
- `critical_anomaly_count`
- `GET /v1/actuators/anomalies` liefert offene Anomalien gruppiert nach Aktor.
## Betrieb
Bei Ladezeiten ab 3 Sekunden gilt die Seite als nicht performant. Dann zuerst
prüfen:
1. Dashboard-Statistik: Ladezeit, Job-p95, langsame Jobs.
2. Job-Queue: welche Aktion langsam war.
3. Aktor-Detail: Anomalien, Safety-Blocker und Automation-Konflikte.
4. Falls Discovery oder Training langsam war: nicht in den Startpfad ziehen,
sondern geplant, manuell oder über Queue laufen lassen.
## Qualität
Vor Release/Installation ausführen:
```bash
pytest -q
ruff check .
mypy app backend tests
git diff --check
```
Zusätzlich das eingebettete Dashboard-JavaScript mit `node --check` prüfen.

View File

@@ -0,0 +1,42 @@
# SillyHome Next v1.4.0 Operating Guide
v1.4.0 überarbeitet das Dashboard für mobile Nutzung, deutsche Verständlichkeit
und stabileren Datenabruf.
## Schneller Startpfad
- Die Startseite lädt zuerst nur die Bedienoberfläche und den kompakten
Dashboard-Startdatensatz.
- Neuer Start-Endpunkt: `GET /v1/actuators/dashboard/start`.
- Der Start-Endpunkt liefert keine Discovery-Gruppen und keine Aufgabenliste.
- Status, Aufgabenliste, Reconciliation-Zeitpunkt und Detail-Kontext werden
danach im Hintergrund geladen.
- Auf der Startansicht werden zunächst nur die ersten 24 Aktoren gerendert.
Weitere Geräte werden auf Knopfdruck nachgerendert.
## Deutsche Oberfläche
Interne Protokollwerte bleiben stabil, werden in der Oberfläche aber übersetzt:
- `observe` -> `Nur beobachten`
- `suggest` -> `Vorschläge anzeigen`
- `shadow` -> `Prüfmodus ohne Schalten`
- `partial` -> `Teilfreigabe`
- `active` -> `Aktiv freigegeben`
- Job-Status wie `running`, `completed`, `failed` erscheinen als `läuft`,
`abgeschlossen`, `fehlgeschlagen`.
- Anomalie-Schweregrade erscheinen als `Hinweis`, `Warnung`, `Kritisch`.
## Stabilität
- Startdaten und Statusdaten sind getrennt. Ein langsamer Statuscheck blockiert
nicht mehr die Geräteübersicht.
- Die Aufgabenliste wird separat geladen und kann ausfallen, ohne die
Bedienoberfläche zu blockieren.
- Detaildaten bleiben gestuft: zuerst Shell und gespeicherte Werte, danach
Kontextvorschläge.
## Performance-Regel
3 Sekunden bleiben die harte Grenze für den Startpfad. Alles, was schwerer ist
als Startdaten, muss nachgelagert oder auf Nutzeraktion geladen werden.

View File

@@ -0,0 +1,47 @@
# SillyHome Next v1.5.0 Operating Guide
v1.5.0 trennt Dashboard-Ansichten, Datenabruf und Detaildaten weiter auf. Ziel
ist, dass die Seite auf mobiler Datenverbindung schneller nutzbar wird und keine
schweren Lern-, Discovery- oder Detaildaten beim Start lädt.
## Menüstruktur
- Startseite / System: Systemübersicht, Cache, Performance, Status.
- Lernen: konfigurierte Aktoren und Lernstand.
- Details: genau ein ausgewählter Aktor.
- Discovery & Einrichtung: Geräteliste, Vorschläge und neue Aktoren.
- Einstellungen: Sprache und Standardverhalten.
- Ablauf: Bedienhinweise.
Beim Öffnen der Seite wird immer nur die Startseite geladen. Andere Ansichten
laden erst beim Öffnen.
## Kompakte Detaildaten
Neuer Endpunkt:
```text
GET /v1/actuators/{actuator_entity_id}/detail
```
Dieser Endpunkt entfernt große Musterlisten und Snapshot-Muster aus dem ersten
Detailabruf. Geladen werden nur die Werte, die für die erste Detailansicht
benötigt werden. Kontextvorschläge bleiben ein separater Abruf und laufen erst
auf Nutzeraktion.
## Sprache
Die Sprache kann unter `Einstellungen` gewählt werden. Deutsch ist Standard.
Technische API-Werte bleiben stabil, werden aber im Dashboard über die
Sprachschicht angezeigt.
## Performance-Regeln
- Kein Discovery beim Start.
- Keine Aufgabenliste beim Start.
- Keine Kontextvorschläge beim Öffnen eines Aktors.
- Keine Musterlisten im ersten Detailabruf.
- Geräteübersicht rendert begrenzt und lädt weitere Karten per Button nach.
Die Angabe „bereit in X ms“ beschreibt nur den jeweiligen API-/Ansichtsabruf.
Sie ist nicht gleichzusetzen mit der kompletten HA/Ingress-Navigationszeit.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.1 Operating Guide
v1.5.1 ist ein Stabilisierungshotfix für die nach v1.2.0 entstandenen
Dashboard-Änderungen. Fachlich gehört diese Arbeit zur v1.2.x-Patchlinie; die
höhere technische Versionsnummer ist nur nötig, weil Home Assistant bereits
v1.5.0 installiert hat und Add-on-Updates monoton nach oben laufen.
## Korrekturen
- Die System-Startseite nutzt `GET /v1/actuators/dashboard/system` und lädt
keine Aktorenliste.
- Sichtbare 3-Sekunden-Abbrüche mit Browsertexten wie `signal is aborted
without reason` wurden entfernt.
- Startdaten und Detaildaten werden ohne künstlichen Frontend-Abbruch geladen.
- Timeout-Meldungen werden deutsch und verständlich angezeigt, wenn sie bei
Nebenprüfungen auftreten.
- `summary`-Zeilen wie `anzeigenaufklappen` haben jetzt Abstand und Layout.
## Ladeverhalten
- Statische Seite wird sofort gerendert.
- Systemdaten laden im Hintergrund.
- Lernen/Geräte laden nur im Menü `Lernen`.
- Discovery lädt nur im Menü `Discovery & Einrichtung`.
- Aktorwerte laden erst beim Öffnen der Detailansicht.
- Kontextvorschläge laden erst auf Nutzeraktion.
## Hinweis zur Performance-Anzeige
Die App zeigt keine echte HA/Ingress-Navigationszeit an. Gemessen werden nur
einzelne interne Abrufe nach Start der Seite. Aussagen zur gesamten Ladezeit
müssen über Browser/Ingress oder HA-Messung geprüft werden.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.2 Operating Guide
v1.5.2 begrenzt den Rollback-Speicher und entschärft Home-Assistant-Timeouts,
die in den Add-on-Logs sichtbar wurden.
## Rollback-Speicher
- Pro Aktor bleiben maximal 3 Modell-Snapshots erhalten.
- Pro Snapshot bleiben maximal 120 Muster erhalten.
- Beim Speichern eines Aktors werden ältere oder zu große Snapshots automatisch
gekappt.
- Der kompakte Detail-Endpunkt liefert ebenfalls maximal 3 Rollback-Snapshots
und keine Musterlisten.
Damit bleibt Rollback nutzbar, ohne dass die JSON-Dateien mit alten Modellen
stark wachsen.
## Home-Assistant-Zugriffe
- REST-Zugriffe auf Home Assistant haben jetzt standardmäßig 25 Sekunden
Timeout statt 10 Sekunden.
- Der Wert ist über `SILLYHOME_HA_TIMEOUT_SECONDS` konfigurierbar.
- WebSocket-Keepalive wurde auf 30 Sekunden Ping-Intervall und 30 Sekunden
Ping-Timeout entschärft.
## Log-Einordnung
- `GET ... HTTP/1.1` ist bei Uvicorn/HA-Ingress normal und kein Fehler.
- `Zeitüberschreitung beim Zugriff auf Home Assistant` bedeutet, dass HA selbst
zu langsam geantwortet hat oder der Ingress/Netzpfad verzögert war.
- `keepalive ping timeout` bedeutet, dass die HA-WebSocket-Verbindung nicht
rechtzeitig geantwortet hat. SillyHome reconnectet automatisch.

View File

@@ -0,0 +1,37 @@
# SillyHome Next v1.5.3 Operating Guide
v1.5.3 führt eine SQLite-Cache-Schicht für Ingress-Dashboarddaten ein.
## Ziel
Die Ingress-Seite soll nicht bei jedem Aufruf live Home Assistant abfragen.
Home-Assistant-Daten werden geplant aktualisiert und lokal gelesen.
## SQLite-Cache
- Cache-Datei: `<actuator_store>/dashboard_cache.sqlite3`
- Tabelle `ha_entities`: aktuelle HA-Entity-Summaries als JSON
- Tabelle `cache_meta`: Aktualisierungszeitpunkt und Discovery-Gruppen
Dashboard-APIs lesen bevorzugt aus SQLite. Der alte JSON-Cache bleibt als
Fallback erhalten.
## Aktualisierung
- Beim App-Start läuft ein Hintergrund-Refresh nach kurzer Verzögerung.
- Danach läuft der Refresh stündlich.
- Konfiguration: `SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS`
- Mindestwert: 300 Sekunden.
- Explizite Discovery aktualisiert SQLite und JSON-Fallback.
## Schaltpfad
Das direkte Schalten bleibt unverändert: Safety prüft lokale Daten, danach geht
der Home-Assistant-Service-Call direkt raus. Der Dashboard-Cache liegt nicht im
Schaltpfad.
## Noch offen
Diese Version verschiebt Entity-/Discovery-Daten in SQLite. Die vollständige
Migration aller Aktor-Konfigurationen und Workflows aus JSON in relationale
Tabellen ist ein größerer Folgeschritt und muss mit Migrationsplan erfolgen.

View File

@@ -0,0 +1,35 @@
# SillyHome Next v1.5.4 Operating Guide
Diese Version korrigiert Ingress-Logging und Dashboard-Navigation.
## Ingress-/Access-Logs
- Das Add-on startet Uvicorn ohne `--proxy-headers` und ohne
`--forwarded-allow-ips='*'`.
- Vorher konnte Uvicorn LAN-Adressen aus `X-Forwarded-For` anzeigen. Diese
Adresse war dann der urspruengliche Client oder Home-Assistant-Proxy, nicht
der direkte Container-Peer.
- Nach dem Update sollten Access-Logs den direkten Docker-/Ingress-Peer zeigen.
`GET ... HTTP/1.1` bleibt normal und ist kein Hinweis auf fehlendes Streaming.
## Dashboard-Verhalten
- Die Startseite nutzt weiter `/v1/actuators/dashboard/system`.
- Die Lernuebersicht nutzt weiter `/v1/actuators/dashboard/start`.
- Bereits geladene System-, Lern- und Discovery-Daten bleiben beim Wechseln der
Ansichten im Browser erhalten und werden nur im Hintergrund aufgefrischt.
- Details sind kein eigener Menuepunkt mehr. Sie werden nur ueber ein
ausgewaehltes beobachtetes Geraet geoeffnet.
- Ein bereits geoeffneter Aktor zeigt seine Detaildaten sofort aus dem
Browser-Cache. Neue Detaildaten werden erst ueber `Details aktualisieren`
oder nach einer Speichern-/Schaltaktion geladen.
## Pruefung
1. Add-on aktualisieren und neu starten.
2. Ingress hart neu laden.
3. Zwischen Startseite, Lernen und Discovery wechseln.
4. Erwartung: Bereits geladene Inhalte bleiben sichtbar; keine volle
Neuladung bei jedem Ansichtswechsel.
5. Details eines Aktors oeffnen, wegwechseln und wieder Details oeffnen.
Erwartung: Die zuletzt geladene Detailansicht steht sofort wieder da.

View File

@@ -0,0 +1,36 @@
# SillyHome Next v1.6.0 Operating Guide
v1.6.0 trennt Startansicht, Aktoruebersicht, Discovery und Detaildaten staerker.
## API-Pfade
- `GET /v1/actuators/dashboard/system`
- nur System- und Cache-Metadaten
- keine Aktorenliste
- kein vollstaendiges Entity-Payload aus SQLite
- `GET /v1/actuators/dashboard/start`
- Aktor-Summaries
- Entity-Metadaten nur fuer konfigurierte Aktoren
- keine Discovery-Gruppen und keine Jobliste
- `GET /v1/actuators/discovery`
- steuerbare HA-Entities
- nutzt SQLite-Cache, liest HA nur bei Cache-Miss oder `refresh=true`
- `GET /v1/actuators/{id}/detail`
- genau ein ausgewaehlter Aktor
- kompakte Modell-/Kontextdaten
## Dashboard
- Frontend zeigt Daten an und loest gezielte Aktionen aus.
- Backend liefert schlanke View-Daten.
- Worker aktualisieren HA-Entity-/Discovery-Cache beim Start und danach
stündlich.
- Die Detailansicht gehoert zu einem Aktor und hat eigene Navigation:
Zurueck, anderes Geraet, Aktualisieren.
## Erwartete Wirkung
- Systemstart muss ohne Entity-Materialisierung reagieren.
- Lernen und Details laden nur ihren eigenen Datenkern.
- Discovery bleibt ein eigener Bedarfspfad.
- Texte im Dashboard sind kurz und handlungsnah.

View File

@@ -0,0 +1,45 @@
# SillyHome Next v1.7.0 Operating Guide
v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und
Planungshilfen.
## Diagnose
- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor.
- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`.
- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde
vorhergesagt, wurde geschaltet oder blockiert, und warum.
## Backup und Restore
- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status
und Job-Historie als JSON.
- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein.
- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben.
## Dry-run
- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus.
- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht
an Home Assistant gesendet.
## Feedback
Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen:
- `correct`
- `wrong`
- `too_early`
- `too_late`
- `never_automate`
`never_automate` setzt eine manuelle Sicherheitssperre am Aktor.
## Planung
`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise:
- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten
- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten
- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback

View File

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

View File

@@ -78,7 +78,7 @@ def _service(
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"),
history_days=14,
history_days=31,
min_training_points=5,
retrain_stale_hours=24,
reconcile_interval_seconds=900,
@@ -277,6 +277,81 @@ def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
tmp_path: Path,
) -> None:
entities = [
HaEntitySummary(
entity_id="light.treppe_unten",
domain="light",
friendly_name="Licht Treppe Unten",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.shelly_schrank_channel_1_overload",
domain="binary_sensor",
device_class="problem",
friendly_name="Shelly Schrank Channel 1 Überlast",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.terrasse_terasse_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Terrasse Terasse Überhitzung",
area_name="Strom",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.treppe_unten")
assert record.assignment.selected_context_entity_ids == []
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="fan.bad_lueftung",
domain="fan",
friendly_name="Bad Lüftung",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_luftfeuchtigkeit",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Bad Luftfeuchtigkeit",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Bad Leistung",
area_name="Bad",
),
]
service = _service(
tmp_path,
entities,
{
"sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0),
"sensor.bad_power": _points(8, start, 5.0),
},
)
record = service.configure_actuator("fan.bad_lueftung")
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
@@ -325,3 +400,39 @@ def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Pa
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
assert record.assignment.source is AssignmentSource.MANUAL
assert record.manual_override is not None
def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
domain="binary_sensor",
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
]
service = _service(tmp_path, entities, {})
service.configure_actuator("light.abstellkammer")
for _ in range(3):
service.set_manual_assignment(
"light.abstellkammer",
numeric_entity_id=None,
context_entity_ids=["binary_sensor.abstellkammer_motion"],
note="Manuell gesetzt",
)
record = service.get_actuator("light.abstellkammer")
candidate = next(
item
for item in record.context_candidates
if item.entity_id == "binary_sensor.abstellkammer_motion"
)
assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1

View File

@@ -1,11 +1,17 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from time import perf_counter
from zoneinfo import ZoneInfo
import pytest
from fastapi.testclient import TestClient
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings
@@ -27,8 +33,11 @@ class FakeHaReader(HaReader):
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
self._entities = entities
self._history = history
self.read_entities_calls = 0
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
def read_entities(self) -> list[HaEntitySummary]:
self.read_entities_calls += 1
return list(self._entities)
def discover(
@@ -82,6 +91,7 @@ class FakeHaReader(HaReader):
service: str,
service_data: dict[str, object],
) -> list[object]:
self.service_calls.append((domain, service, service_data))
return []
def find_automations_for_entity(
@@ -107,6 +117,7 @@ def _install_service(tmp_path: Path) -> None:
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
state="12",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
@@ -114,6 +125,7 @@ def _install_service(tmp_path: Path) -> None:
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
state="off",
),
HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes",
@@ -137,6 +149,7 @@ def _install_service(tmp_path: Path) -> None:
)
app.state.registry = ModelRegistry(tmp_path / "models")
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
app.state.dashboard_cache = DashboardCache(tmp_path / "actuators" / "dashboard_cache.sqlite3")
app.state.ha_reader = FakeHaReader(
entities,
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
@@ -212,6 +225,432 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
]
def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
},
)
response = client.post(
"/v1/actuators/light.abstellkammer/weights",
json={
"sensor_weights": {
"sensor.abstellkammer_illuminance": 0.75,
"binary_sensor.abstellkammer_motion": 0.5,
},
"sensor_weight_groups": [
{
"group_id": "abstellkammer_context",
"name": "Abstellkammer Kontext",
"entity_ids": [
"sensor.abstellkammer_illuminance",
"binary_sensor.abstellkammer_motion",
],
"weight": 0.8,
}
],
"note": "Gewichtung korrigiert",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["manual_override"]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.75
assert payload["manual_override"]["sensor_weight_groups"][0]["group_id"] == (
"abstellkammer_context"
)
numeric = {
candidate["entity_id"]: candidate
for candidate in payload["numeric_candidates"]
}
assert numeric["sensor.abstellkammer_illuminance"]["manual_weight"] == 0.75
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
def test_actuator_simulation_ranks_sensor_states_without_switching(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
},
)
store = app.state.actuator_store
record = store.get("light.abstellkammer")
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Europe/Berlin"))
local_minute = local.hour * 60 + local.minute
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "on",
},
source="user",
weight=1.0,
observed_at=now,
)
for _ in range(3)
]
patterns.extend(
[
BehaviorPattern(
target_state="off",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "off",
},
source="user",
weight=0.5,
observed_at=now,
)
for _ in range(3)
]
)
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"patterns": patterns,
"sample_count": len(patterns),
"high_confidence_sample_count": len(patterns),
"activation_ready": True,
"activation_reason": "Testfreigabe.",
}
)
}
)
)
response = client.post(
"/v1/actuators/light.abstellkammer/simulate",
json={
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
"sensor_weights": {
"binary_sensor.abstellkammer_motion": 1.0,
"sensor.abstellkammer_illuminance": 0.25,
},
"max_results": 2,
},
)
assert response.status_code == 200
payload = response.json()
assert len(payload) == 2
assert payload[0]["prediction"]["target_state"] == "on"
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
assert app.state.ha_reader.service_calls == []
def test_safety_profile_can_block_actuator_manually(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/safety",
json={
"safety": {
"stage": "shadow",
"manual_block": True,
"min_confidence": 0.9,
"cooldown_seconds": 120,
"rules": [
{
"rule_id": "manual_block",
"label": "Manuelle Sperre respektieren",
"enabled": True,
"blocking": True,
"reason": "Test",
}
],
"note": "Test",
}
},
)
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["safety"]["min_confidence"] == 0.9
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
record = app.state.actuator_store.get("light.abstellkammer")
version_id = "model-test"
snapshot = ModelSnapshot(
version_id=version_id,
sample_count=1,
high_confidence_sample_count=1,
average_confidence=0.9,
patterns=[],
reason="Test-Snapshot",
)
app.state.actuator_store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"model_snapshots": [snapshot],
"active_model_version": "model-current",
"sample_count": 2,
}
)
}
)
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "expected_state": "off"},
)
rollback = client.post(
"/v1/actuators/light.abstellkammer/model/rollback",
json={"version_id": version_id},
)
assert feedback.status_code == 200
feedback_payload = feedback.json()
assert feedback_payload["behavior"]["adaptive_weight_updates"]
assert feedback_payload["manual_override"]["sensor_weights"]
assert rollback.status_code == 200
assert rollback.json()["behavior"]["active_model_version"] == version_id
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "kind": "never_automate"},
)
assert feedback.status_code == 200
payload = feedback.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
backup = client.get("/v1/actuators/backup/export")
dry_run = client.post(
"/v1/actuators/light.abstellkammer/dry-run",
json={"enabled": True},
)
planning = client.post("/v1/actuators/planning/refresh")
restore = client.post(
"/v1/actuators/backup/restore",
json={"backup": backup.json(), "replace_existing": True},
)
assert backup.status_code == 200
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
assert dry_run.status_code == 200
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
assert planning.status_code == 200
assert "agent_insights" in planning.json()[0]["behavior"]
assert restore.status_code == 200
assert restore.json()["restored_records"] == 1
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/summary")
assert response.status_code == 200
payload = response.json()
assert payload[0]["actuator_entity_id"] == "light.abstellkammer"
assert payload[0]["friendly_name"] == "Abstellkammer Licht"
assert payload[0]["area_name"] == "Abstellkammer"
assert "behavior" not in payload[0]
assert "numeric_candidates" not in payload[0]
def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
reader = app.state.ha_reader
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
calls_before = reader.read_entities_calls
response = client.get("/v1/actuators/dashboard")
assert response.status_code == 200
assert reader.read_entities_calls == calls_before
payload = response.json()
assert payload["cache"]["available"] is True
assert payload["cache"]["entity_count"] == 4
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
assert payload["discovery_groups"]
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
def test_reconciliation_run_records_visible_job_queue(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/reconciliation/run")
jobs = client.get("/v1/actuators/job-queue/state")
assert response.status_code == 200
assert jobs.status_code == 200
payload = jobs.json()
assert [job["kind"] for job in payload["jobs"][-3:]] == [
"reconciliation",
"training",
"evaluation",
]
assert payload["jobs"][-1]["status"] == "completed"
def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
root_started_at = perf_counter()
root_response = client.get("/")
root_elapsed = perf_counter() - root_started_at
dashboard_started_at = perf_counter()
dashboard_response = client.get("/v1/actuators/dashboard/start")
dashboard_elapsed = perf_counter() - dashboard_started_at
assert root_response.status_code == 200
assert dashboard_response.status_code == 200
assert root_elapsed < 3.0
assert dashboard_elapsed < 3.0
def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
store = app.state.actuator_store
job = store.start_job(kind="training", trigger="test", summary="Langsamer Testjob")
queue = store.load_job_queue()
queue.jobs = [
item.model_copy(update={"started_at": datetime.now(timezone.utc) - timedelta(seconds=4)})
if item.job_id == job.job_id
else item
for item in queue.jobs
]
store._persist_job_queue(queue)
store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig")
dashboard_response = client.get("/v1/actuators/dashboard")
start_response = client.get("/v1/actuators/dashboard/start")
system_response = client.get("/v1/actuators/dashboard/system")
anomalies_response = client.get("/v1/actuators/anomalies")
assert dashboard_response.status_code == 200
assert start_response.status_code == 200
assert system_response.status_code == 200
system = dashboard_response.json()["system"]
start_payload = start_response.json()
assert start_payload["jobs"]["jobs"] == []
assert start_payload["discovery_groups"] == []
assert system_response.json()["actuators"] == []
assert system["performance_budget_ms"] == 3000
assert system["slow_job_count"] == 1
assert system["performance_status"] == "slow"
assert system["anomaly_count"] >= 1
assert anomalies_response.status_code == 200
assert anomalies_response.json()
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
raise AssertionError("full entity payload must not be loaded")
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
system_response = client.get("/v1/actuators/dashboard/system")
start_response = client.get("/v1/actuators/dashboard/start")
assert system_response.status_code == 200
assert system_response.json()["actuators"] == []
assert system_response.json()["cache"]["entity_count"] == 4
assert start_response.status_code == 200
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/light.abstellkammer/detail")
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["patterns"] == []
assert all(
snapshot["patterns"] == []
for snapshot in payload["behavior"]["model_snapshots"]
)
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
reader = app.state.ha_reader
response = client.get("/v1/actuators/discovery", params={"refresh": True})
assert response.status_code == 200
assert reader.read_entities_calls == 1
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
@@ -227,3 +666,37 @@ def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
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
def test_actuator_discovery_prefers_light_over_duplicate_switch() -> None:
entities = {
"light.schreibtisch": HaEntitySummary(
entity_id="light.schreibtisch",
domain="light",
friendly_name="Schreibtisch Licht",
device_id="device-1",
),
"switch.schreibtisch": HaEntitySummary(
entity_id="switch.schreibtisch",
domain="switch",
friendly_name="Schreibtisch Schalter",
device_id="device-1",
),
"cover.rollladen": HaEntitySummary(
entity_id="cover.rollladen",
domain="cover",
friendly_name="Rollladen",
device_id="device-2",
),
}
result = _deduplicate_actuator_ids(
[
("switch.schreibtisch", "switch_socket"),
("light.schreibtisch", "light"),
("cover.rollladen", "cover_shutter"),
],
entities,
)
assert result == ["cover.rollladen", "light.schreibtisch"]

View File

@@ -27,6 +27,7 @@ class FakeHaReader(HaReader):
entity_id="sensor.temperature",
domain="sensor",
device_class="temperature",
category="temperature",
role=EntityRole.MEASUREMENT,
learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.",
@@ -116,6 +117,7 @@ def test_discovery_filters_entities() -> None:
"device_class": "temperature",
"state_class": None,
"unit_of_measurement": None,
"category": "temperature",
"role": "measurement",
"learnable": True,
"reason": "Numerischer Messsensor für Zeitreihen und Training.",

View File

@@ -8,6 +8,7 @@ import pytest
from app.actuators.models import (
BehaviorMode,
BehaviorPattern,
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
ExecutionEvent,
@@ -212,6 +213,125 @@ def test_engine_counts_known_automation_actions_like_manual_actions(
assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0}
def test_feedback_marks_prediction_correct_as_learning_pattern(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
)
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback("light.office", correct=True)
assert result.behavior.patterns[-1].target_state == "on"
assert result.behavior.patterns[-1].context_states == {
"binary_sensor.office_presence": "on"
}
assert result.behavior.patterns[-1].source == "user_feedback"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als korrekt bestätigt."
def test_feedback_marks_prediction_wrong_and_adds_correction(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.office_presence": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=1),
)
],
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
),
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback(
"light.office",
correct=False,
expected_state="off",
)
assert result.behavior.patterns[0].weight == 0.1
assert result.behavior.patterns[-1].target_state == "off"
assert result.behavior.patterns[-1].source == "user_correction"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als falsch markiert."
def test_engine_learns_causal_automation_with_activation_credit(
tmp_path: Path,
) -> None:
@@ -438,6 +558,7 @@ def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None:
("domain", "state", "service"),
[
("light", "on", "turn_on"),
("media_player", "off", "turn_off"),
("switch", "off", "turn_off"),
("cover", "open", "open_cover"),
("cover", "closed", "close_cover"),
@@ -523,3 +644,263 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
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"})
]
def test_event_evaluation_records_decision_timeline_and_latency(
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="on",
last_changed=now,
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.evaluate(
"light.storage",
trigger_entity_id="binary_sensor.storage_door",
trigger_state="on",
event_received_at=now,
)
trace = result.behavior.decision_timeline[-1]
latency = result.behavior.latency_measurements[-1]
assert trace.trigger_entity_id == "binary_sensor.storage_door"
assert trace.target_state == "on"
assert trace.executed is True
assert latency.trigger_entity_id == "binary_sensor.storage_door"
assert latency.executed is True
def test_dry_run_records_without_calling_service(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,
"dry_run_enabled": 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="on",
last_changed=now,
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.evaluate("light.storage")
assert reader.service_calls == []
assert result.behavior.dry_run_sample_count == 1
assert result.behavior.decision_timeline[-1].executed is False

View File

@@ -74,3 +74,60 @@ def test_discovery_filters_domain_and_learnable() -> None:
result = discover_entities(entities, domains={" SENSOR "}, learnable=True)
assert [item.entity_id for item in result] == ["sensor.temperature"]
@pytest.mark.parametrize(
("entity", "category"),
[
(
HaEntitySummary(entity_id="climate.bad", domain="climate"),
"heating",
),
(
HaEntitySummary(entity_id="lock.front_door", domain="lock"),
"lock",
),
(
HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"),
"helper",
),
(
HaEntitySummary(entity_id="media_player.tv", domain="media_player"),
"media_tv",
),
(
HaEntitySummary(
entity_id="sensor.brightness",
domain="sensor",
device_class="illuminance",
),
"brightness",
),
(
HaEntitySummary(
entity_id="binary_sensor.motion",
domain="binary_sensor",
device_class="motion",
),
"presence_motion",
),
],
)
def test_classify_entity_categories(entity: HaEntitySummary, category: str) -> None:
assert classify_entity(entity).category == category
@pytest.mark.parametrize(
"entity",
[
HaEntitySummary(entity_id="automation.lights", domain="automation"),
HaEntitySummary(entity_id="update.core", domain="update"),
],
)
def test_classify_excludes_non_actuator_management_entities(
entity: HaEntitySummary,
) -> None:
result = classify_entity(entity)
assert result.role is EntityRole.UNSUPPORTED
assert result.learnable is False

View File

@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
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
def test_addon_does_not_trust_forwarded_lan_ips() -> None:
run_script = Path("addon/run.sh").read_text(encoding="utf-8")
assert "--proxy-headers" not in run_script
assert "--forwarded-allow-ips" not in run_script

View File

@@ -9,28 +9,43 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200
assert "SillyHome Next" in response.text
assert "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Oder aus Liste wählen" in response.text
assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
assert "So gehst du vor" not in response.text
assert "Discovery & Einrichtung" in response.text
assert "Entity-ID" in response.text
assert "Geräteliste" 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 "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" not in response.text
assert '<option value="detail">Details</option>' not in response.text
assert "Wie gewohnt bedienen" not in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" not 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 "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 "Zurück zur Übersicht" in response.text
assert "Anderes Gerät" 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 "Die Prüfung simuliert keinen Sensorwechsel" not 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 "record.behavior.status ===" not in response.text
assert "record.behavior_status || record.behavior?.status" in response.text
assert 'api("v1/actuators")' not in response.text
assert 'api("v1/actuators/summary")' in response.text
assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' not in response.text
assert 'class="group-panel"' in response.text
assert "cachedDetailHtml" in response.text
assert "refreshOverviewInBackground" in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text

View File

@@ -1,4 +1,5 @@
import asyncio
from collections.abc import Sequence
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -8,6 +9,8 @@ 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
@@ -41,10 +44,32 @@ class _RecordingBehaviorEngine(BehaviorEngine):
store=ActuatorStore(tmp_path / "actuators"),
settings=MagicMock(),
)
self.state_changes: list[tuple[str, dict[str, object] | None]] = []
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) -> None:
self.state_changes.append((entity_id, new_state))
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:
@@ -55,18 +80,22 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"entity_id":"light.test","new_state":{"state":"on"}}}'
'"data":{"entity_id":"light.test","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
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"},
@@ -79,16 +108,61 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
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_store.configure("light.test")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()
anyio.run(run_test)
assert mock_engine.state_changes == [("light.test", {"state": "on"})]
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_ha_event_listener_skips_unrelated_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":"sensor.unused","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
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_store.configure("light.test")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()
anyio.run(run_test)
assert mock_engine.state_changes == []
def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI()
app.state.settings = MagicMock()