Compare commits

...

21 Commits

Author SHA1 Message Date
787516ac67 Avoid per-request discovery classification in dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:19:59 +02:00
7ba9807a4e Prepare SillyHome Next 1.0.0 dashboard and API rework
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:13:43 +02:00
4db4276b95 Rework dashboard loading and cache entity metadata
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:41:50 +02:00
98a2b2cc38 Fix dashboard summary status rendering
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:21:14 +02:00
387e027fe2 Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:09:35 +02:00
f8bee92e64 Optimize dashboard categories and context loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:02:19 +02:00
9ddb065f62 Speed up HA event processing
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:58:58 +02:00
8222f24ebe Group configured actuator overview
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:51:02 +02:00
a7a2f8c78a Make SillyHome startup resilient
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:43:41 +02:00
faf4099756 Load actuator suggestions asynchronously
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:14:51 +02:00
1b2b76455a Tighten context onboarding and actuator suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:06:03 +02:00
18999ff68a Limit actuator picker results
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:43:00 +02:00
e2826e92ec Improve SillyHome discovery and feedback learning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:38:32 +02:00
c5f42a39a9 Fix realtime HA state-change execution
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 10:50:28 +02:00
309b33b812 Use fresh HA event state for behavior triggers
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 19:37:45 +02:00
9db7cde179 Fix HA websocket keepalive fallback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 19:30:02 +02:00
3140f65527 Fix HA websocket state change handling
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 18:15:14 +02:00
5727053951 fix: hide diagnostic context suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:47:14 +02:00
658516cd96 fix: narrow manual context suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:42:50 +02:00
8cd8f3e3b7 feat: improve actor-specific context selection
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:35:38 +02:00
09e14689a3 feat: add manual context assignment and fix actuator discovery
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 23:15:00 +02:00
18 changed files with 2904 additions and 156 deletions

View File

@@ -1,5 +1,155 @@
# Changelog # Changelog
## 1.0.0 - 2026-06-17
- Neuer blockweiser Dashboard-Start über `/v1/actuators/dashboard`: lokale
Store-/Cache-Daten laden sofort, HA-Discovery und Vorschläge laufen
nachgelagert.
- Discovery liest Entities pro Anfrage nur noch einmal und klassifiziert aus
diesem Snapshot weiter. Dadurch entfallen doppelte HA-Vollabfragen.
- Persistenter JSON-Entity-Cache wird für Friendly Name, Raum, Gerät,
Discovery-Gruppen und schnelle Summaries genutzt.
- Dashboard mit Orange als Primärfarbe, kompakter Navigation, aufklappbarer
Anleitung, aufklappbaren Gerätegruppen und Cache-/Systemstatistik.
- Aktor-/Sensor-Kategorien erweitert: Feuchte, Wetter, Helligkeit, Bewegung,
Tür/Fenster, Präsenz, Lichtzustände, Schalter, Steckdosen, Lüftung, Heizung,
Cover, Helper, PV/Akku/Einspeisung.
- Kontextvorschläge vermeiden weitere doppelte HA-Discovery und sortieren
aktortypbezogen nach relevanten Bereichen.
## 0.7.21 - 2026-06-17
- Dashboard-Ladepfad getrennt: beobachtete Geräte laden sofort über
`/v1/actuators/summary`; Status, Discovery und Vorschläge laufen unabhängig
nachgelagert und blockieren die Übersicht nicht mehr.
- Systemstatus nutzt Timeouts und bleibt auch bei langsamem ML-/HA-Status
bedienbar.
- HA-Entity-Metadaten werden als JSON-Cache gespeichert und für Friendly Name,
Raum und Gerät in schlanken Summaries wiederverwendet.
- Anleitung, Gerätegruppen und manuelle Kontextauswahl sind aufklappbar und
kompakter für Smartphone- und Desktopansichten.
## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
## 0.7.19 - 2026-06-17
- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt
statt voller Lernmuster und kompletter HA-Entityliste.
- Nach Aktionen werden Dashboard-Caches gezielt invalidiert, damit keine
stale oder doppelt geladenen Einträge entstehen.
## 0.7.18 - 2026-06-16
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.
- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie
"Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen.
- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände,
Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer
kategorisiert und per Suche/Kategorie erreichbar.
- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur,
Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter.
## 0.7.17 - 2026-06-16
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
dem teuren State-Cache-Listenbau verworfen.
- WebSocket nutzt Keepalive und reconnectet nach Abbrüchen nach 1s statt 5s.
## 0.7.16 - 2026-06-16
- Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und
mit Friendly Name angezeigt.
## 0.7.15 - 2026-06-16
- Add-on-Start ist robust gegen Home-Assistant-Core-502 beim Systemboot:
API und WebSocket-Listener starten trotzdem, Reconciliation/Training werden
im Hintergrund mit Retry nachgeholt.
- Periodische Reconciliation und Fallback-Auswertung beenden den Dienst nicht
mehr bei temporären HA-Fehlern.
- Add-on-Watchdog prüft `/health`, damit Supervisor den Dienst nach Absturz
wieder starten kann.
## 0.7.14 - 2026-06-16
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
## 0.7.13 - 2026-06-16
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
übernommen.
- Verwendete Kontext-Entities können pro Aktor direkt entfernt und damit als
manuelle Zuordnung überschrieben werden.
- Onboarding-Vorschläge zeigen passende, noch nicht eingerichtete Aktoren aus
bestehenden Automationen und naheliegenden Kontexten.
- TV-/Medien-Aktoren über `media_player` und Fernbedienungen über `remote`
werden in Discovery und Auswahl berücksichtigt.
## 0.7.12 - 2026-06-16
- Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei
größeren Mengen zum Eingrenzen per Suche oder Typfilter auf.
## 0.7.11 - 2026-06-16
- Aktor-Discovery erkennt weitere steuerbare HA-Domains wie Buttons, Helper,
Heizungen, Schlösser, Ventile und numerische Helper.
- Aktor-Auswahl dedupliziert Licht-/Schalter-Doppelungen pro Gerät und gruppiert
zusätzliche Typen im Dashboard.
- Discovery liefert Kategorien für Mess-, Binär-, Kontext- und Aktor-Entities.
- Nutzerfeedback kann Vorhersagen als korrekt oder falsch markieren und direkt
als Lernsignal speichern.
## 0.7.10 - 2026-06-16
- WebSocket-State-Changes aktualisieren einen internen Home-Assistant-State-
Cache und werten Aktoren direkt gegen diesen frischen Event-Zustand aus.
- Event-Auswertungen lösen keine REST-Statusabfrage mehr aus, bevor sie
aktive Aktoren schalten.
## 0.7.9 - 2026-06-15
- Event-basierte Vorhersagen verwenden den frischen Sensorzustand direkt aus
dem Home-Assistant-WebSocket-Event, damit Kontextwechsel ohne REST-Race sofort
bewertet und geschaltet werden können
- Regressionstest stellt sicher, dass ein Türsensor-Event trotz veraltetem
HA-Snapshot direkt `light.turn_on` auslöst
## 0.7.8 - 2026-06-15
- Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive-
Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu
aufgebaut werden
- Fallback-Auswertung läuft bei getrenntem WebSocket kurzfristig alle 5 Sekunden,
damit übernommene Aktoren nicht ohne Steuerung bleiben
## 0.7.7 - 2026-06-15
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
## 0.7.6 - 2026-06-14
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
## 0.7.5 - 2026-06-14
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
gemeinsame Geräte/Tokens oder echte globale Außenwerte
- Diagnosewerte wie MQTT-, WiFi-, Restart- und Connect-Zähler werden nicht mehr
als fachliche Kontextvorschläge angeboten
## 0.7.4 - 2026-06-14
- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
Roh-Liste aller Sensoren und Zustände
- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und
durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden
- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne
Dropdown-Treffer gespeichert werden können
- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne
fachlichen Bezug nicht mehr als Standardvorschläge auf
## 0.7.3 - 2026-06-14
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
- Aktor-Auswahl auf tatsächlich sicher steuerbare Domains begrenzt:
`light`, `switch`, `cover`, `fan`, `humidifier`
- Neue manuelle Kontext-Zuordnung pro Aktor: Haupt-Messsensor optional setzen und
mehrere relevante Kontext-Entities wie PIR, Außenhelligkeit, Luftfeuchtigkeit
oder andere Lichtzustände auswählen
- Dashboard-Dropdown durch echtes Select plus Suche ersetzt; mobile Bedienung und
Aktor-Details enthalten Speichern/Neu-laden-Aktionen für manuelle Kontextwahl
## 0.7.2 - 2026-06-14 ## 0.7.2 - 2026-06-14
- Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA- - Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA-
Installationen nicht mehr am Template-Ausgabe-Limit scheitern Installationen nicht mehr am Template-Ausgabe-Limit scheitern

View File

@@ -11,6 +11,8 @@ 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)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## Reifegrad
@@ -58,6 +60,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.2" version: "1.0.0"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next
@@ -7,6 +7,7 @@ arch:
- amd64 - amd64
startup: application startup: application
boot: auto boot: auto
watchdog: http://[HOST]:[PORT:8000]/health
init: false init: false
ingress: true ingress: true
ingress_port: 8000 ingress_port: 8000

View File

