Compare commits

...

27 Commits

Author SHA1 Message Date
9ff005f089 Add room management settings
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:44:56 +02:00
954c4511a7 Fix complete dashboard i18n refresh
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:25:13 +02:00
33cce32098 Release SillyHome Next 1.7.4
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:59:21 +02:00
08e41b0198 Improve learning discovery and dashboard i18n
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:57:57 +02:00
1b9db62294 Add simulation apply workflow
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 20:10:53 +02:00
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
38 changed files with 7047 additions and 291 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,178 @@
# Changelog # Changelog
## 1.7.6 - 2026-07-26
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
einer gemeinsamen Ansicht.
- Neue API `/v1/actuators/settings/rooms` liefert kompakte Verwaltungsdaten
für Raumkarten, Sensorvorschläge, Aktoren und noch nicht verwaltete
Vorschläge.
- Licht-/Schalter-Zuordnung darf bei eindeutigem Tür-/Öffnungskontext ohne
numerischen Helligkeitssensor arbeiten, z. B. Tür auf -> Licht an und Tür zu
-> Licht aus.
## 1.7.5 - 2026-07-26
- Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte
Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte.
- Aufklapp-Hinweise (`expand`/`collapse`) kommen nicht mehr fest aus CSS auf
Deutsch, sondern werden pro Sprache gesetzt.
- Detail-Cache wird beim Sprachwechsel geleert, damit keine alten deutschen
HTML-Fragmente in der englischen Oberfläche sichtbar bleiben.
## 1.7.4 - 2026-07-26
- Dashboard-Sprachumschaltung aktualisiert statische Texte, Labels,
Platzhalter und wichtige Laufzeittexte direkt beim Wechsel.
- Verhaltenslernen speichert Lichtattribute wie Helligkeit und Farbwerte aus
der Home-Assistant-Historie und gibt sie bei Lichtvorhersagen an den
`light.turn_on` Service weiter.
- Kontext-Discovery erkennt Umlaute/Raumvarianten robuster, ignoriert
Markenwörter wie `lidl` als falsche Gemeinsamkeit und bevorzugt Raum-
Präsenzsensoren für Lidl-/Treppenlichter.
- Lüftungen bevorzugen Luftfeuchte und Belegungs-/Präsenzkontext; gelernte
Sensorwechsel dürfen jetzt eine Verzögerung haben, z. B. WC besetzt -> nach
2-3 Minuten Lüftung an.
- Briefkasten-Reset-Buttons können Schrank-/Entnahme-Türen als Kontext
erkennen; Button-Aktoren können im aktiven Modus per `press` ausgeführt
werden.
- PV-/Akku-/Verbrauchssensoren werden als Energiemanagement-Kontext stärker
einsortiert.
## 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 ## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift - Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu. nicht mehr auf `record.behavior.status` aus dem Vollformat zu.

View File

@@ -11,6 +11,28 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md) [`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md)
- Entwickeln, testen, veröffentlichen und installieren: - Entwickeln, testen, veröffentlichen und installieren:
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) [`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) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## 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/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen - `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow - `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
- `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` - 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}/evaluate` - Shadow-Vorhersage aktualisieren
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen - `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen

View File

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