@@ -13,13 +13,14 @@ from app.actuators.models import (
AssignmentSource, AssignmentSource,
LifecycleAuditEntry, LifecycleAuditEntry,
LifecycleStatus, LifecycleStatus,
ManualOverride,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
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
@@ -47,6 +48,7 @@ _STOPWORDS = frozenset(
"light", "light",
"licht", "licht",
"lichtschalter", "lichtschalter",
"monitoring",
"power", "power",
"sensor", "sensor",
"state", "state",
@@ -55,6 +57,7 @@ _STOPWORDS = frozenset(
"value", "value",
} }
) )
_GENERIC_AREA_NAMES = frozenset({"energie", "monitoring", "power", "strom", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5 _NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
@@ -62,6 +65,78 @@ _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3 _CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5 _MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20 _AUDIT_LIMIT = 20
_MANUAL_CONTEXT_DOMAINS = frozenset({
"binary_sensor",
"climate",
"cover",
"device_tracker",
"fan",
"humidifier",
"input_boolean",
"input_number",
"input_select",
"light",
"media_player",
"person",
"remote",
"scene",
"sensor",
"sun",
"switch",
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 500
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"bytes",
"connect",
"count",
"data",
"diagnostic",
"firmware",
"gesehen",
"heat",
"inbytes",
"interface",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"outbytes",
"pfsense",
"reason",
"restart",
"rssi",
"signal",
"ssid",
"status",
"overheat",
"overheating",
"overload",
"uptime",
"vpn",
"uberhitzung",
"ueberhitzung",
"ueberlast",
"überhitzung",
"überlast",
"wifi",
"zuletzt",
})
_AUTO_CONTEXT_CLASSES = frozenset({
"door",
"garage_door",
"illuminance",
"motion",
"occupancy",
"opening",
"presence",
"window",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -88,11 +163,114 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord: def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id) return self._store.get(actuator_entity_id)
def suggest_context_options(
self,
actuator_entity_id: str,
*,
limit: int = _CONTEXT_SUGGESTION_LIMIT,
) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
selected_ids = _selected_context_ids(self._store.get(actuator_entity_id))
ranked: list[tuple[float, str, HaEntitySummary]] = []
for entity in entities.values():
if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS:
continue
role = _manual_context_role(entity, discovered.get(entity.entity_id))
score, _ = _score_candidate(
actuator,
entity,
role,
context=role is not EntityRole.MEASUREMENT,
)
selected = entity.entity_id in selected_ids
if selected:
score = max(score, 1.0)
if not selected and _is_diagnostic_context(entity):
continue
if not selected and not _has_context_relationship(actuator, entity):
score = max(score, 0.01)
ranked.append((score, _context_sort_group(entity), entity))
ranked.sort(
key=lambda item: (
-item[0],
item[1],
item[2].area_name or "",
item[2].friendly_name or item[2].entity_id,
item[2].entity_id,
)
)
return [entity for _, _, entity in ranked[:limit]]
def delete_actuator(self, actuator_entity_id: str) -> None: def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id) model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id) self._registry.archive(model_id)
self._store.delete(actuator_entity_id) self._store.delete(actuator_entity_id)
def set_manual_assignment(
self,
actuator_entity_id: str,
*,
numeric_entity_id: str | None,
context_entity_ids: list[str],
note: str | None = None,
) -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
selected_context_ids = list(dict.fromkeys(context_entity_ids))
selected_ids = [
entity_id
for entity_id in [numeric_entity_id, *selected_context_ids]
if entity_id
]
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
if missing:
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(missing)}")
if actuator_entity_id in selected_ids:
raise ValueError("Der Aktor selbst kann nicht als Kontextsensor verwendet werden.")
override = ManualOverride(
numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids,
updated_at=now,
note=note,
)
assignment = self._manual_assignment(override)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
assignment=assignment,
lifecycle=record.lifecycle.model_copy(update={"last_reconciled_at": now}),
now=now,
)
updated = record.model_copy(
update={
"assignment": assignment,
"manual_override": override,
"numeric_candidates": _merge_manual_candidates(
record.numeric_candidates,
entities,
[numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
"context_candidates": _merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
),
"lifecycle": lifecycle,
"updated_at": now,
}
)
return self._store.upsert(updated)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy( state = self._store.load_reconciliation_state().model_copy(
update={ update={
@@ -198,10 +376,14 @@ class ActuatorReconciliationService:
), ),
context=True, context=True,
) )
assignment = self._select_assignment( assignment = (
actuator=actuator, self._manual_assignment(record.manual_override)
numeric_candidates=numeric_candidates, if record.manual_override is not None
context_candidates=context_candidates, else self._select_assignment(
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
)
) )
lifecycle = self._reconcile_lifecycle( lifecycle = self._reconcile_lifecycle(
actuator=actuator, actuator=actuator,
@@ -212,7 +394,7 @@ class ActuatorReconciliationService:
updated = record.model_copy( updated = record.model_copy(
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": None, "manual_override": record.manual_override,
"numeric_candidates": numeric_candidates, "numeric_candidates": numeric_candidates,
"context_candidates": context_candidates, "context_candidates": context_candidates,
"lifecycle": lifecycle, "lifecycle": lifecycle,
@@ -228,6 +410,23 @@ class ActuatorReconciliationService:
) )
return updated return updated
@staticmethod
def _manual_assignment(override: ManualOverride) -> AssignmentSelection:
selected_context_ids = list(dict.fromkeys(override.context_entity_ids))
selected_count = len(selected_context_ids) + (1 if override.numeric_entity_id else 0)
return AssignmentSelection(
selected_numeric_entity_id=override.numeric_entity_id,
selected_context_entity_ids=selected_context_ids,
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_count else 0.0,
review_required=selected_count == 0,
reason=(
f"Manuell festgelegt: {selected_count} Kontext-Entity(s) werden verwendet."
if selected_count
else "Manuelle Zuordnung enthält noch keine Kontext-Entities."
),
)
def _select_assignment( def _select_assignment(
self, self,
*, *,
@@ -459,8 +658,12 @@ class ActuatorReconciliationService:
if context if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
) )
can_auto_accept_context = (
not context or _eligible_for_auto_context(actuator, candidate)
)
auto_accepted = ( auto_accepted = (
candidate.score >= minimum_score can_auto_accept_context
and candidate.score >= minimum_score
and confidence >= auto_score and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN) and (context or margin >= _NUMERIC_MIN_MARGIN)
) )
@@ -507,6 +710,111 @@ def _filter_candidates(
return result return result
def _selected_context_ids(record: ActuatorRecord) -> set[str]:
result = set(record.assignment.selected_context_entity_ids)
if record.assignment.selected_numeric_entity_id:
result.add(record.assignment.selected_numeric_entity_id)
if record.manual_override is not None:
result.update(record.manual_override.context_entity_ids)
if record.manual_override.numeric_entity_id:
result.add(record.manual_override.numeric_entity_id)
return result
def _manual_context_role(
entity: HaEntitySummary,
discovered: DiscoveredEntity | None,
) -> EntityRole:
if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED:
return discovered.role
if entity.domain == "sensor":
return EntityRole.MEASUREMENT
if entity.domain == "binary_sensor":
return EntityRole.BINARY_CONTEXT
return EntityRole.CONTEXT
def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
text = " ".join(
value.lower().replace("_", " ")
for value in [entity.entity_id, entity.friendly_name, entity.area_name, entity.device_name]
if value
)
if device_class in {"motion", "occupancy", "presence"}:
return "01_presence"
if device_class in {"illuminance"}:
return "02_brightness"
if device_class in {"door", "garage_door", "opening", "window"}:
return "03_opening"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if device_class in {"temperature"}:
return "05_temperature"
if any(token in text for token in {"pv", "solar", "akku", "batterie", "battery", "einspeisung"}):
return "06_pv_battery"
if device_class in {"power", "energy", "current", "voltage"}:
return "07_power"
if entity.domain in {"weather"}:
return "08_weather"
if entity.domain in {"fan", "humidifier"}:
return "09_ventilation"
if entity.domain in {"climate"}:
return "10_heating"
if entity.domain in {"cover"}:
return "11_cover"
if entity.domain in {"light", "switch"}:
return "12_states"
if entity.domain.startswith("input_"):
return "13_helper"
if entity.domain in {"person", "device_tracker"}:
return "14_people"
return f"20_{entity.domain}_{device_class}"
def _is_diagnostic_context(entity: HaEntitySummary) -> bool:
tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(tokens.intersection(_DIAGNOSTIC_TOKENS))
def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary) -> bool:
if (
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
):
return True
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
return True
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS)
and entity.device_class in {"illuminance", "humidity", "temperature"}
)
def _eligible_for_auto_context(
actuator: HaEntitySummary,
candidate: AssignmentCandidate,
) -> bool:
device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES:
return True
if (
actuator.device_name
and candidate.device_name
and actuator.device_name == candidate.device_name
and candidate.domain in {"light", "switch"}
):
return True
return False
def _score_candidate( def _score_candidate(
actuator: HaEntitySummary, actuator: HaEntitySummary,
entity: HaEntitySummary, entity: HaEntitySummary,
@@ -522,7 +830,12 @@ def _score_candidate(
if overlap: if overlap:
score += min(0.4, 0.1 * len(overlap)) score += min(0.4, 0.1 * len(overlap))
evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}") evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}")
if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name: if (
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
):
score += 0.35 score += 0.35
evidence.append(f"Gleicher Bereich: {actuator.area_name}") evidence.append(f"Gleicher Bereich: {actuator.area_name}")
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id: if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
@@ -547,25 +860,96 @@ def _score_candidate(
if context and role is EntityRole.BINARY_CONTEXT: if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05 score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.") evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
"illuminance",
"humidity",
"temperature",
}:
score += 0.1
evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.")
return round(min(score, 1.0), 4), evidence return round(min(score, 1.0), 4), evidence
def _merge_manual_candidates(
candidates: list[AssignmentCandidate],
entities: dict[str, HaEntitySummary],
selected_entity_ids: list[str],
*,
role: EntityRole,
) -> list[AssignmentCandidate]:
by_id = {candidate.entity_id: candidate for candidate in candidates}
for entity_id in selected_entity_ids:
existing = by_id.get(entity_id)
if existing is not None:
evidence = [
item
for item in existing.evidence
if item != "Manuell vom Nutzer als relevant festgelegt."
]
by_id[entity_id] = existing.model_copy(
update={
"auto_accepted": True,
"confidence": 1.0,
"evidence": [
*evidence,
"Manuell vom Nutzer als relevant festgelegt.",
],
}
)
continue
entity = entities.get(entity_id)
if entity is None:
continue
by_id[entity_id] = AssignmentCandidate(
entity_id=entity.entity_id,
domain=entity.domain,
role=role,
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
score=1.0,
confidence=1.0,
auto_accepted=True,
evidence=["Manuell vom Nutzer als relevant festgelegt."],
)
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) mapping = {
"climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"},
"cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "illuminance", "motion", "occupancy", "opening", "presence", "window"},
"media_player": {"occupancy", "presence"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
}
return frozenset(
mapping.get(
domain,
{"door", "garage_door", "motion", "occupancy", "opening", "presence"},
)
)
mapping = { mapping = {
"climate": {"temperature", "humidity", "power"}, "climate": {"temperature", "humidity"},
"cover": {"illuminance", "temperature", "wind_speed"}, "cover": {"illuminance", "temperature", "wind_speed"},
"fan": {"temperature", "humidity", "power"}, "fan": {"temperature", "humidity", "moisture"},
"humidifier": {"humidity", "temperature", "power"}, "humidifier": {"humidity", "moisture", "temperature"},
"light": {"illuminance", "power", "energy"}, "light": {"illuminance"},
"media_player": {"power", "energy"},
"remote": {"battery"},
"switch": {"power", "energy", "current"}, "switch": {"power", "energy", "current"},
"valve": {"temperature", "pressure", "humidity"}, "valve": {"temperature", "pressure", "humidity"},
} }
return frozenset(mapping.get(domain, {"power", "energy", "temperature"})) return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
def _metadata_tokens(entity: HaEntitySummary) -> set[str]: def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False) -> set[str]:
raw_values = [ raw_values = [
entity.entity_id, entity.entity_id,
entity.friendly_name, entity.friendly_name,
@@ -577,7 +961,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
if value is None: if value is None:
continue continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
if len(token) < 3 or token in _STOPWORDS: if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
continue continue
tokens.add(token) tokens.add(token)
return tokens return tokens

View File

@@ -1,5 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -7,8 +12,9 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ReconciliationState from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -32,16 +38,263 @@ class AutomationControlRequest(BaseModel):
enabled: bool enabled: bool
class ManualAssignmentRequest(BaseModel):
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
context_entity_ids: list[str] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=500)
class FeedbackRequest(BaseModel):
correct: bool
expected_state: str | None = Field(default=None, max_length=100)
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
confidence: float
reason: str
related_automation_count: int = 0
likely_context_count: int = 0
class ActuatorSummary(BaseModel):
actuator_entity_id: str
domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
enabled: bool
behavior_mode: str
behavior_status: str
lifecycle_status: str
activation_ready: bool
activation_reason: str
sample_count: int
prediction_target_state: str | None = None
prediction_confidence: float | None = None
updated_at: str
class EntityCacheStatus(BaseModel):
available: bool
updated_at: str | None = None
entity_count: int = 0
class DashboardSystemStatus(BaseModel):
api_status: str = "ok"
websocket_status: str = "unavailable"
websocket_error: str | None = None
reconciliation_last_completed_at: str | None = None
configured_actuators: int = 0
trained_models: int = 0
review_required: int = 0
class DashboardDiscoveryGroup(BaseModel):
category: str
role: str
count: int
class DashboardOverview(BaseModel):
system: DashboardSystemStatus
cache: EntityCacheStatus
actuators: list[ActuatorSummary]
discovery_groups: list[DashboardDiscoveryGroup]
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: def discover_actuators(
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} request: Request,
discovered = ha_reader.discover() refresh: bool = Query(default=False),
actuator_ids = sorted( ha_reader: HaReader = Depends(get_ha_reader),
entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR ) -> list[HaEntitySummary]:
cached_entities = [] if refresh else _load_cached_entities(request)
if cached_entities:
entities = {entity.entity_id: entity for entity in cached_entities}
else:
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
entities = {entity.entity_id: entity for entity in fresh_entities}
discovered = discover_entities(list(entities.values()))
actuator_ids = _deduplicate_actuator_ids(
[
(entity.entity_id, entity.category)
for entity in discovered
if entity.role is EntityRole.ACTUATOR
],
entities,
) )
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("/suggestions", response_model=list[ActuatorSuggestion])
def suggest_actuators(
request: Request,
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[ActuatorSuggestion]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
actuator_ids = _deduplicate_actuator_ids(
[
(entity.entity_id, entity.category)
for entity in discovered.values()
if entity.role is EntityRole.ACTUATOR
],
entities,
)
suggestions: list[ActuatorSuggestion] = []
for entity_id in actuator_ids:
if entity_id in configured_ids:
continue
entity = entities.get(entity_id)
if entity is None:
continue
try:
automations = ha_reader.find_automations_for_entity(entity_id)
except Exception:
automations = []
context_count = _likely_context_count(entity, entities, discovered)
if not automations and context_count == 0:
continue
confidence = 1.0 if automations else min(0.85, 0.35 + context_count * 0.1)
reason_parts = []
if automations:
reason_parts.append(f"{len(automations)} passende HA-Automation(en)")
if context_count:
reason_parts.append(f"{context_count} naheliegende Kontext-Entity(s)")
suggestions.append(
ActuatorSuggestion(
entity_id=entity.entity_id,
domain=entity.domain,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
confidence=round(confidence, 4),
reason=", ".join(reason_parts),
related_automation_count=len(automations),
likely_context_count=context_count,
)
)
return sorted(
suggestions,
key=lambda item: (
-item.related_automation_count,
-item.confidence,
item.area_name or "",
item.friendly_name or item.entity_id,
),
)[:30]
@router.get("/context-options", response_model=list[HaEntitySummary])
def context_options(
request: Request,
actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"),
) -> list[HaEntitySummary]:
if actuator_entity_id is None:
return []
try:
return _service(request).suggest_context_options(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/summary", response_model=list[ActuatorSummary])
def list_configured_summary(request: Request) -> list[ActuatorSummary]:
records = _service(request).list_configured()
entity_map = _load_cached_entity_map(
request,
{record.actuator_entity_id for record in records},
)
return [
ActuatorSummary(
actuator_entity_id=record.actuator_entity_id,
domain=record.actuator_entity_id.split(".", 1)[0],
friendly_name=(
entity_map[record.actuator_entity_id].friendly_name
if record.actuator_entity_id in entity_map
else None
),
area_name=(
entity_map[record.actuator_entity_id].area_name
if record.actuator_entity_id in entity_map
else None
),
device_name=(
entity_map[record.actuator_entity_id].device_name
if record.actuator_entity_id in entity_map
else None
),
enabled=record.enabled,
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
activation_ready=record.behavior.activation_ready,
activation_reason=record.behavior.activation_reason,
sample_count=record.behavior.sample_count,
prediction_target_state=(
record.behavior.prediction.target_state
if record.behavior.prediction is not None
else None
),
prediction_confidence=(
record.behavior.prediction.confidence
if record.behavior.prediction is not None
else None
),
updated_at=record.updated_at.isoformat(),
)
for record in records
]
@router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
raw_groups = cache_payload.get("discovery_groups", [])
cached_groups = [
DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups
if isinstance(group, dict)
] if isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
websocket_error=getattr(ws_status, "error", None),
reconciliation_last_completed_at=(
reconciliation.last_completed_at.isoformat()
if reconciliation.last_completed_at is not None
else None
),
configured_actuators=len(actuators),
trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required,
),
cache=EntityCacheStatus(
available=bool(raw_entities),
updated_at=updated_at,
entity_count=len(raw_entities),
),
actuators=actuators,
discovery_groups=cached_groups,
)
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -97,6 +350,22 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback(
actuator_entity_id: str,
payload: FeedbackRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).record_feedback(
actuator_entity_id,
correct=payload.correct,
expected_state=payload.expected_state,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation( def set_activation(
actuator_entity_id: str, actuator_entity_id: str,
@@ -116,6 +385,27 @@ def set_activation(
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/assignment", response_model=ActuatorRecord)
def set_manual_assignment(
actuator_entity_id: str,
payload: ManualAssignmentRequest,
request: Request,
) -> ActuatorRecord:
try:
record = _service(request).set_manual_assignment(
actuator_entity_id,
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
_behavior(request).train(record.actuator_entity_id)
return _behavior(request).evaluate(record.actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post( @router.post(
"/{actuator_entity_id}/related-automations/refresh", "/{actuator_entity_id}/related-automations/refresh",
response_model=ActuatorRecord, response_model=ActuatorRecord,
@@ -193,3 +483,193 @@ def _behavior(request: Request) -> BehaviorEngine:
detail="Verhaltenslernen ist nicht initialisiert.", detail="Verhaltenslernen ist nicht initialisiert.",
) )
return engine return engine
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return ReconciliationState()
try:
return store.load_reconciliation_state()
except ValueError:
return ReconciliationState(last_summary="Reconciliation-Status ist unlesbar.")
def _entity_cache_path(request: Request) -> Path:
store = getattr(request.app.state, "actuator_store", None)
settings = getattr(request.app.state, "settings", None)
if isinstance(store, ActuatorStore):
base_dir = store._root
elif isinstance(settings, Settings):
base_dir = Path(settings.actuator_store).resolve().parent
else:
base_dir = Path(".").resolve()
return Path(os.getenv("SILLYHOME_ENTITY_CACHE", base_dir / "ha_entity_cache.json"))
def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
return []
try:
return [HaEntitySummary.model_validate(entity) for entity in raw_entities]
except ValueError:
return []
def _load_cached_entity_map(
request: Request,
entity_ids: set[str],
) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
return {}
result: dict[str, HaEntitySummary] = {}
for raw_entity in raw_entities:
if not isinstance(raw_entity, dict):
continue
entity_id = raw_entity.get("entity_id")
if not isinstance(entity_id, str) or entity_id not in entity_ids:
continue
try:
result[entity_id] = HaEntitySummary.model_validate(raw_entity)
except ValueError:
continue
return result
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
path = _entity_cache_path(request)
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
except (OSError, TypeError, ValueError):
return {}
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
path = _entity_cache_path(request)
path.parent.mkdir(parents=True, exist_ok=True)
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
payload = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"discovery_groups": [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
],
"entities": [entity.model_dump(mode="json") for entity in entities],
}
temporary = path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def _deduplicate_actuator_ids(
discovered: list[tuple[str, str]],
entities: dict[str, HaEntitySummary],
) -> list[str]:
priority = {
"light": 0,
"cover_shutter": 1,
"heating": 2,
"lock": 3,
"fan": 4,
"switch_socket": 5,
"button": 6,
"helper": 7,
}
selected: dict[str, tuple[int, str]] = {}
for entity_id, category in discovered:
entity = entities.get(entity_id)
if entity is None:
continue
key = _actuator_duplicate_key(entity, category)
rank = priority.get(category, 50)
current = selected.get(key)
if current is None or (rank, entity_id) < current:
selected[key] = (rank, entity_id)
return sorted(entity_id for _, entity_id in selected.values())
def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str:
if entity.device_id and category in {"light", "switch_socket", "button"}:
return f"device:{entity.device_id}:control"
if entity.device_name and category in {"light", "switch_socket", "button"}:
return f"device-name:{entity.device_name.lower()}:control"
return f"entity:{entity.entity_id}"
def _likely_context_count(
actuator: HaEntitySummary,
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
) -> int:
actuator_tokens = _tokens(actuator)
count = 0
for entity in entities.values():
if entity.entity_id == actuator.entity_id:
continue
descriptor = discovered.get(entity.entity_id)
role = descriptor.role if descriptor is not None else None
if role not in {
EntityRole.MEASUREMENT,
EntityRole.BINARY_CONTEXT,
EntityRole.CONTEXT,
}:
continue
if entity.device_class not in {
"door",
"energy",
"garage_door",
"humidity",
"illuminance",
"motion",
"occupancy",
"opening",
"power",
"presence",
"temperature",
"window",
}:
continue
same_area = bool(
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
)
same_device = bool(
actuator.device_id
and entity.device_id
and actuator.device_id == entity.device_id
)
token_match = bool(actuator_tokens.intersection(_tokens(entity)))
if same_area or same_device or token_match:
count += 1
return count
def _tokens(entity: HaEntitySummary) -> set[str]:
values = [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
tokens: set[str] = set()
for value in values:
if not value:
continue
tokens.update(token for token in value.lower().replace("_", " ").split() if len(token) > 2)
return tokens

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -18,6 +19,7 @@ from app.actuators.store import ActuatorStore
from app.config import Settings from app.config import Settings
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
_MAX_PATTERNS = 500 _MAX_PATTERNS = 500
@@ -187,23 +189,32 @@ class BehaviorEngine:
results.append(record) results.append(record)
return results return results
def evaluate(self, actuator_entity_id: str) -> ActuatorRecord: def evaluate(
self,
actuator_entity_id: str,
*,
context_state_overrides: dict[str, str | None] | None = None,
context_changed_at_overrides: dict[str, datetime | None] | None = None,
current_entities: Sequence[HaEntitySummary] | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
try: if current_entities is None:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} try:
except HaClientError as exc: current_entities = self._ha_reader.read_entities()
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc) except HaClientError as exc:
return self._save_behavior( logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
record, return self._save_behavior(
record.behavior.model_copy( record,
update={ record.behavior.model_copy(
"last_evaluated_at": now, update={
"prediction": None, "last_evaluated_at": now,
"reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}", "prediction": None,
} "reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}",
), }
) ),
)
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id) actuator = entities.get(actuator_entity_id)
if actuator is None: if actuator is None:
return self._save_behavior( return self._save_behavior(
@@ -230,6 +241,22 @@ class BehaviorEngine:
entity_id: entities[entity_id].last_changed entity_id: entities[entity_id].last_changed
for entity_id in current_context for entity_id in current_context
} }
selected_context_ids = {
entity_id
for entity_id in (
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
if entity_id
}
for entity_id, state in (context_state_overrides or {}).items():
if entity_id in selected_context_ids and state is not None:
current_context[entity_id] = state
for entity_id, changed_at in (context_changed_at_overrides or {}).items():
if entity_id in current_context:
current_context_changed_at[entity_id] = changed_at or now
prediction = predict_behavior( prediction = predict_behavior(
record.behavior.patterns, record.behavior.patterns,
current_context=current_context, current_context=current_context,
@@ -329,6 +356,95 @@ class BehaviorEngine:
) )
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
def record_feedback(
self,
actuator_entity_id: str,
*,
correct: bool,
expected_state: str | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
current_context = {
entity_id: entities[entity_id].state
for entity_id in context_ids
if entity_id in entities and entities[entity_id].state is not None
}
prediction = record.behavior.prediction
patterns = list(record.behavior.patterns)
reason = "Nutzerfeedback gespeichert."
if correct and prediction is not None:
local = now.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=prediction.target_state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states={
entity_id: state
for entity_id, state in current_context.items()
if state is not None
},
source="user_feedback",
weight=1.0,
observed_at=now,
)
)
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
else:
target = prediction.target_state if prediction is not None else None
if target:
patterns = [
pattern.model_copy(update={"weight": 0.1})
if pattern.target_state == target
and _pattern_context_matches(pattern, current_context)
else pattern
for pattern in patterns
]
if expected_state:
local = now.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=expected_state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states={
entity_id: state
for entity_id, state in current_context.items()
if state is not None
},
source="user_correction",
weight=1.0,
observed_at=now,
)
)
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
behavior = record.behavior.model_copy(
update={
"patterns": patterns[-_MAX_PATTERNS:],
"prediction": (
prediction.model_copy(update={"execution_reason": reason})
if prediction is not None
else None
),
"reason": reason,
"last_trained_at": now,
}
)
return self._save_behavior(record, behavior)
def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord: def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord:
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
related = [ related = [
@@ -608,20 +724,30 @@ class BehaviorEngine:
) )
return self._store.upsert(updated) return self._store.upsert(updated)
def handle_state_change(self, entity_id: str, new_state: dict[str, object] | None) -> None: def handle_state_change(
self,
entity_id: str,
new_state: dict[str, object] | None,
*,
current_entities: Sequence[HaEntitySummary] | None = None,
) -> None:
"""Wird bei jedem HA-State-Change aufgerufen und löst sofortige Vorhersage aus. """Wird bei jedem HA-State-Change aufgerufen und löst sofortige Vorhersage aus.
- Wenn entity_id ein Aktor ist: evaluate() direkt. - Wenn entity_id ein Aktor ist: evaluate() direkt.
- Wenn entity_id ein Kontext-Entity ist: alle betroffenen Aktoren evaluieren. - Wenn entity_id ein Kontext-Entity ist: alle betroffenen Aktoren evaluieren.
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
""" """
# Aktor direkt evaluieren # Aktor direkt evaluieren
for record in self._store.list(): for record in self._store.list():
if record.actuator_entity_id == entity_id: if record.actuator_entity_id == entity_id:
try: try:
self.evaluate(record.actuator_entity_id) self.evaluate(record.actuator_entity_id, current_entities=current_entities)
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
return return
event_state = _event_state(new_state)
event_changed_at = _event_changed_at(new_state) or datetime.now(timezone.utc)
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen # Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
affected_actuators = [ affected_actuators = [
record.actuator_entity_id record.actuator_entity_id
@@ -633,11 +759,38 @@ class BehaviorEngine:
] ]
for actuator_entity_id in affected_actuators: for actuator_entity_id in affected_actuators:
try: try:
self.evaluate(actuator_entity_id) self.evaluate(
actuator_entity_id,
context_state_overrides={entity_id: event_state},
context_changed_at_overrides={entity_id: event_changed_at},
current_entities=current_entities,
)
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
def _event_state(new_state: dict[str, object] | None) -> str | None:
if not isinstance(new_state, dict):
return None
state = new_state.get("state")
return state if isinstance(state, str) else None
def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
if not isinstance(new_state, dict):
return None
value = new_state.get("last_changed") or new_state.get("last_updated")
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def predict_behavior( def predict_behavior(
patterns: list[BehaviorPattern], patterns: list[BehaviorPattern],
*, *,
@@ -745,8 +898,10 @@ def predict_behavior(
def service_for_state(domain: str, target_state: str) -> str | None: def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "switch"}: if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state) return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "scene":
return "turn_on" if target_state == "on" else None
if domain == "cover": if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state) return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
return None return None
@@ -792,6 +947,20 @@ def _matches_own_execution(
) )
def _pattern_context_matches(
pattern: BehaviorPattern,
current_context: dict[str, str | None],
) -> bool:
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
if not comparable:
return False
return all(current_context[entity_id] == expected for entity_id, expected in comparable)
def _recent_context_transition( def _recent_context_transition(
history: dict[str, StateHistorySeries], history: dict[str, StateHistorySeries],
context_ids: list[str], context_ids: list[str],

View File

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

View File

@@ -3,6 +3,7 @@ import json
import logging import logging
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@@ -19,6 +20,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.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes from backend.routes.ml import init_ml_routes
@@ -41,6 +43,7 @@ class _WsStatus:
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None client: HaClient | None = None
startup_task: asyncio.Task[None] | None = 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
@@ -72,15 +75,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=settings, settings=settings,
) )
app.state.ws_status = _WsStatus() app.state.ws_status = _WsStatus()
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup") startup_task = asyncio.create_task(_startup_reconciliation(app))
await asyncio.to_thread(app.state.behavior_engine.train_all)
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
reconcile_task = asyncio.create_task(_periodic_reconciliation(app)) reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client)) 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))
try: try:
yield yield
finally: finally:
if startup_task is not None:
startup_task.cancel()
with suppress(asyncio.CancelledError):
await startup_task
if reconcile_task is not None: if reconcile_task is not None:
reconcile_task.cancel() reconcile_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
@@ -100,7 +105,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.2", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -145,24 +150,59 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
service = getattr(app.state, "actuator_service", None) service = getattr(app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):
continue continue
await asyncio.to_thread(service.reconcile_all, "scheduled") try:
await asyncio.to_thread(service.reconcile_all, "scheduled")
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all)
except Exception:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
async def _startup_reconciliation(app: FastAPI) -> None:
delay_seconds = 5
while True:
service = getattr(app.state, "actuator_service", None)
engine = getattr(app.state, "behavior_engine", None) engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine): if not isinstance(service, ActuatorReconciliationService) or not isinstance(
engine,
BehaviorEngine,
):
return
try:
await asyncio.to_thread(service.reconcile_all, "startup")
await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
return
except Exception as exc:
logger.warning(
"Startup-Reconciliation verschoben: %s. Neuer Versuch in %ss.",
exc,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
delay_seconds = min(delay_seconds * 2, 60)
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None: async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
"""Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus.""" """Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus."""
settings = app.state.settings settings = app.state.settings
engine = app.state.behavior_engine engine = app.state.behavior_engine
ha_reader = getattr(app.state, "ha_reader", None)
store = app.state.actuator_store store = app.state.actuator_store
if not isinstance(engine, BehaviorEngine) or not isinstance(store, ActuatorStore): if (
not isinstance(engine, BehaviorEngine)
or not isinstance(store, ActuatorStore)
or not isinstance(ha_reader, HaReader)
):
logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert") logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert")
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert" ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert"
return return
state_cache: dict[str, HaEntitySummary] = {}
ha_url = str(settings.ha_url).rstrip("/") ha_url = str(settings.ha_url).rstrip("/")
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)
@@ -171,7 +211,11 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
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(ws_url) as websocket: async with websockets.connect(
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":
@@ -194,6 +238,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
continue continue
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)
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
@@ -213,28 +258,45 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
event = data.get("event", {}) event = data.get("event", {})
if event.get("event_type") != "state_changed": if event.get("event_type") != "state_changed":
continue continue
entity_id = event.get("entity_id") event_data = event.get("data", {})
if not isinstance(event_data, dict):
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
continue
entity_id = event_data.get("entity_id")
if not entity_id: if not entity_id:
continue continue
new_state = event_data.get("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(engine.handle_state_change, entity_id, event.get("new_state")) await asyncio.to_thread(
engine.handle_state_change,
entity_id,
new_state,
current_entities=list(state_cache.values()),
)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket") logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc: except Exception as exc:
logger.exception("Fehler bei Event-Verarbeitung: %s", exc) logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
except (websockets.exceptions.ConnectionClosed, OSError) as exc: except (
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc) websockets.exceptions.ConnectionClosed,
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
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(5) await asyncio.sleep(1)
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(5) await asyncio.sleep(1)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt # Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -244,7 +306,13 @@ async def _fallback_prediction(app: FastAPI) -> None:
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen. Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
""" """
while True: while True:
await asyncio.sleep(app.state.settings.prediction_interval_seconds) ws_status = getattr(app.state, "ws_status", None)
websocket_connected = ws_status is not None and ws_status.status == "connected"
await asyncio.sleep(
app.state.settings.prediction_interval_seconds
if websocket_connected
else min(5, 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)
if ws_status is None or ws_status.status != "connected": if ws_status is None or ws_status.status != "connected":
@@ -254,4 +322,82 @@ async def _fallback_prediction(app: FastAPI) -> None:
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)", "Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
ws_status.status if ws_status else "unavailable", ws_status.status if ws_status else "unavailable",
) )
await asyncio.to_thread(engine.evaluate_all) try:
await asyncio.to_thread(engine.evaluate_all)
except Exception:
logger.exception("Fallback-Vorhersage fehlgeschlagen.")
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:
return {entity.entity_id: entity for entity in reader.read_entities()}
def _update_ha_state_cache(
state_cache: dict[str, HaEntitySummary],
entity_id: str,
new_state: object,
) -> None:
if not isinstance(new_state, dict):
state_cache.pop(entity_id, None)
return
state_cache[entity_id] = _ha_entity_from_event(
entity_id,
new_state,
state_cache.get(entity_id),
)
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
for record in store.list():
if record.actuator_entity_id == entity_id:
return True
if record.assignment.selected_numeric_entity_id == entity_id:
return True
if entity_id in record.assignment.selected_context_entity_ids:
return True
return False
def _ha_entity_from_event(
entity_id: str,
new_state: dict[str, object],
previous: HaEntitySummary | None,
) -> HaEntitySummary:
attributes = new_state.get("attributes")
attr = attributes if isinstance(attributes, dict) else {}
state_class = _optional_event_string(attr.get("state_class"))
device_class = _optional_event_string(attr.get("device_class"))
unit_of_measurement = _optional_event_string(attr.get("unit_of_measurement"))
friendly_name = _optional_event_string(attr.get("friendly_name"))
return HaEntitySummary(
entity_id=entity_id,
domain=entity_id.split(".", 1)[0],
state=_optional_event_string(new_state.get("state")),
last_changed=_event_datetime(new_state.get("last_changed"))
or _event_datetime(new_state.get("last_updated")),
state_class=state_class or (previous.state_class if previous else None),
device_class=device_class or (previous.device_class if previous else None),
unit_of_measurement=unit_of_measurement
or (previous.unit_of_measurement if previous else None),
friendly_name=friendly_name or (previous.friendly_name if previous else None),
area_id=previous.area_id if previous else None,
area_name=previous.area_name if previous else None,
device_id=previous.device_id if previous else None,
device_name=previous.device_name if previous else None,
)
def _optional_event_string(value: object) -> str | None:
return value if isinstance(value, str) else None
def _event_datetime(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed

View File

@@ -5,59 +5,79 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title> <title>SillyHome Next</title>
<style> <style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; } :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111317; color: #f3f5f7; scroll-behavior:smooth; --accent:#ff8a1c; --accent-strong:#ffab45; --panel:#181c22; --panel-soft:#20262e; --border:#303842; }
body { margin: 0; font-size:16px; } body { margin: 0; font-size:15px; }
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); } header { padding: 18px 20px; background: linear-gradient(135deg,#1c1f26,#3a2411 70%,#5c2d08); }
h1,h2,h3 { margin: 0 0 12px; } h1,h2,h3 { margin: 0 0 12px; }
header p { margin: 5px 0; color: #c3d1dc; } header p { margin: 5px 0; color: #c3d1dc; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; } main { display: grid; grid-template-columns: minmax(280px,.82fr) minmax(0,1.38fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; } section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width:0; }
section:target { outline:2px solid #66dfa9; outline-offset:2px; } section:target { outline:2px solid var(--accent); outline-offset:2px; }
.wide { grid-column: 1 / -1; } .wide { grid-column: 1 / -1; }
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); } .quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(17,19,23,.94); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; } .quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#222831; border:1px solid var(--border); color:#f3f5f7; text-decoration:none; font-weight:700; font-size:.92rem; }
.quick-nav a.primary { background:#23715b; } .quick-nav a.primary { background:#8f4208; border-color:var(--accent); }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; } details.collapsible > summary,
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; } .manual-context > summary,
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; } .group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
details.collapsible > summary::-webkit-details-marker,
.manual-context > summary::-webkit-details-marker,
.group-panel > summary::-webkit-details-marker { display:none; }
details.collapsible > summary::after,
.manual-context > summary::after,
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
details[open].collapsible > summary::after,
.manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:"zuklappen"; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
.step { background:#12161c; border:1px solid var(--border); border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:var(--accent); color:#211204; font-weight:800; margin-bottom:8px; }
.step p { margin:5px 0; } .step p { margin:5px 0; }
.ok { color: #66dfa9; } .ok { color: #73e0a9; }
.warn { color: #f3c969; } .warn { color: #f3c969; }
.bad { color: #ff8f8f; } .bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; } label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; } select,input,button { box-sizing: border-box; width: 100%; border-radius: 8px; border: 1px solid #3b4b5b; padding: 10px; background: #11161d; color: #fff; font:inherit; min-width:0; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } select[multiple] { min-height:150px; }
button { min-height:44px; margin-top: 10px; background: var(--accent); color:#211204; border: 0; font-weight: 800; cursor: pointer; }
button.secondary { background: #37495c; } button.secondary { background: #37495c; }
button.danger { background: #7b3434; } button.danger { background: #7b3434; }
button.compact { width:auto; min-width:120px; margin-right:8px; } button.compact { width:auto; min-width:120px; margin-right:8px; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; } table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; } td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; } ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #66dfa9; padding-left: 10px; } .notice { border-left: 4px solid var(--accent); padding-left: 10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; } .grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; } .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; } .chip { padding:4px 8px; border-radius:999px; background:#222831; border:1px solid var(--border); font-size:.85rem; }
.muted { color:#9fb0be; } .muted { color:#9fb0be; }
.card-list { display:grid; gap:12px; } .card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } .actuator-card { background:#12161c; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); } .actuator-card.selected { border-color:var(--accent); box-shadow:0 0 0 1px rgba(255,138,28,.35); }
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; } .card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
.entity-id { overflow-wrap:anywhere; font-weight:800; } .entity-id { overflow-wrap:anywhere; font-weight:800; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; } .metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; } .metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; } .metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; } .actions button { flex:1 1 180px; margin-top:0; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; } .detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:12px; background:#12161c; border:1px solid var(--border); border-radius:8px; padding:10px; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; }
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; } code { color:#cfe0ec; overflow-wrap:anywhere; }
@media (max-width: 760px) { @media (max-width: 760px) {
header { padding:18px 14px; } header { padding:18px 14px; }
header h1 { font-size:1.55rem; } header h1 { font-size:1.55rem; }
main { display:block; padding:10px; } main { display:block; padding:8px; }
section { margin-bottom:12px; padding:14px; border-radius:14px; } section { margin-bottom:10px; padding:10px; border-radius:8px; }
.steps { grid-template-columns:1fr; } .steps { grid-template-columns:1fr; }
.grid-two { grid-template-columns:1fr; } .grid-two { grid-template-columns:1fr; }
.metric-grid { grid-template-columns:1fr 1fr; } .metric-grid { grid-template-columns:1fr 1fr; }
.card-list { grid-template-columns:1fr; }
.actions { display:grid; grid-template-columns:1fr; } .actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; } .actions button, button.compact { width:100%; min-width:0; margin-right:0; }
.quick-nav { padding:8px 10px; } .quick-nav { padding:8px 10px; }
@@ -84,7 +104,8 @@
</nav> </nav>
<main> <main>
<section class="wide" id="guide"> <section class="wide" id="guide">
<h2>So gehst du vor</h2> <details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps"> <div class="steps">
<div class="step"> <div class="step">
<span class="step-number">1</span> <span class="step-number">1</span>
@@ -105,14 +126,16 @@
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p> <p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div> </div>
</div> </div>
</details>
</section> </section>
<section id="status-section"> <section id="status-section">
<h2>Systemstatus</h2> <h2>System & Cache</h2>
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p> <p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt im Hintergrund nach.</p>
<div id="status">Prüfung läuft ...</div> <div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div> <div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button> <div id="dashboard-stats" class="metric-grid"></div>
<button class="secondary" onclick="loadOverview()">Dashboard aktualisieren</button>
</section> </section>
<section id="choose"> <section id="choose">
@@ -121,8 +144,40 @@
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label> <label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off"> <input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
<datalist id="actuator-options"></datalist> <datalist id="actuator-options"></datalist>
<div class="inline-controls">
<div>
<label for="actuator-domain-filter">Typ</label>
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
<option value="">Alle steuerbaren Typen</option>
<option value="light">Lichter</option>
<option value="switch">Schalter / Helper</option>
<option value="button">Buttons</option>
<option value="input_button">Helper-Buttons</option>
<option value="input_boolean">Helper-Schalter</option>
<option value="cover">Rollläden / Cover</option>
<option value="climate">Heizungen / Klima</option>
<option value="lock">Schlösser</option>
<option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</option>
<option value="scene">Szenen</option>
<option value="number">Numerische Helper</option>
<option value="valve">Ventile</option>
</select>
</div>
<div>
<label for="actuator-search">Liste durchsuchen</label>
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
</div>
</div>
<label for="actuator-select">Oder aus Liste wählen</label>
<select id="actuator-select" onchange="selectActuatorFromList()">
<option value="">Geräteliste wird geladen ...</option>
</select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<div id="actuator-suggestions" class="card-list"></div>
</section> </section>
<section class="wide" id="observed"> <section class="wide" id="observed">
@@ -145,6 +200,24 @@ const escapeHtml = value => String(value ?? "")
.replaceAll('"', "&quot;") .replaceAll('"', "&quot;")
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
let currentActuatorId = null; let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2500;
function uniqueValues(values) {
return [...new Set(values.filter(Boolean))];
}
function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
}
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
@@ -153,8 +226,20 @@ async function api(path, options = {}) {
return body; return body;
} }
async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await api(path, {signal: controller.signal});
} finally {
clearTimeout(timeout);
}
}
function lifecycleLabel(record) { function lifecycleLabel(record) {
if (record.behavior.status === "trained") return "Kontext erkannt"; const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt";
const labels = { const labels = {
trained: "lernt", trained: "lernt",
pending_history: "sammelt Historie", pending_history: "sammelt Historie",
@@ -163,74 +248,364 @@ function lifecycleLabel(record) {
archived: "wartet auf Kontext", archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden", orphaned: "Aktor nicht gefunden",
}; };
return labels[record.lifecycle.status] || record.lifecycle.status; return labels[lifecycleStatus] || lifecycleStatus;
} }
function statusClass(record) { function statusClass(record) {
if (record.behavior.status === "trained") return "ok"; const behaviorStatus = record.behavior_status || record.behavior?.status;
if (record.lifecycle.status === "trained") return "ok"; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; if (behaviorStatus === "trained") return "ok";
if (lifecycleStatus === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
return "bad"; return "bad";
} }
function behaviorLabel(record) { function behaviorLabel(record) {
if (record.behavior.mode === "active") return "aktiv freigegeben"; const mode = record.behavior_mode || record.behavior?.mode;
if (record.behavior.status === "trained") return "Shadow-Vorhersage"; const status = record.behavior_status || record.behavior?.status;
if (record.behavior.status === "blocked") return "Lernen blockiert"; if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Shadow-Vorhersage";
if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen"; return "sammelt Handlungen";
} }
function predictionLabel(record) { function predictionLabel(record) {
return record.behavior.prediction const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)` const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target
? `${target} (${Math.round(confidence * 100)} %)`
: "Keine fällige Aktion"; : "Keine fällige Aktion";
} }
function entityLabel(entity) {
const area = entity.area_name || "Ohne Bereich";
const name = entity.friendly_name || entity.entity_id;
return `${area} - ${name} (${entity.entity_id})`;
}
function normalizedSearch(value) {
return String(value || "").toLowerCase().replaceAll("_", " ");
}
function matchesSearch(entity, query) {
if (!query) return true;
return normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
entity.device_class,
entity.domain,
].filter(Boolean).join(" ")).includes(query);
}
function categoryForEntity(entity) {
const cls = entity.device_class || "";
const text = normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
].filter(Boolean).join(" "));
if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) {
return "PV / Akku / Einspeisung";
}
if (entity.domain === "fan") return "Lüftung / Ventilatoren";
if (entity.domain === "climate") return "Heizung / Klima";
if (entity.domain === "weather") return "Wetter";
if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen";
if (entity.domain === "cover") return "Rollläden / Cover";
if (entity.domain === "light") return "Lichtzustände";
if (entity.domain === "switch") return "Schalter / Steckdosen";
if (entity.domain.startsWith("input_")) return "Helper";
if (entity.domain === "scene") return "Szenen";
if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien";
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
if (["illuminance"].includes(cls)) return "Helligkeit";
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
if (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose";
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
if (["temperature"].includes(cls)) return "Temperatur";
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal";
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
if (entity.domain === "sensor") return "Weitere Messsensoren";
return "Weitere Zustände";
}
function optionGroups(entities, selectedIds = new Set()) {
const groups = new Map();
for (const entity of entities) {
const category = categoryForEntity(entity);
if (!groups.has(category)) groups.set(category, []);
groups.get(category).push(entity);
}
return Array.from(groups.entries()).map(([label, items]) => `
<optgroup label="${escapeHtml(label)}">
${items.map(entity => `
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
${escapeHtml(entityLabel(entity))}
</option>
`).join("")}
</optgroup>
`).join("");
}
async function loadOverview() { async function loadOverview() {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
try {
const dashboard = await api("v1/actuators/dashboard");
cachedActuators = dashboard.actuators || [];
cachedEntities = [];
renderDashboardStatus(dashboard);
renderConfiguredActuators();
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
await loadSummaryData();
renderConfiguredActuators();
void loadStatus();
}
void loadActuatorDiscovery();
void loadActuatorSuggestions();
}
async function loadStatus() {
const status = document.getElementById("status"); const status = document.getElementById("status");
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
try { try {
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([ const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([
api("health"), apiWithTimeout("health"),
api("health/websocket"), apiWithTimeout("health/websocket"),
api("ml/health"), apiWithTimeout("ml/health"),
api("v1/actuators/reconciliation/state"), apiWithTimeout("v1/actuators/reconciliation/state"),
api("v1/actuators"), apiWithTimeout("v1/actuators/summary"),
]); ]);
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`; const values = [health, websocket, ml, reconciliation, actuators].map(result =>
result.status === "fulfilled" ? result.value : null
);
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values;
const hasError = values.some(value => value === null);
status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`;
chips.innerHTML = [ chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(health.status)}</span>`, `<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`, `<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Aktoren: ${actuators.length}</span>`, `<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`, `<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); }
function renderDashboardStatus(dashboard) {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats");
const system = dashboard.system || {};
const cache = dashboard.cache || {};
const discoveryGroups = dashboard.discovery_groups || [];
const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut";
status.innerHTML = `
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">
Dashboard bereit. WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}
</p>
<p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</p>
`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`,
`<span class="chip">Cache: ${escapeHtml(cacheLabel)}</span>`,
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
].join("");
stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml((dashboard.actuators || []).length)} Geräte</div>`,
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join("");
}
async function loadSummaryData() {
const actuators = await api("v1/actuators/summary");
cachedActuators = actuators;
cachedEntities = [];
}
async function loadDashboardData() {
await loadSummaryData();
await loadDiscoveryData();
}
async function loadDiscoveryData() {
const discovery = await api("v1/actuators/discovery");
cachedDiscovery = discovery;
}
async function loadFullDashboardData() {
const [actuators, discovery] = await Promise.all([
api("v1/actuators/summary"),
api("v1/actuators/discovery"),
]);
cachedActuators = actuators;
cachedEntities = [];
cachedDiscovery = discovery;
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select");
if (select && !cachedDiscovery) {
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
}
if (!cachedActuators) {
await loadSummaryData();
}
if (!cachedDiscovery) {
await loadDiscoveryData();
}
renderActuatorDiscovery();
}
function renderActuatorDiscovery() {
const options = document.getElementById("actuator-options"); const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select");
try { try {
const [available, configured] = await Promise.all([ const available = cachedDiscovery || [];
api("v1/actuators/discovery"), const configured = cachedActuators || [];
api("v1/actuators"),
]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id)); const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
const choices = available.filter(entity => !configuredIds.has(entity.entity_id)); actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = choices.map(entity => options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>` `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
).join(""); ).join("");
renderActuatorSelect();
} catch (error) { } catch (error) {
options.innerHTML = ""; options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
} }
} }
async function loadActuatorSuggestions() {
const box = document.getElementById("actuator-suggestions");
if (!box) return;
try {
const suggestions = await api("v1/actuators/suggestions");
box.innerHTML = suggestions.length ? `
<h3>Vorschläge aus bestehenden Zusammenhängen</h3>
${suggestions.slice(0, 8).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">Vorschlag übernehmen</button>
</article>
`).join("")}
` : "";
} catch (_) {
box.innerHTML = "";
}
}
function actuatorGroupLabel(domain) {
const labels = {
button: "Buttons",
climate: "Heizungen / Klima",
light: "Lichter",
input_boolean: "Helper-Schalter",
input_button: "Helper-Buttons",
lock: "Schlösser",
media_player: "TV / Medien",
number: "Numerische Helper",
remote: "Fernbedienungen",
scene: "Szenen",
switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren",
humidifier: "Befeuchter / Entfeuchter",
valve: "Ventile",
};
return labels[domain] || domain;
}
function renderActuatorSelect() {
const select = document.getElementById("actuator-select");
if (!select) return;
const domain = document.getElementById("actuator-domain-filter")?.value || "";
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
const filtered = actuatorChoices
.filter(entity => !domain || entity.domain === domain)
.filter(entity => matchesSearch(entity, query));
const visible = filtered.slice(0, ACTUATOR_RESULT_LIMIT);
const domains = [...new Set(visible.map(entity => entity.domain))].sort();
const limitLabel = filtered.length > visible.length
? ` - ${visible.length} von ${filtered.length}; Suche oder Typ weiter eingrenzen`
: "";
select.innerHTML = [
`<option value="">${filtered.length ? `Gerät auswählen${limitLabel}` : "Keine passenden Geräte gefunden"}</option>`,
...domains.map(group => `
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
${visible
.filter(entity => entity.domain === group)
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
.join("")}
</optgroup>
`),
].join("");
}
async function loadContextOptions(actuatorId) {
try {
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
} catch (error) {
contextOptions = [];
}
}
function selectActuatorFromList() {
const value = document.getElementById("actuator-select").value;
if (value) document.getElementById("actuator-input").value = value;
}
function renderManualContextSelect() {
const select = document.getElementById("manual-context-select");
if (!select) return;
const category = document.getElementById("manual-context-category")?.value || "";
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
const selectedNow = new Set([
...manualContextState.selected,
...Array.from(select.selectedOptions).map(option => option.value),
]);
const filtered = manualContextState.options
.filter(entity => !category || categoryForEntity(entity) === category)
.filter(entity => matchesSearch(entity, query))
.slice(0, 80);
select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`;
}
function parseEntityIds(value) {
return String(value || "")
.split(/[\s,;]+/)
.map(item => item.trim())
.filter(Boolean);
}
async function configureActuator() { async function configureActuator() {
const actuatorId = document.getElementById("actuator-input").value.trim(); const actuatorId = (
document.getElementById("actuator-input").value.trim()
|| document.getElementById("actuator-select").value.trim()
);
const result = document.getElementById("actuator-config-result"); const result = document.getElementById("actuator-config-result");
if (!actuatorId) return; if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ..."; result.textContent = "Kontext wird automatisch analysiert ...";
@@ -250,37 +625,57 @@ async function configureActuator() {
} }
async function loadConfiguredActuators() { async function loadConfiguredActuators() {
if (!cachedActuators) {
await loadSummaryData();
}
renderConfiguredActuators();
}
function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators"); const box = document.getElementById("configured-actuators");
try { try {
const rows = await api("v1/actuators"); const rows = cachedActuators || [];
const groups = new Map();
for (const record of rows) {
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({record});
}
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
<div class="card-list"> ${groupedRows.map(([group, items]) => `
${rows.map(record => ` <details class="group-panel" open>
<summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list">
${items.map(({record}) => `
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}"> <article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
<div class="card-title"> <div class="card-title">
<div> <div>
<div><strong>${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div> <div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div> <div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
</div> </div>
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span> <span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div> <div class="metric"><strong>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div> <div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div> <div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div> </div>
<div class="actions"> <div class="actions">
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button> <button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
${record.behavior.mode === "active" ${record.behavior_mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
: record.behavior.activation_ready : record.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>` ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
: ""} : ""}
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button> <button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
</div> </div>
</article> </article>
`).join("")} `).join("")}
</div>` : "<p>Noch keine Aktoren ausgewählt.</p>"; </div>
</details>
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
@@ -296,19 +691,77 @@ async function showActuator(actuatorId, evaluationMessage = "") {
} catch (_) { } catch (_) {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
} }
await loadContextOptions(actuatorId);
const contexts = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids, ...record.assignment.selected_context_entity_ids,
].filter(Boolean); ].filter(Boolean);
const evidence = [...record.numeric_candidates, ...record.context_candidates] const evidence = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id)) .filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
<code>${escapeHtml(entityId)}</code>
<button class="secondary compact" onclick="removeContextEntity('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(entityId)}')">Entfernen</button>
</li>
`).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction; const prediction = record.behavior.prediction;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
).length; ).length;
const relatedAutomations = record.behavior.related_automations || []; const relatedAutomations = record.behavior.related_automations || [];
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
const manualOnlyIds = [
record.assignment.selected_numeric_entity_id,
...manualContextIds,
].filter(entityId => entityId && !suggestedIds.has(entityId));
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
const manualAssignment = `
<details class="manual-context" open>
<summary>Kontext selbst festlegen</summary>
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select">
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
</select>
<div class="inline-controls">
<div>
<label for="manual-context-category">Kategorie</label>
<select id="manual-context-category" onchange="renderManualContextSelect()">
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
</select>
</div>
<div>
<label for="manual-context-filter">Vorschläge durchsuchen</label>
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
</div>
</div>
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
<select id="manual-context-select" multiple>
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
</select>
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<div class="actions">
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
</div>
</details>
`;
const activationButton = record.behavior.mode === "active" const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button> ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>` <button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
@@ -361,11 +814,18 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${prediction ${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>` ? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"} : "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<div class="actions">
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
</div>
<h3>Passende Home-Assistant-Automationen</h3> <h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p> <p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
${automationControls} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
`; `;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
@@ -373,6 +833,62 @@ async function showActuator(actuatorId, evaluationMessage = "") {
} }
} }
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from(
document.getElementById("manual-context-select").selectedOptions,
).map(option => option.value).filter(value => value.includes("."));
const freeformContextIds = parseEntityIds(
document.getElementById("manual-context-freeform").value,
);
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
.filter(entityId => entityId !== numericEntityId);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: "Manuell im Dashboard gesetzt",
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function removeContextEntity(actuatorId, entityId) {
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericEntityId = record.assignment.selected_numeric_entity_id === entityId
? null
: record.assignment.selected_numeric_entity_id;
const contextEntityIds = (record.assignment.selected_context_entity_ids || [])
.filter(id => id !== entityId);
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: `Entity ${entityId} entfernt`,
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) {
alert(error.message);
}
}
async function configureSuggestedActuator(actuatorId) {
document.getElementById("actuator-input").value = actuatorId;
await configureActuator();
}
async function evaluateActuator(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
const record = await api( const record = await api(
@@ -385,6 +901,7 @@ async function evaluateActuator(actuatorId) {
const message = record.behavior.prediction const message = record.behavior.prediction
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.` ? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`; : `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, message); await showActuator(actuatorId, message);
} catch (error) { } catch (error) {
@@ -392,6 +909,24 @@ async function evaluateActuator(actuatorId) {
} }
} }
async function sendFeedback(actuatorId, correct) {
const expectedState = correct ? null : prompt("Welcher Zustand wäre korrekt gewesen? Leer lassen, wenn nur abwerten.");
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/feedback`, {
method: "POST",
body: JSON.stringify({
correct,
expected_state: expectedState || null,
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) { async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active const question = active
? pauseMatchingAutomations ? pauseMatchingAutomations
@@ -410,6 +945,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
restore_paused_automations: restorePausedAutomations, restore_paused_automations: restorePausedAutomations,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
@@ -428,6 +964,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
enabled, enabled,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,107 @@
# 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.
## 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
```
## 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.

View File

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

View File

@@ -7,7 +7,6 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ( from app.actuators.models import (
AssignmentSource, AssignmentSource,
LifecycleStatus, LifecycleStatus,
ManualOverride,
model_id_for_actuator, model_id_for_actuator,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
@@ -79,7 +78,7 @@ def _service(
model_store=str(tmp_path / "models"), model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"), automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"), actuator_store=str(tmp_path / "actuators"),
history_days=14, history_days=31,
min_training_points=5, min_training_points=5,
retrain_stale_hours=24, retrain_stale_hours=24,
reconcile_interval_seconds=900, reconcile_interval_seconds=900,
@@ -240,7 +239,120 @@ def test_reconciliation_does_not_cross_assign_other_room_light_energy(
assert record.lifecycle.status is LifecycleStatus.ARCHIVED assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
tmp_path: Path,
) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Licht Abstellkammer",
area_name="Monitoring",
),
HaEntitySummary(
entity_id="binary_sensor.disk_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Max. fehlerhafte Sektoren ueberschritten",
area_name="Monitoring",
),
HaEntitySummary(
entity_id="sensor.router_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Router Leistung",
area_name="Monitoring",
),
]
service = _service(tmp_path, entities, {"sensor.router_power": _points(8, start, 1.0)})
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == []
assert record.assignment.review_required is True
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
tmp_path: Path,
) -> None:
entities = [
HaEntitySummary(
entity_id="light.treppe_unten",
domain="light",
friendly_name="Licht Treppe Unten",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.shelly_schrank_channel_1_overload",
domain="binary_sensor",
device_class="problem",
friendly_name="Shelly Schrank Channel 1 Überlast",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.terrasse_terasse_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Terrasse Terasse Überhitzung",
area_name="Strom",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.treppe_unten")
assert record.assignment.selected_context_entity_ids == []
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="fan.bad_lueftung",
domain="fan",
friendly_name="Bad Lüftung",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_luftfeuchtigkeit",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Bad Luftfeuchtigkeit",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Bad Leistung",
area_name="Bad",
),
]
service = _service(
tmp_path,
entities,
{
"sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0),
"sensor.bad_power": _points(8, start, 5.0),
},
)
record = service.configure_actuator("fan.bad_lueftung")
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -273,21 +385,54 @@ def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path:
"sensor.abstellkammer_power": _points(8, start, 30.0), "sensor.abstellkammer_power": _points(8, start, 30.0),
} }
service = _service(tmp_path, entities, history) service = _service(tmp_path, entities, history)
configured = service.configure_actuator("light.abstellkammer") service.configure_actuator("light.abstellkammer")
legacy = configured.model_copy( service.set_manual_assignment(
update={ "light.abstellkammer",
"manual_override": ManualOverride( numeric_entity_id="sensor.abstellkammer_power",
numeric_entity_id="sensor.abstellkammer_power", context_entity_ids=["sensor.abstellkammer_illuminance"],
context_entity_ids=[], note="Manuell wichtiger Sensor",
note="Alte manuelle Zuordnung",
)
}
) )
service._store.upsert(legacy)
restarted = _service(tmp_path, entities, history) restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer") record = restarted.reconcile_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance" assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
assert record.assignment.source.value == "automatic" assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
assert record.manual_override is None assert record.assignment.source is AssignmentSource.MANUAL
assert record.manual_override is not None
def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
domain="binary_sensor",
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
]
service = _service(tmp_path, entities, {})
service.configure_actuator("light.abstellkammer")
for _ in range(3):
service.set_manual_assignment(
"light.abstellkammer",
numeric_entity_id=None,
context_entity_ids=["binary_sensor.abstellkammer_motion"],
note="Manuell gesetzt",
)
record = service.get_actuator("light.abstellkammer")
candidate = next(
item
for item in record.context_candidates
if item.entity_id == "binary_sensor.abstellkammer_motion"
)
assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1