View File

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

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, ManualOverride,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
SensorWeightGroup,
model_id_for_actuator, model_id_for_actuator,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.config import Settings 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.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -45,6 +46,8 @@ _STOPWORDS = frozenset(
"entity", "entity",
"humidity", "humidity",
"illuminance", "illuminance",
"led",
"lidl",
"light", "light",
"licht", "licht",
"lichtschalter", "lichtschalter",
@@ -137,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
"presence", "presence",
"window", "window",
}) })
_PRESENCE_TOKENS = frozenset({
"besetzt",
"occupied",
"occupancy",
"presence",
"prasenz",
"praesenz",
"motion",
"bewegung",
"bewegungsmelder",
})
_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"})
_CABINET_TOKENS = frozenset({"schrank", "cabinet"})
_PV_TOKENS = frozenset({
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
"netzbezug",
"grid",
"verbrauch",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -170,7 +200,7 @@ class ActuatorReconciliationService:
limit: int = _CONTEXT_SUGGESTION_LIMIT, limit: int = _CONTEXT_SUGGESTION_LIMIT,
) -> list[HaEntitySummary]: ) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} 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) actuator = entities.get(actuator_entity_id)
if actuator is None: if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.") raise KeyError("Aktuator-Konfiguration nicht gefunden.")
@@ -239,6 +269,10 @@ class ActuatorReconciliationService:
override = ManualOverride( override = ManualOverride(
numeric_entity_id=numeric_entity_id, numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids, 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, updated_at=now,
note=note, note=note,
) )
@@ -253,17 +287,23 @@ class ActuatorReconciliationService:
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": override, "manual_override": override,
"numeric_candidates": _merge_manual_candidates( "numeric_candidates": _apply_weight_overrides(
record.numeric_candidates, _merge_manual_candidates(
entities, record.numeric_candidates,
[numeric_entity_id] if numeric_entity_id else [], entities,
role=EntityRole.MEASUREMENT, [numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
override,
), ),
"context_candidates": _merge_manual_candidates( "context_candidates": _apply_weight_overrides(
record.context_candidates, _merge_manual_candidates(
entities, record.context_candidates,
selected_context_ids, entities,
role=EntityRole.CONTEXT, selected_context_ids,
role=EntityRole.CONTEXT,
),
override,
), ),
"lifecycle": lifecycle, "lifecycle": lifecycle,
"updated_at": now, "updated_at": now,
@@ -271,6 +311,65 @@ class ActuatorReconciliationService:
) )
return self._store.upsert(updated) 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: def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy( state = self._store.load_reconciliation_state().model_copy(
update={ update={
@@ -376,6 +475,9 @@ class ActuatorReconciliationService:
), ),
context=True, 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 = ( assignment = (
self._manual_assignment(record.manual_override) self._manual_assignment(record.manual_override)
if record.manual_override is not None if record.manual_override is not None
@@ -444,6 +546,25 @@ class ActuatorReconciliationService:
if candidate.auto_accepted if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS] ][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts] top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if (
top_numeric is not None
and actuator.domain in {"light", "switch"}
and any(
(candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in accepted_contexts
)
):
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Tür-/Öffnungskontext automatisch erkannt. Für diese "
"direkte Schaltlogik ist kein Helligkeitssensor erforderlich."
),
)
if top_numeric is None: if top_numeric is None:
if accepted_contexts: if accepted_contexts:
return AssignmentSelection( return AssignmentSelection(
@@ -736,6 +857,11 @@ def _manual_context_role(
def _context_sort_group(entity: HaEntitySummary) -> str: def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or "" 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"}: if device_class in {"motion", "occupancy", "presence"}:
return "01_presence" return "01_presence"
if device_class in {"illuminance"}: if device_class in {"illuminance"}:
@@ -744,10 +870,26 @@ def _context_sort_group(entity: HaEntitySummary) -> str:
return "03_opening" return "03_opening"
if device_class in {"humidity", "moisture"}: if device_class in {"humidity", "moisture"}:
return "04_humidity" 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"}: 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"}: 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}" return f"20_{entity.domain}_{device_class}"
@@ -770,6 +912,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
return True return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)): if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity):
return True
if actuator.domain in {"fan", "humidifier"} and (
_is_presence_context(entity) or entity.device_class in {"humidity", "moisture"}
):
return True
if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and (
entity_tokens.intersection(_PV_TOKENS)
):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True) entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool( return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS) entity_tokens.intersection(_OUTDOOR_TOKENS)
@@ -784,6 +938,18 @@ def _eligible_for_auto_context(
device_class = candidate.device_class or "" device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES: if device_class in _AUTO_CONTEXT_CLASSES:
return True return True
if actuator.domain in {"fan", "humidifier"} and device_class in {
"humidity",
"moisture",
"temperature",
}:
return True
if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate):
return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
candidate_tokens = _candidate_tokens(candidate, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate):
return True
if ( if (
actuator.device_name actuator.device_name
and candidate.device_name and candidate.device_name
@@ -805,6 +971,7 @@ def _score_candidate(
score = 0.0 score = 0.0
actuator_tokens = _metadata_tokens(actuator) actuator_tokens = _metadata_tokens(actuator)
entity_tokens = _metadata_tokens(entity) entity_tokens = _metadata_tokens(entity)
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
overlap = sorted(actuator_tokens.intersection(entity_tokens)) overlap = sorted(actuator_tokens.intersection(entity_tokens))
if overlap: if overlap:
score += min(0.4, 0.1 * len(overlap)) score += min(0.4, 0.1 * len(overlap))
@@ -830,6 +997,31 @@ def _score_candidate(
if entity.device_class in preferred_device_classes: if entity.device_class in preferred_device_classes:
score += 0.2 score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}") evidence.append(f"Passende device_class: {entity.device_class}")
if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.")
if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.")
if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity):
score += 0.3
evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.")
if context and _is_mailbox_reset_candidate(
_metadata_tokens(actuator, include_stopwords=True),
_metadata_tokens(entity, include_stopwords=True),
entity,
):
score += 0.45
evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.")
if full_entity_tokens.intersection(_PV_TOKENS):
score += 0.12 if context else 0.18
evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.")
if not context and actuator.domain == "light" and entity.device_class == "illuminance": if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2 score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.") evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
@@ -897,14 +1089,50 @@ def _merge_manual_candidates(
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id)) 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]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
mapping = { mapping = {
"climate": {"occupancy", "presence", "window"}, "climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"},
"cover": {"illuminance", "wind_speed"}, "cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"}, "fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"}, "humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, "light": {"door", "garage_door", "illuminance", "motion", "occupancy", "opening", "presence", "window"},
"media_player": {"occupancy", "presence"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, "switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
} }
return frozenset( return frozenset(
@@ -938,11 +1166,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False
for value in raw_values: for value in raw_values:
if value is None: if value is None:
continue continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS): if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue continue
tokens.add(token) tokens.add(token)
return tokens return _expand_room_tokens(tokens)
def _candidate_tokens(
candidate: AssignmentCandidate,
*,
include_stopwords: bool = False,
) -> set[str]:
raw_values = [
candidate.entity_id,
candidate.friendly_name,
candidate.area_name,
candidate.device_name,
]
tokens: set[str] = set()
for value in raw_values:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return _expand_room_tokens(tokens)
def _expand_room_tokens(tokens: set[str]) -> set[str]:
expanded = set(tokens)
if "gaste" in expanded:
expanded.add("gaeste")
if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded):
expanded.add("gaestewc")
if {"gaeste", "zimmer"}.issubset(expanded):
expanded.add("gaestezimmer")
return expanded
def _normalize_text(value: str) -> str:
return (
value.lower()
.replace("_", " ")
.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
)
def _is_presence_context(entity: HaEntitySummary) -> bool:
if entity.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_presence_candidate(candidate: AssignmentCandidate) -> bool:
if candidate.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_mailbox_reset_candidate(
actuator_tokens: set[str],
context_tokens: set[str],
entity: HaEntitySummary | AssignmentCandidate,
) -> bool:
if not actuator_tokens.intersection(_MAILBOX_TOKENS):
return False
if not context_tokens.intersection(_CABINET_TOKENS):
return False
return entity.domain == "binary_sensor" and entity.device_class in {
"door",
"garage_door",
"opening",
}
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str: def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:

View File

@@ -37,6 +37,29 @@ class BehaviorStatus(StrEnum):
BLOCKED = "blocked" 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): class AssignmentCandidate(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -49,6 +72,8 @@ class AssignmentCandidate(BaseModel):
device_name: str | None = None device_name: str | None = None
score: float = Field(ge=0.0) score: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.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 auto_accepted: bool = False
evidence: list[str] = Field(default_factory=list) evidence: list[str] = Field(default_factory=list)
@@ -62,9 +87,18 @@ class AssignmentSelection(BaseModel):
reason: str = "Noch keine Zuordnung vorhanden." 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): class ManualOverride(BaseModel):
numeric_entity_id: str | None = None numeric_entity_id: str | None = None
context_entity_ids: list[str] = Field(default_factory=list) 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)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
note: str | None = None note: str | None = None
@@ -89,12 +123,14 @@ class ModelLifecycleState(BaseModel):
class BehaviorPattern(BaseModel): class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100) target_state: str = Field(min_length=1, max_length=100)
target_attributes: dict[str, object] = Field(default_factory=dict)
minute_of_day: int = Field(ge=0, le=1439) minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6) weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict) context_states: dict[str, str] = Field(default_factory=dict)
trigger_entity_id: str | None = None trigger_entity_id: str | None = None
trigger_from_state: str | None = None trigger_from_state: str | None = None
trigger_to_state: str | None = None trigger_to_state: str | None = None
trigger_delay_seconds: int | None = Field(default=None, ge=0)
source: str = Field(default="observed", max_length=40) source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0) weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime observed_at: datetime
@@ -102,6 +138,7 @@ class BehaviorPattern(BaseModel):
class BehaviorPrediction(BaseModel): class BehaviorPrediction(BaseModel):
target_state: str target_state: str
target_attributes: dict[str, object] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0) confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime generated_at: datetime
reason: str reason: str
@@ -110,11 +147,148 @@ class BehaviorPrediction(BaseModel):
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt." 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): class ExecutionEvent(BaseModel):
target_state: str target_state: str
executed_at: datetime 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): class RelatedAutomation(BaseModel):
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
config_id: str = Field(min_length=1, max_length=120) config_id: str = Field(min_length=1, max_length=120)
@@ -122,6 +296,32 @@ class RelatedAutomation(BaseModel):
enabled: bool 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): class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING status: BehaviorStatus = BehaviorStatus.COLLECTING
@@ -139,6 +339,32 @@ class BehaviorState(BaseModel):
related_automations: list[RelatedAutomation] = Field(default_factory=list) related_automations: list[RelatedAutomation] = Field(default_factory=list)
paused_automation_entity_ids: list[str] = Field(default_factory=list) paused_automation_entity_ids: list[str] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert." reason: str = "Historische Aktorhandlungen werden analysiert."
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): class ActuatorRecord(BaseModel):
@@ -165,5 +391,22 @@ class ReconciliationState(BaseModel):
last_summary: str = "Noch keine Reconciliation ausgeführt." 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: def model_id_for_actuator(actuator_entity_id: str) -> str:
return f"actuator.{actuator_entity_id}" return f"actuator.{actuator_entity_id}"