View File

@@ -9,6 +9,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.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 (
@@ -27,8 +28,10 @@ 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
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(
@@ -115,6 +118,14 @@ def _install_service(tmp_path: Path) -> None:
friendly_name="Abstellkammer Bewegung", friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer", area_name="Abstellkammer",
), ),
HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes",
domain="sensor",
device_class="data_size",
state_class="measurement",
unit_of_measurement="KiB",
friendly_name="pfSense Interface VPN inbytes",
),
] ]
settings = Settings( settings = Settings(
ha_url="http://ha.local", ha_url="http://ha.local",
@@ -179,14 +190,124 @@ def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
assert client.get("/v1/actuators").json() == [] assert client.get("/v1/actuators").json() == []
def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None: def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post( response = client.post(
"/v1/actuators/light.abstellkammer/override", "/v1/actuators/light.abstellkammer/assignment",
json={"numeric_entity_id": "sensor.abstellkammer_illuminance"}, json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
"note": "Manuell gesetzt",
},
) )
assert response.status_code == 404 assert response.status_code == 200
payload = response.json()
assert payload["assignment"]["source"] == "manual"
assert payload["assignment"]["selected_numeric_entity_id"] == (
"sensor.abstellkammer_illuminance"
)
assert payload["assignment"]["selected_context_entity_ids"] == [
"binary_sensor.abstellkammer_motion"
]
def test_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"]
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
reader = app.state.ha_reader
response = client.get("/v1/actuators/discovery", params={"refresh": True})
assert response.status_code == 200
assert reader.read_entities_calls == 1
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get(
"/v1/actuators/context-options",
params={"actuator_entity_id": "light.abstellkammer"},
)
assert response.status_code == 200
entity_ids = {item["entity_id"] for item in response.json()}
assert "sensor.abstellkammer_illuminance" in entity_ids
assert "binary_sensor.abstellkammer_motion" in entity_ids
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids
def test_actuator_discovery_prefers_light_over_duplicate_switch() -> None:
entities = {
"light.schreibtisch": HaEntitySummary(
entity_id="light.schreibtisch",
domain="light",
friendly_name="Schreibtisch Licht",
device_id="device-1",
),
"switch.schreibtisch": HaEntitySummary(
entity_id="switch.schreibtisch",
domain="switch",
friendly_name="Schreibtisch Schalter",
device_id="device-1",
),
"cover.rollladen": HaEntitySummary(
entity_id="cover.rollladen",
domain="cover",
friendly_name="Rollladen",
device_id="device-2",
),
}
result = _deduplicate_actuator_ids(
[
("switch.schreibtisch", "switch_socket"),
("light.schreibtisch", "light"),
("cover.rollladen", "cover_shutter"),
],
entities,
)
assert result == ["cover.rollladen", "light.schreibtisch"]

View File

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

View File

@@ -8,6 +8,7 @@ import pytest
from app.actuators.models import ( from app.actuators.models import (
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
BehaviorPrediction,
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
ExecutionEvent, ExecutionEvent,
@@ -212,6 +213,125 @@ def test_engine_counts_known_automation_actions_like_manual_actions(
assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0} assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0}
def test_feedback_marks_prediction_correct_as_learning_pattern(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
)
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback("light.office", correct=True)
assert result.behavior.patterns[-1].target_state == "on"
assert result.behavior.patterns[-1].context_states == {
"binary_sensor.office_presence": "on"
}
assert result.behavior.patterns[-1].source == "user_feedback"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als korrekt bestätigt."
def test_feedback_marks_prediction_wrong_and_adds_correction(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.office_presence": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=1),
)
],
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
),
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback(
"light.office",
correct=False,
expected_state="off",
)
assert result.behavior.patterns[0].weight == 0.1
assert result.behavior.patterns[-1].target_state == "off"
assert result.behavior.patterns[-1].source == "user_correction"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als falsch markiert."
def test_engine_learns_causal_automation_with_activation_credit( def test_engine_learns_causal_automation_with_activation_credit(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -438,6 +558,7 @@ def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None:
("domain", "state", "service"), ("domain", "state", "service"),
[ [
("light", "on", "turn_on"), ("light", "on", "turn_on"),
("media_player", "off", "turn_off"),
("switch", "off", "turn_off"), ("switch", "off", "turn_off"),
("cover", "open", "open_cover"), ("cover", "open", "open_cover"),
("cover", "closed", "close_cover"), ("cover", "closed", "close_cover"),
@@ -523,3 +644,137 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
min_support=1, min_support=1,
window_minutes=30, window_minutes=30,
) is None ) is None
def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": ["binary_sensor.storage_door"],
}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="off",
last_changed=now - timedelta(minutes=5),
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
engine.handle_state_change(
"binary_sensor.storage_door",
{"state": "on", "last_changed": now.isoformat()},
)
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"})
]
def test_state_change_uses_event_cache_without_rest_state_query(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": ["binary_sensor.storage_door"],
}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[],
history=[],
logbook=[],
)
def fail_read_entities() -> list[HaEntitySummary]:
raise AssertionError("Event-Auswertung darf keinen REST-State lesen.")
reader.read_entities = fail_read_entities # type: ignore[method-assign]
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
engine.handle_state_change(
"binary_sensor.storage_door",
{"state": "on", "last_changed": now.isoformat()},
current_entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="on",
last_changed=now,
),
],
)
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"})
]