View File

@@ -8,6 +8,9 @@ from threading import RLock
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
JobQueueItem,
JobQueueState,
JobStatus,
LifecycleStatus, LifecycleStatus,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
@@ -22,6 +25,7 @@ class ActuatorStore:
self._actuators_root.mkdir(parents=True, exist_ok=True) self._actuators_root.mkdir(parents=True, exist_ok=True)
self._lock = RLock() self._lock = RLock()
self._reconciliation_state_path = self._root / "reconciliation_state.json" self._reconciliation_state_path = self._root / "reconciliation_state.json"
self._job_queue_path = self._root / "job_queue.json"
def list(self) -> list[ActuatorRecord]: def list(self) -> list[ActuatorRecord]:
with self._lock: with self._lock:
@@ -85,6 +89,80 @@ class ActuatorStore:
self._persist_reconciliation_state(state) self._persist_reconciliation_state(state)
return 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: def _target(self, actuator_entity_id: str) -> Path:
if "." not in actuator_entity_id: if "." not in actuator_entity_id:
raise ValueError("Ungültige actuator_entity_id.") raise ValueError("Ungültige actuator_entity_id.")
@@ -108,6 +186,14 @@ class ActuatorStore:
) )
os.replace(temporary, self._reconciliation_state_path) 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 @staticmethod
def _load(path: Path) -> ActuatorRecord: def _load(path: Path) -> ActuatorRecord:
try: 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 prediction_interval_seconds: int = 60
execution_cooldown_seconds: int = 900 execution_cooldown_seconds: int = 900
timezone: str = "Europe/Berlin" timezone: str = "Europe/Berlin"
ha_timeout_seconds: int = 25
dashboard_cache_refresh_seconds: int = 3600
@property @property
def ha_configured(self) -> bool: def ha_configured(self) -> bool:
@@ -55,4 +57,8 @@ def load_settings() -> Settings:
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900")) 60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
), ),
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"), 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

@@ -78,7 +78,6 @@ class HaClient:
"filter_entity_id": ",".join(entity_ids), "filter_entity_id": ",".join(entity_ids),
"end_time": end_time.isoformat(), "end_time": end_time.isoformat(),
"minimal_response": "1", "minimal_response": "1",
"no_attributes": "1",
}, },
) )
if not isinstance(payload, list): if not isinstance(payload, list):

View File

@@ -214,9 +214,12 @@ def _result(
def _actuator_category(entity: HaEntitySummary) -> str: def _actuator_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain == "light": if entity.domain == "light":
return "light" return "light"
if entity.domain == "switch": if entity.domain == "switch":
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket"
return "switch_socket" return "switch_socket"
if entity.domain == "button" or entity.domain == "input_button": if entity.domain == "button" or entity.domain == "input_button":
return "button" return "button"
@@ -239,13 +242,31 @@ def _actuator_category(entity: HaEntitySummary) -> str:
def _measurement_category(entity: HaEntitySummary) -> str: def _measurement_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or "" 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": if device_class == "illuminance":
return "brightness" return "brightness"
if device_class == "temperature": if device_class == "temperature":
return "temperature" return "temperature"
if device_class in {"humidity", "moisture"}: if device_class in {"humidity", "moisture"}:
return "humidity" return "humidity"
if device_class in {"power", "energy", "current", "voltage"}: if device_class in {"power", "energy", "current", "voltage", "apparent_power"}:
return "energy_power" return "energy_power"
if device_class in {"battery", "signal_strength"}: if device_class in {"battery", "signal_strength"}:
return "diagnostic" return "diagnostic"
@@ -260,12 +281,42 @@ def _binary_category(entity: HaEntitySummary) -> str:
return "opening" return "opening"
if device_class in {"smoke", "safety", "problem"}: if device_class in {"smoke", "safety", "problem"}:
return "safety" return "safety"
if device_class in {"lock"}:
return "lock_state"
return "binary" return "binary"
def _context_category(entity: HaEntitySummary) -> str: def _context_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain.startswith("input_"): if entity.domain.startswith("input_"):
return "helper" return "helper"
if entity.domain in {"person", "device_tracker", "zone"}: if entity.domain in {"person", "device_tracker", "zone"}:
return "presence_location" 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 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

@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
class StateHistoryPoint(BaseModel): class StateHistoryPoint(BaseModel):
timestamp: datetime timestamp: datetime
state: str state: str
attributes: dict[str, object] = {}
class StateHistorySeries(BaseModel): class StateHistorySeries(BaseModel):
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
timestamp = _parse_timestamp( timestamp = _parse_timestamp(
raw_entry.get("last_changed") or raw_entry.get("last_updated") raw_entry.get("last_changed") or raw_entry.get("last_updated")
) )
if not points or points[-1].state != raw_state: attributes = raw_entry.get("attributes")
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state)) if not isinstance(attributes, dict):
attributes = {}
if (
not points
or points[-1].state != raw_state
or _relevant_state_attributes(points[-1].attributes)
!= _relevant_state_attributes(attributes)
):
points.append(
StateHistoryPoint(
timestamp=timestamp,
state=raw_state,
attributes=_relevant_state_attributes(attributes),
)
)
if entity_id is not None and points: if entity_id is not None and points:
points.sort(key=lambda point: point.timestamp) points.sort(key=lambda point: point.timestamp)
normalized.append(StateHistorySeries(entity_id=entity_id, points=points)) normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
@@ -176,3 +191,16 @@ def _optional_string(value: object) -> str | None:
if value is None or value == "": if value is None or value == "":
return None return None
return str(value) return str(value)
def _relevant_state_attributes(attributes: dict[str, object]) -> dict[str, object]:
keys = {
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
}
return {key: attributes[key] for key in keys if key in attributes}

View File