View File

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

View File

@@ -12,6 +12,8 @@ def test_dashboard_is_served_at_root() -> None:
assert "So gehst du vor" in response.text assert "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" in response.text assert "Gerät zum Lernen auswählen" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Oder aus Liste wählen" in response.text
assert "Liste durchsuchen" in response.text
assert "Wie gewohnt bedienen" in response.text assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
@@ -21,9 +23,21 @@ def test_dashboard_is_served_at_root() -> None:
assert "Pausieren" in response.text assert "Pausieren" in response.text
assert "Davon erkannte HA-Automationen" in response.text assert "Davon erkannte HA-Automationen" in response.text
assert "Aktuelle Situation auswerten" in response.text assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text
assert "manual-context-freeform" in response.text
assert "Diese Kontext-Auswahl speichern" in response.text
assert "manual-context-select" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert "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 || 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"' in response.text
assert 'class="group-panel"' 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

@@ -1,4 +1,5 @@
import asyncio import asyncio
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -8,6 +9,8 @@ from fastapi.testclient import TestClient
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.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import _ha_event_listener, app as fastapi_app, lifespan from app.main import _ha_event_listener, app as fastapi_app, lifespan
@@ -41,10 +44,32 @@ class _RecordingBehaviorEngine(BehaviorEngine):
store=ActuatorStore(tmp_path / "actuators"), store=ActuatorStore(tmp_path / "actuators"),
settings=MagicMock(), settings=MagicMock(),
) )
self.state_changes: list[tuple[str, dict[str, object] | None]] = [] self.state_changes: list[
tuple[str, dict[str, object] | None, Sequence[HaEntitySummary] | None]
] = []
def handle_state_change(self, entity_id: str, new_state: dict[str, object] | None) -> None: def handle_state_change(
self.state_changes.append((entity_id, new_state)) self,
entity_id: str,
new_state: dict[str, object] | None,
*,
current_entities: Sequence[HaEntitySummary] | None = None,
) -> None:
self.state_changes.append((entity_id, new_state, current_entities))
class _FakeHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> list[HaEntitySummary]:
return [
HaEntitySummary(
entity_id="light.test",
domain="light",
state="off",
)
]
def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None: def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
@@ -55,18 +80,23 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
'{"type":"auth_ok"}', '{"type":"auth_ok"}',
( (
'{"type":"event","event":{"event_type":"state_changed",' '{"type":"event","event":{"event_type":"state_changed",'
'"entity_id":"light.test","new_state":{"state":"on"}}}' '"data":{"entity_id":"light.test","new_state":{"state":"on"}}}}'
), ),
asyncio.CancelledError(), asyncio.CancelledError(),
] ]
) )
with patch("websockets.connect", return_value=fake_ws): with patch("websockets.connect", return_value=fake_ws) as connect:
try: try:
await _ha_event_listener(mock_app, mock_client) await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=20,
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"},
@@ -79,12 +109,20 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
mock_app.state.ws_status = MagicMock() mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path) mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store") mock_store = ActuatorStore(tmp_path / "store")
mock_store.configure("light.test")
mock_app.state.actuator_store = mock_store mock_app.state.actuator_store = mock_store
mock_client = MagicMock() mock_client = MagicMock()
anyio.run(run_test) anyio.run(run_test)
assert mock_engine.state_changes == [("light.test", {"state": "on"})] assert len(mock_engine.state_changes) == 1
entity_id, new_state, current_entities = mock_engine.state_changes[0]
assert entity_id == "light.test"
assert new_state == {"state": "on"}
assert current_entities == [
HaEntitySummary(entity_id="light.test", domain="light", state="on")
]
assert mock_app.state.ws_status.status == "connected" assert mock_app.state.ws_status.status == "connected"
assert mock_app.state.ws_status.error is None assert mock_app.state.ws_status.error is None