@@ -12,6 +12,7 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router from app.api.v1.actuators import router as actuators_router
@@ -20,6 +21,7 @@ from app.behavior.engine import BehaviorEngine
from app.config import load_settings from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.discovery import discover_entities
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry from app.ml.registry.model_registry import ModelRegistry
@@ -47,8 +49,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task: asyncio.Task[None] | None = None reconcile_task: asyncio.Task[None] | None = None
event_listener_task: asyncio.Task[None] | None = None event_listener_task: asyncio.Task[None] | None = None
fallback_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.registry = ModelRegistry(settings.model_store)
app.state.actuator_store = ActuatorStore(settings.actuator_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"): if hasattr(app.state, "ha_reader"):
del app.state.ha_reader del app.state.ha_reader
if hasattr(app.state, "actuator_service"): if hasattr(app.state, "actuator_service"):
@@ -60,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=HaClientSettings( settings=HaClientSettings(
url=cast(str, settings.ha_url), url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token), token=cast(str, settings.ha_token),
timeout_seconds=settings.ha_timeout_seconds,
) )
) )
app.state.ha_reader = HaReader(client=client) app.state.ha_reader = HaReader(client=client)
@@ -79,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task = asyncio.create_task(_periodic_reconciliation(app)) reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client)) event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
fallback_task = asyncio.create_task(_fallback_prediction(app)) fallback_task = asyncio.create_task(_fallback_prediction(app))
cache_refresh_task = asyncio.create_task(_periodic_dashboard_cache_refresh(app))
try: try:
yield yield
finally: finally:
@@ -98,6 +106,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
fallback_task.cancel() fallback_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await fallback_task 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: if client is not None:
client.close() client.close()
@@ -105,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.20", version="1.7.4",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -159,6 +171,42 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.") 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: async def _startup_reconciliation(app: FastAPI) -> None:
delay_seconds = 5 delay_seconds = 5
while True: while True:
@@ -207,15 +255,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket" ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token) auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None) 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: while True:
if ws_status is not None: if ws_status is not None:
ws_status.status = "connecting" ws_status.status = "connecting"
try: try:
async with websockets.connect( async with websockets.connect(ws_url, ping_interval=None) as websocket:
ws_url,
ping_interval=20,
ping_timeout=10,
) as websocket:
auth_required_msg = await websocket.recv() auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg) auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required": if auth_required_data.get("type") != "auth_required":
@@ -239,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader) 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: if ws_status is not None:
ws_status.status = "connected" ws_status.status = "connected"
ws_status.error = None ws_status.error = None
@@ -265,10 +315,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
entity_id = event_data.get("entity_id") entity_id = event_data.get("entity_id")
if not entity_id: if not entity_id:
continue 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") new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state) _update_ha_state_cache(state_cache, entity_id, new_state)
if not _is_relevant_state_change(store, str(entity_id)):
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist # Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen # Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread( await asyncio.to_thread(
@@ -286,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
websockets.exceptions.InvalidStatus, websockets.exceptions.InvalidStatus,
OSError, OSError,
) as exc: ) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc) delay = reconnect_delay
logger.warning(
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
exc,
delay,
)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(1) await asyncio.sleep(delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
except Exception as exc: except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc) logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(1) await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt # Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -311,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
await asyncio.sleep( await asyncio.sleep(
app.state.settings.prediction_interval_seconds app.state.settings.prediction_interval_seconds
if websocket_connected if websocket_connected
else min(5, app.state.settings.prediction_interval_seconds) else max(30, app.state.settings.prediction_interval_seconds)
) )
# Nur ausführen, wenn WebSocket nicht verbunden ist # Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
@@ -347,15 +408,14 @@ def _update_ha_state_cache(
) )
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool: def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
result: set[str] = set()
for record in store.list(): for record in store.list():
if record.actuator_entity_id == entity_id: result.add(record.actuator_entity_id)
return True if record.assignment.selected_numeric_entity_id:
if record.assignment.selected_numeric_entity_id == entity_id: result.add(record.assignment.selected_numeric_entity_id)
return True result.update(record.assignment.selected_context_entity_ids)
if entity_id in record.assignment.selected_context_entity_ids: return result
return True
return False
def _ha_entity_from_event( def _ha_entity_from_event(

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] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.7.20" version = "1.7.6"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -87,7 +87,7 @@ def _service(
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None: def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
entity_id="light.abstellkammer", entity_id="light.abstellkammer",
@@ -141,6 +141,46 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_light_with_opening_context_does_not_require_brightness_sensor(tmp_path: Path) -> None:
start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_tuer",
domain="binary_sensor",
device_class="door",
friendly_name="Tür Abstellkammer",
area_name="Abstellkammer",
),
]
service = _service(
tmp_path,
entities,
{"sensor.abstellkammer_illuminance": _points(8, start, 10.0)},
)
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_tuer"]
assert record.assignment.review_required is False
assert "kein Helligkeitssensor erforderlich" in record.assignment.reason
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None: def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
@@ -352,6 +392,100 @@ def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit" assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.lidl_kuche",
domain="light",
friendly_name="Lidl Küche",
),
HaEntitySummary(
entity_id="light.lidl_wohnzimmer",
domain="light",
friendly_name="Lidl Wohnzimmer",
),
HaEntitySummary(
entity_id="binary_sensor.pir_kuche_motion_detection",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Küche",
),
HaEntitySummary(
entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Wohnzimmer",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.lidl_kuche")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.pir_kuche_motion_detection"
]
def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="button.smart_mailbox_als_geleert_markieren",
domain="button",
friendly_name="Smart Mailbox Als geleert markieren",
),
HaEntitySummary(
entity_id="binary_sensor.schrank_strasse_open",
domain="binary_sensor",
device_class="door",
friendly_name="Schrank Straße",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.schrank_strasse_open"
]
assert record.assignment.review_required is False
def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="humidifier.gastewc_luftung",
domain="humidifier",
friendly_name="GästeWC Lüftung",
),
HaEntitySummary(
entity_id="sensor.pir_gastewc_humidity",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Gäste WC Luftfeuchtigkeit",
),
HaEntitySummary(
entity_id="input_boolean.gaste_wc_occupied",
domain="input_boolean",
friendly_name="gaste_wc_occupied",
),
]
service = _service(
tmp_path,
entities,
{"sensor.pir_gastewc_humidity": _points(8, start, 55.0)},
)
record = service.configure_actuator("humidifier.gastewc_luftung")
assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity"
assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [

View File

@@ -1,15 +1,20 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from time import perf_counter
from zoneinfo import ZoneInfo
import pytest
from fastapi.testclient import TestClient 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.lifecycle import ActuatorReconciliationService
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import ( from app.ha.history import (
@@ -28,8 +33,11 @@ class FakeHaReader(HaReader):
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None: def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
self._entities = entities self._entities = entities
self._history = history self._history = history
self.read_entities_calls = 0
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
def read_entities(self) -> list[HaEntitySummary]: def read_entities(self) -> list[HaEntitySummary]:
self.read_entities_calls += 1
return list(self._entities) return list(self._entities)
def discover( def discover(
@@ -83,6 +91,7 @@ class FakeHaReader(HaReader):
service: str, service: str,
service_data: dict[str, object], service_data: dict[str, object],
) -> list[object]: ) -> list[object]:
self.service_calls.append((domain, service, service_data))
return [] return []
def find_automations_for_entity( def find_automations_for_entity(
@@ -108,6 +117,7 @@ def _install_service(tmp_path: Path) -> None:
unit_of_measurement="lx", unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit", friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer", area_name="Abstellkammer",
state="12",
), ),
HaEntitySummary( HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion", entity_id="binary_sensor.abstellkammer_motion",
@@ -115,6 +125,7 @@ def _install_service(tmp_path: Path) -> None:
device_class="motion", device_class="motion",
friendly_name="Abstellkammer Bewegung", friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer", area_name="Abstellkammer",
state="off",
), ),
HaEntitySummary( HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes", entity_id="sensor.pfsense_interface_vpn_inbytes",
@@ -138,6 +149,7 @@ def _install_service(tmp_path: Path) -> None:
) )
app.state.registry = ModelRegistry(tmp_path / "models") app.state.registry = ModelRegistry(tmp_path / "models")
app.state.actuator_store = ActuatorStore(tmp_path / "actuators") app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
app.state.dashboard_cache = DashboardCache(tmp_path / "actuators" / "dashboard_cache.sqlite3")
app.state.ha_reader = FakeHaReader( app.state.ha_reader = FakeHaReader(
entities, entities,
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]}, {"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
@@ -213,6 +225,451 @@ 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_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/settings/rooms")
assert response.status_code == 200
payload = response.json()
room = payload["rooms"][0]
assert room["room"] == "Abstellkammer"
assert room["actuator_count"] == 1
actuator = room["actuators"][0]
assert actuator["actuator_entity_id"] == "light.abstellkammer"
assert actuator["sensors"]
assert actuator["prediction_rules"]
assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"])
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: def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)

View File

@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
) is None ) is None
def test_prediction_respects_learned_context_delay() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"input_boolean.gaste_wc_occupied": "on"},
trigger_entity_id="input_boolean.gaste_wc_occupied",
trigger_from_state="off",
trigger_to_state="on",
trigger_delay_seconds=180,
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
]
early = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=30)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
due = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=185)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
assert early is None
assert due is not None
assert due.target_state == "on"
def test_light_prediction_carries_brightness_attributes() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
target_attributes={"brightness": brightness},
minute_of_day=now.astimezone().hour * 60 + now.astimezone().minute,
weekday=now.astimezone().weekday(),
context_states={"binary_sensor.pir_kuche_motion_detection": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago, brightness in zip((3, 2, 1), (80, 90, 100), strict=True)
]
prediction = predict_behavior(
patterns,
current_context={"binary_sensor.pir_kuche_motion_detection": "on"},
now=now,
min_support=3,
window_minutes=30,
)
assert prediction is not None
assert prediction.target_attributes["brightness"] == 90
def test_state_change_uses_websocket_context_state_for_immediate_action( def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -778,3 +853,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
assert reader.service_calls == [ assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"}) ("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

@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
assert [point.state for point in result[0].points] == ["off", "on"] assert [point.state for point in result[0].points] == ["off", "on"]
def test_normalize_state_history_keeps_light_attribute_changes() -> None:
result = normalize_state_history_payload(
[
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {"brightness": 80, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:00:00+00:00",
},
{
"state": "on",
"attributes": {"brightness": 120, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:05:00+00:00",
},
]
]
)
assert [point.attributes["brightness"] for point in result[0].points] == [80, 120]
def test_normalize_logbook_preserves_action_origin() -> None: def test_normalize_logbook_preserves_action_origin() -> None:
result = normalize_logbook_payload( result = normalize_logbook_payload(
[ [

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") config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main") repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone 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,30 +9,47 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "So gehst du vor" in response.text assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
assert "Gerät zum Lernen auswählen" in response.text assert "So gehst du vor" not in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text assert "Discovery & Einrichtung" in response.text
assert "Oder aus Liste wählen" in response.text assert "Entity-ID" in response.text
assert "Geräteliste" in response.text
assert "Liste durchsuchen" in response.text assert "Liste durchsuchen" in response.text
assert "Wie gewohnt bedienen" in response.text assert "Geräteliste bei Bedarf laden" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Vorschläge können Home Assistant stark abfragen" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" 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 "Freigabestatus" in response.text
assert "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort." in response.text
assert "room-management" in response.text
assert 'api("v1/actuators/settings/rooms")' in response.text
assert "Auswahl speichern" in response.text
assert "SillyHome übernehmen lassen" in response.text assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" 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 "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" 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 "manual-context-freeform" in response.text
assert "Diese Kontext-Auswahl speichern" in response.text assert "Diese Kontext-Auswahl speichern" in response.text
assert "manual-context-select" 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 "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert "record.behavior.activation_ready" in response.text assert "record.behavior.activation_ready" in response.text
assert "record.behavior.status ===" not in response.text assert "record.behavior.status ===" not in response.text
assert "record.behavior_status || record.behavior?.status" 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 "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text

View File

@@ -92,11 +92,10 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
connect.assert_called_once_with( connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket", "ws://homeassistant:8123/api/websocket",
ping_interval=20, ping_interval=None,
ping_timeout=10, )
)
assert fake_ws.sent == [ assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"}, {"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"}, {"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
@@ -127,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
assert mock_app.state.ws_status.error is None 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: def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI() app = FastAPI()
app.state.settings = MagicMock() app.state.settings = MagicMock()