Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff005f089 | |||
| 954c4511a7 | |||
| 33cce32098 | |||
| 08e41b0198 | |||
| 1b9db62294 | |||
| 5ca0c53f6a | |||
| 8070a85b52 | |||
| 575211f0db | |||
| d9dc186f9b | |||
| 214b384b70 | |||
| 6323b93f23 | |||
| 1d176cce45 | |||
| bd087728e1 | |||
| 10f9113547 | |||
| 47fa8eb0ce | |||
| bc4e33ddd8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
|
/.actuator_store/
|
||||||
|
/MagicMock/
|
||||||
|
|||||||
76
CHANGELOG.md
76
CHANGELOG.md
@@ -1,5 +1,81 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.7.6 - 2026-07-26
|
||||||
|
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
|
||||||
|
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
|
||||||
|
einer gemeinsamen Ansicht.
|
||||||
|
- Neue API `/v1/actuators/settings/rooms` liefert kompakte Verwaltungsdaten
|
||||||
|
für Raumkarten, Sensorvorschläge, Aktoren und noch nicht verwaltete
|
||||||
|
Vorschläge.
|
||||||
|
- Licht-/Schalter-Zuordnung darf bei eindeutigem Tür-/Öffnungskontext ohne
|
||||||
|
numerischen Helligkeitssensor arbeiten, z. B. Tür auf -> Licht an und Tür zu
|
||||||
|
-> Licht aus.
|
||||||
|
|
||||||
|
## 1.7.5 - 2026-07-26
|
||||||
|
- Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte
|
||||||
|
Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte.
|
||||||
|
- Aufklapp-Hinweise (`expand`/`collapse`) kommen nicht mehr fest aus CSS auf
|
||||||
|
Deutsch, sondern werden pro Sprache gesetzt.
|
||||||
|
- Detail-Cache wird beim Sprachwechsel geleert, damit keine alten deutschen
|
||||||
|
HTML-Fragmente in der englischen Oberfläche sichtbar bleiben.
|
||||||
|
|
||||||
|
## 1.7.4 - 2026-07-26
|
||||||
|
- Dashboard-Sprachumschaltung aktualisiert statische Texte, Labels,
|
||||||
|
Platzhalter und wichtige Laufzeittexte direkt beim Wechsel.
|
||||||
|
- Verhaltenslernen speichert Lichtattribute wie Helligkeit und Farbwerte aus
|
||||||
|
der Home-Assistant-Historie und gibt sie bei Lichtvorhersagen an den
|
||||||
|
`light.turn_on` Service weiter.
|
||||||
|
- Kontext-Discovery erkennt Umlaute/Raumvarianten robuster, ignoriert
|
||||||
|
Markenwörter wie `lidl` als falsche Gemeinsamkeit und bevorzugt Raum-
|
||||||
|
Präsenzsensoren für Lidl-/Treppenlichter.
|
||||||
|
- Lüftungen bevorzugen Luftfeuchte und Belegungs-/Präsenzkontext; gelernte
|
||||||
|
Sensorwechsel dürfen jetzt eine Verzögerung haben, z. B. WC besetzt -> nach
|
||||||
|
2-3 Minuten Lüftung an.
|
||||||
|
- Briefkasten-Reset-Buttons können Schrank-/Entnahme-Türen als Kontext
|
||||||
|
erkennen; Button-Aktoren können im aktiven Modus per `press` ausgeführt
|
||||||
|
werden.
|
||||||
|
- PV-/Akku-/Verbrauchssensoren werden als Energiemanagement-Kontext stärker
|
||||||
|
einsortiert.
|
||||||
|
|
||||||
|
## 1.7.0 - 2026-06-18
|
||||||
|
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
|
||||||
|
Event-Latenzmessungen und Dry-run pro Aktor.
|
||||||
|
- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und
|
||||||
|
sichtbare Job-Historie.
|
||||||
|
- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und
|
||||||
|
`never_automate` speichern; `never_automate` setzt eine manuelle Sperre.
|
||||||
|
- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und
|
||||||
|
lokale Agent-Insights aus vorhandenen Daten.
|
||||||
|
- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro
|
||||||
|
Home-Assistant-State-Change.
|
||||||
|
|
||||||
|
## 1.6.1 - 2026-06-18
|
||||||
|
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
|
||||||
|
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
|
||||||
|
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
|
||||||
|
|
||||||
|
## 1.6.0 - 2026-06-18
|
||||||
|
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
|
||||||
|
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
|
||||||
|
materialisieren.
|
||||||
|
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
|
||||||
|
der Aktor-IDs.
|
||||||
|
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
|
||||||
|
Versions-Chip im Einrichtungsbereich.
|
||||||
|
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
|
||||||
|
anderen beobachteten Geraets.
|
||||||
|
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
|
||||||
|
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
|
||||||
|
|
||||||
|
## 1.5.4 - 2026-06-18
|
||||||
|
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
|
||||||
|
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.
|
||||||
|
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
|
||||||
|
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
|
||||||
|
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
|
||||||
|
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
|
||||||
|
laden nur bei expliziter Aktualisierung neu.
|
||||||
|
|
||||||
## 1.2.0 - 2026-06-17
|
## 1.2.0 - 2026-06-17
|
||||||
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
||||||
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -21,6 +21,18 @@ nach einer ausdrücklichen Freigabe ausführen.
|
|||||||
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
|
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
|
||||||
- Version 1.3.0 Anomalie- und Performance-Überwachung:
|
- Version 1.3.0 Anomalie- und Performance-Überwachung:
|
||||||
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
|
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
|
||||||
|
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
|
||||||
|
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
|
||||||
|
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.2 Rollback-Speicher und HA-Timeouts:
|
||||||
|
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
|
||||||
|
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
|
||||||
|
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
|
||||||
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
||||||
|
|
||||||
## Reifegrad
|
## Reifegrad
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.3.0"
|
version: "1.7.6"
|
||||||
slug: sillyhome_next
|
slug: sillyhome_next
|
||||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||||
|
|||||||
@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
|
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
|
||||||
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
|
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000
|
||||||
--proxy-headers --forwarded-allow-ips='*'
|
|
||||||
|
|||||||
130
app/actuators/cache_db.py
Normal file
130
app/actuators/cache_db.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import RLock
|
||||||
|
|
||||||
|
from app.ha.models import HaEntitySummary
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardCache:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self._path = Path(path).resolve()
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = RLock()
|
||||||
|
self._init()
|
||||||
|
|
||||||
|
def load_entities_payload(self) -> dict[str, object]:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"select entity_id, payload from ha_entities order by entity_id"
|
||||||
|
).fetchall()
|
||||||
|
updated_at = self._get_meta(connection, "ha_entities_updated_at")
|
||||||
|
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
|
||||||
|
try:
|
||||||
|
groups = json.loads(groups_json)
|
||||||
|
except ValueError:
|
||||||
|
groups = []
|
||||||
|
return {
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"discovery_groups": groups if isinstance(groups, list) else [],
|
||||||
|
"entities": [json.loads(row[1]) for row in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_status(self) -> dict[str, object]:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
updated_at = self._get_meta(connection, "ha_entities_updated_at")
|
||||||
|
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
|
||||||
|
entity_count = connection.execute("select count(*) from ha_entities").fetchone()[0]
|
||||||
|
try:
|
||||||
|
groups = json.loads(groups_json)
|
||||||
|
except ValueError:
|
||||||
|
groups = []
|
||||||
|
return {
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"discovery_groups": groups if isinstance(groups, list) else [],
|
||||||
|
"entity_count": int(entity_count or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_entity_map(self, entity_ids: set[str]) -> dict[str, HaEntitySummary]:
|
||||||
|
if not entity_ids:
|
||||||
|
return {}
|
||||||
|
placeholders = ",".join("?" for _ in entity_ids)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"select entity_id, payload from ha_entities where entity_id in ({placeholders})",
|
||||||
|
tuple(sorted(entity_ids)),
|
||||||
|
).fetchall()
|
||||||
|
result: dict[str, HaEntitySummary] = {}
|
||||||
|
for entity_id, payload in rows:
|
||||||
|
try:
|
||||||
|
result[str(entity_id)] = HaEntitySummary.model_validate(json.loads(payload))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save_entities_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
entities: list[HaEntitySummary],
|
||||||
|
discovery_groups: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
rows = [
|
||||||
|
(entity.entity_id, entity.model_dump_json())
|
||||||
|
for entity in entities
|
||||||
|
]
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("delete from ha_entities")
|
||||||
|
connection.executemany(
|
||||||
|
"insert into ha_entities(entity_id, payload) values (?, ?)",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
self._set_meta(connection, "ha_entities_updated_at", now)
|
||||||
|
self._set_meta(
|
||||||
|
connection,
|
||||||
|
"discovery_groups",
|
||||||
|
json.dumps(discovery_groups, ensure_ascii=True, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
create table if not exists ha_entities (
|
||||||
|
entity_id text primary key,
|
||||||
|
payload text not null
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
create table if not exists cache_meta (
|
||||||
|
key text primary key,
|
||||||
|
value text
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(self._path, timeout=30)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_meta(connection: sqlite3.Connection, key: str) -> str | None:
|
||||||
|
row = connection.execute(
|
||||||
|
"select value from cache_meta where key = ?",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
return str(row[0]) if row is not None and row[0] is not None else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_meta(connection: sqlite3.Connection, key: str, value: str) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
insert into cache_meta(key, value) values (?, ?)
|
||||||
|
on conflict(key) do update set value = excluded.value
|
||||||
|
""",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
|
|||||||
"entity",
|
"entity",
|
||||||
"humidity",
|
"humidity",
|
||||||
"illuminance",
|
"illuminance",
|
||||||
|
"led",
|
||||||
|
"lidl",
|
||||||
"light",
|
"light",
|
||||||
"licht",
|
"licht",
|
||||||
"lichtschalter",
|
"lichtschalter",
|
||||||
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
|
|||||||
"presence",
|
"presence",
|
||||||
"window",
|
"window",
|
||||||
})
|
})
|
||||||
|
_PRESENCE_TOKENS = frozenset({
|
||||||
|
"besetzt",
|
||||||
|
"occupied",
|
||||||
|
"occupancy",
|
||||||
|
"presence",
|
||||||
|
"prasenz",
|
||||||
|
"praesenz",
|
||||||
|
"motion",
|
||||||
|
"bewegung",
|
||||||
|
"bewegungsmelder",
|
||||||
|
})
|
||||||
|
_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"})
|
||||||
|
_CABINET_TOKENS = frozenset({"schrank", "cabinet"})
|
||||||
|
_PV_TOKENS = frozenset({
|
||||||
|
"pv",
|
||||||
|
"solar",
|
||||||
|
"photovoltaik",
|
||||||
|
"akku",
|
||||||
|
"batterie",
|
||||||
|
"battery",
|
||||||
|
"einspeisung",
|
||||||
|
"wechselrichter",
|
||||||
|
"inverter",
|
||||||
|
"netzbezug",
|
||||||
|
"grid",
|
||||||
|
"verbrauch",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ActuatorReconciliationService:
|
class ActuatorReconciliationService:
|
||||||
@@ -517,6 +546,25 @@ class ActuatorReconciliationService:
|
|||||||
if candidate.auto_accepted
|
if candidate.auto_accepted
|
||||||
][: _MAX_CONTEXT_SELECTIONS]
|
][: _MAX_CONTEXT_SELECTIONS]
|
||||||
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
|
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
|
||||||
|
if (
|
||||||
|
top_numeric is not None
|
||||||
|
and actuator.domain in {"light", "switch"}
|
||||||
|
and any(
|
||||||
|
(candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
|
||||||
|
for candidate in accepted_contexts
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return AssignmentSelection(
|
||||||
|
selected_numeric_entity_id=None,
|
||||||
|
selected_context_entity_ids=top_contexts,
|
||||||
|
source=AssignmentSource.AUTOMATIC,
|
||||||
|
confidence=max(candidate.confidence for candidate in accepted_contexts),
|
||||||
|
review_required=False,
|
||||||
|
reason=(
|
||||||
|
"Tür-/Öffnungskontext automatisch erkannt. Für diese "
|
||||||
|
"direkte Schaltlogik ist kein Helligkeitssensor erforderlich."
|
||||||
|
),
|
||||||
|
)
|
||||||
if top_numeric is None:
|
if top_numeric is None:
|
||||||
if accepted_contexts:
|
if accepted_contexts:
|
||||||
return AssignmentSelection(
|
return AssignmentSelection(
|
||||||
@@ -864,6 +912,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
|
|||||||
return True
|
return True
|
||||||
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
||||||
return True
|
return True
|
||||||
|
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
|
||||||
|
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
|
if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity):
|
||||||
|
return True
|
||||||
|
if actuator.domain in {"fan", "humidifier"} and (
|
||||||
|
_is_presence_context(entity) or entity.device_class in {"humidity", "moisture"}
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and (
|
||||||
|
entity_tokens.intersection(_PV_TOKENS)
|
||||||
|
):
|
||||||
|
return True
|
||||||
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
return bool(
|
return bool(
|
||||||
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
||||||
@@ -878,6 +938,18 @@ def _eligible_for_auto_context(
|
|||||||
device_class = candidate.device_class or ""
|
device_class = candidate.device_class or ""
|
||||||
if device_class in _AUTO_CONTEXT_CLASSES:
|
if device_class in _AUTO_CONTEXT_CLASSES:
|
||||||
return True
|
return True
|
||||||
|
if actuator.domain in {"fan", "humidifier"} and device_class in {
|
||||||
|
"humidity",
|
||||||
|
"moisture",
|
||||||
|
"temperature",
|
||||||
|
}:
|
||||||
|
return True
|
||||||
|
if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate):
|
||||||
|
return True
|
||||||
|
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
|
||||||
|
candidate_tokens = _candidate_tokens(candidate, include_stopwords=True)
|
||||||
|
if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate):
|
||||||
|
return True
|
||||||
if (
|
if (
|
||||||
actuator.device_name
|
actuator.device_name
|
||||||
and candidate.device_name
|
and candidate.device_name
|
||||||
@@ -899,6 +971,7 @@ def _score_candidate(
|
|||||||
score = 0.0
|
score = 0.0
|
||||||
actuator_tokens = _metadata_tokens(actuator)
|
actuator_tokens = _metadata_tokens(actuator)
|
||||||
entity_tokens = _metadata_tokens(entity)
|
entity_tokens = _metadata_tokens(entity)
|
||||||
|
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
overlap = sorted(actuator_tokens.intersection(entity_tokens))
|
overlap = sorted(actuator_tokens.intersection(entity_tokens))
|
||||||
if overlap:
|
if overlap:
|
||||||
score += min(0.4, 0.1 * len(overlap))
|
score += min(0.4, 0.1 * len(overlap))
|
||||||
@@ -924,6 +997,31 @@ def _score_candidate(
|
|||||||
if entity.device_class in preferred_device_classes:
|
if entity.device_class in preferred_device_classes:
|
||||||
score += 0.2
|
score += 0.2
|
||||||
evidence.append(f"Passende device_class: {entity.device_class}")
|
evidence.append(f"Passende device_class: {entity.device_class}")
|
||||||
|
if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
|
||||||
|
"humidity",
|
||||||
|
"moisture",
|
||||||
|
}:
|
||||||
|
score += 0.3
|
||||||
|
evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.")
|
||||||
|
if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
|
||||||
|
"humidity",
|
||||||
|
"moisture",
|
||||||
|
}:
|
||||||
|
score += 0.3
|
||||||
|
evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.")
|
||||||
|
if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity):
|
||||||
|
score += 0.3
|
||||||
|
evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.")
|
||||||
|
if context and _is_mailbox_reset_candidate(
|
||||||
|
_metadata_tokens(actuator, include_stopwords=True),
|
||||||
|
_metadata_tokens(entity, include_stopwords=True),
|
||||||
|
entity,
|
||||||
|
):
|
||||||
|
score += 0.45
|
||||||
|
evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.")
|
||||||
|
if full_entity_tokens.intersection(_PV_TOKENS):
|
||||||
|
score += 0.12 if context else 0.18
|
||||||
|
evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.")
|
||||||
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
|
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
|
||||||
score += 0.2
|
score += 0.2
|
||||||
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
|
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
|
||||||
@@ -1068,11 +1166,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False
|
|||||||
for value in raw_values:
|
for value in raw_values:
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
|
||||||
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
|
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
|
||||||
continue
|
continue
|
||||||
tokens.add(token)
|
tokens.add(token)
|
||||||
return tokens
|
return _expand_room_tokens(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_tokens(
|
||||||
|
candidate: AssignmentCandidate,
|
||||||
|
*,
|
||||||
|
include_stopwords: bool = False,
|
||||||
|
) -> set[str]:
|
||||||
|
raw_values = [
|
||||||
|
candidate.entity_id,
|
||||||
|
candidate.friendly_name,
|
||||||
|
candidate.area_name,
|
||||||
|
candidate.device_name,
|
||||||
|
]
|
||||||
|
tokens: set[str] = set()
|
||||||
|
for value in raw_values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
|
||||||
|
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
|
||||||
|
continue
|
||||||
|
tokens.add(token)
|
||||||
|
return _expand_room_tokens(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_room_tokens(tokens: set[str]) -> set[str]:
|
||||||
|
expanded = set(tokens)
|
||||||
|
if "gaste" in expanded:
|
||||||
|
expanded.add("gaeste")
|
||||||
|
if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded):
|
||||||
|
expanded.add("gaestewc")
|
||||||
|
if {"gaeste", "zimmer"}.issubset(expanded):
|
||||||
|
expanded.add("gaestezimmer")
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: str) -> str:
|
||||||
|
return (
|
||||||
|
value.lower()
|
||||||
|
.replace("_", " ")
|
||||||
|
.replace("ä", "ae")
|
||||||
|
.replace("ö", "oe")
|
||||||
|
.replace("ü", "ue")
|
||||||
|
.replace("ß", "ss")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_presence_context(entity: HaEntitySummary) -> bool:
|
||||||
|
if entity.device_class in {"motion", "occupancy", "presence"}:
|
||||||
|
return True
|
||||||
|
return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_presence_candidate(candidate: AssignmentCandidate) -> bool:
|
||||||
|
if candidate.device_class in {"motion", "occupancy", "presence"}:
|
||||||
|
return True
|
||||||
|
return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mailbox_reset_candidate(
|
||||||
|
actuator_tokens: set[str],
|
||||||
|
context_tokens: set[str],
|
||||||
|
entity: HaEntitySummary | AssignmentCandidate,
|
||||||
|
) -> bool:
|
||||||
|
if not actuator_tokens.intersection(_MAILBOX_TOKENS):
|
||||||
|
return False
|
||||||
|
if not context_tokens.intersection(_CABINET_TOKENS):
|
||||||
|
return False
|
||||||
|
return entity.domain == "binary_sensor" and entity.device_class in {
|
||||||
|
"door",
|
||||||
|
"garage_door",
|
||||||
|
"opening",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:
|
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ class JobStatus(StrEnum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackKind(StrEnum):
|
||||||
|
CORRECT = "correct"
|
||||||
|
WRONG = "wrong"
|
||||||
|
TOO_EARLY = "too_early"
|
||||||
|
TOO_LATE = "too_late"
|
||||||
|
NEVER_AUTOMATE = "never_automate"
|
||||||
|
|
||||||
|
|
||||||
class AssignmentCandidate(BaseModel):
|
class AssignmentCandidate(BaseModel):
|
||||||
entity_id: str
|
entity_id: str
|
||||||
domain: str
|
domain: str
|
||||||
@@ -115,12 +123,14 @@ class ModelLifecycleState(BaseModel):
|
|||||||
|
|
||||||
class BehaviorPattern(BaseModel):
|
class BehaviorPattern(BaseModel):
|
||||||
target_state: str = Field(min_length=1, max_length=100)
|
target_state: str = Field(min_length=1, max_length=100)
|
||||||
|
target_attributes: dict[str, object] = Field(default_factory=dict)
|
||||||
minute_of_day: int = Field(ge=0, le=1439)
|
minute_of_day: int = Field(ge=0, le=1439)
|
||||||
weekday: int = Field(ge=0, le=6)
|
weekday: int = Field(ge=0, le=6)
|
||||||
context_states: dict[str, str] = Field(default_factory=dict)
|
context_states: dict[str, str] = Field(default_factory=dict)
|
||||||
trigger_entity_id: str | None = None
|
trigger_entity_id: str | None = None
|
||||||
trigger_from_state: str | None = None
|
trigger_from_state: str | None = None
|
||||||
trigger_to_state: str | None = None
|
trigger_to_state: str | None = None
|
||||||
|
trigger_delay_seconds: int | None = Field(default=None, ge=0)
|
||||||
source: str = Field(default="observed", max_length=40)
|
source: str = Field(default="observed", max_length=40)
|
||||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
@@ -128,6 +138,7 @@ class BehaviorPattern(BaseModel):
|
|||||||
|
|
||||||
class BehaviorPrediction(BaseModel):
|
class BehaviorPrediction(BaseModel):
|
||||||
target_state: str
|
target_state: str
|
||||||
|
target_attributes: dict[str, object] = Field(default_factory=dict)
|
||||||
confidence: float = Field(ge=0.0, le=1.0)
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
generated_at: datetime
|
generated_at: datetime
|
||||||
reason: str
|
reason: str
|
||||||
@@ -146,6 +157,43 @@ class DecisionFactor(BaseModel):
|
|||||||
evidence: list[str] = Field(default_factory=list)
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationOutcome(BaseModel):
|
||||||
|
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
actuator_entity_id: str
|
||||||
|
sensor_states: dict[str, str] = Field(default_factory=dict)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
prediction: BehaviorPrediction | None = None
|
||||||
|
decision_factors: list[DecisionFactor] = Field(default_factory=list)
|
||||||
|
would_execute: bool = False
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
recommendation: str = Field(default="", max_length=700)
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionTrace(BaseModel):
|
||||||
|
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
trigger_entity_id: str | None = None
|
||||||
|
trigger_state: str | None = None
|
||||||
|
target_state: str | None = None
|
||||||
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
executed: bool = False
|
||||||
|
blocked: bool = False
|
||||||
|
reason: str = Field(default="", max_length=700)
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LatencyMeasurement(BaseModel):
|
||||||
|
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
trigger_entity_id: str | None = None
|
||||||
|
event_to_decision_ms: int | None = Field(default=None, ge=0)
|
||||||
|
decision_to_service_ms: int | None = Field(default=None, ge=0)
|
||||||
|
event_to_done_ms: int | None = Field(default=None, ge=0)
|
||||||
|
executed: bool = False
|
||||||
|
source: str = Field(default="manual", max_length=40)
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveWeightUpdate(BaseModel):
|
class AdaptiveWeightUpdate(BaseModel):
|
||||||
entity_id: str
|
entity_id: str
|
||||||
previous_weight: float = Field(ge=0.0, le=1.0)
|
previous_weight: float = Field(ge=0.0, le=1.0)
|
||||||
@@ -248,6 +296,32 @@ class RelatedAutomation(BaseModel):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ActuatorGroup(BaseModel):
|
||||||
|
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
area_name: str | None = Field(default=None, max_length=120)
|
||||||
|
member_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
reason: str = Field(default="", max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneSuggestion(BaseModel):
|
||||||
|
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
label: str = Field(min_length=1, max_length=120)
|
||||||
|
member_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
reason: str = Field(default="", max_length=500)
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentInsight(BaseModel):
|
||||||
|
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
severity: str = Field(default="info", max_length=20)
|
||||||
|
title: str = Field(min_length=1, max_length=160)
|
||||||
|
detail: str = Field(min_length=1, max_length=700)
|
||||||
|
action: str | None = Field(default=None, max_length=300)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class BehaviorState(BaseModel):
|
class BehaviorState(BaseModel):
|
||||||
mode: BehaviorMode = BehaviorMode.SHADOW
|
mode: BehaviorMode = BehaviorMode.SHADOW
|
||||||
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
||||||
@@ -281,6 +355,16 @@ class BehaviorState(BaseModel):
|
|||||||
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
||||||
time_profiles: list[TimeProfile] = Field(default_factory=list)
|
time_profiles: list[TimeProfile] = Field(default_factory=list)
|
||||||
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
||||||
|
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
|
||||||
|
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
|
||||||
|
feedback_log: list[FeedbackKind] = Field(default_factory=list)
|
||||||
|
dry_run_enabled: bool = False
|
||||||
|
dry_run_started_at: datetime | None = None
|
||||||
|
dry_run_sample_count: int = Field(default=0, ge=0)
|
||||||
|
dry_run_hit_count: int = Field(default=0, ge=0)
|
||||||
|
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
|
||||||
|
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
|
||||||
|
agent_insights: list[AgentInsight] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ActuatorRecord(BaseModel):
|
class ActuatorRecord(BaseModel):
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class ActuatorStore:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("Ungültiger Job-Queue-Status.") from exc
|
raise ValueError("Ungültiger Job-Queue-Status.") from exc
|
||||||
|
|
||||||
|
def save_job_queue(self, queue: JobQueueState) -> JobQueueState:
|
||||||
|
with self._lock:
|
||||||
|
self._persist_job_queue(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
def start_job(
|
def start_job(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ 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
|
||||||
|
|
||||||
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup
|
from app.actuators.models import (
|
||||||
|
ActuatorRecord,
|
||||||
|
AnomalyEvent,
|
||||||
|
AssignmentCandidate,
|
||||||
|
BehaviorPattern,
|
||||||
|
FeedbackKind,
|
||||||
|
ReconciliationState,
|
||||||
|
SensorWeightGroup,
|
||||||
|
SimulationOutcome,
|
||||||
|
)
|
||||||
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
@@ -51,9 +61,40 @@ class WeightOverrideRequest(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationRequest(BaseModel):
|
||||||
|
sensor_states: dict[str, str] = Field(default_factory=dict)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
state_options: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
include_current: bool = True
|
||||||
|
max_results: int = Field(default=8, ge=1, le=20)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackRequest(BaseModel):
|
class FeedbackRequest(BaseModel):
|
||||||
correct: bool
|
correct: bool
|
||||||
expected_state: str | None = Field(default=None, max_length=100)
|
expected_state: str | None = Field(default=None, max_length=100)
|
||||||
|
kind: FeedbackKind | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPayload(BaseModel):
|
||||||
|
exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
records: list[ActuatorRecord] = Field(default_factory=list)
|
||||||
|
reconciliation: ReconciliationState = Field(default_factory=ReconciliationState)
|
||||||
|
jobs: JobQueueState = Field(default_factory=JobQueueState)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
backup: BackupPayload
|
||||||
|
replace_existing: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreResult(BaseModel):
|
||||||
|
restored_records: int = 0
|
||||||
|
skipped_existing: int = 0
|
||||||
|
restored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class SafetyProfileRequest(BaseModel):
|
class SafetyProfileRequest(BaseModel):
|
||||||
@@ -138,6 +179,49 @@ class AnomalyOverview(BaseModel):
|
|||||||
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomManagementSensor(BaseModel):
|
||||||
|
entity_id: str
|
||||||
|
domain: str
|
||||||
|
role: str
|
||||||
|
category: str
|
||||||
|
friendly_name: str | None = None
|
||||||
|
device_class: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
active: bool = False
|
||||||
|
optional: bool = False
|
||||||
|
not_required: bool = False
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoomManagementActuator(BaseModel):
|
||||||
|
actuator_entity_id: str
|
||||||
|
friendly_name: str | None = None
|
||||||
|
domain: str
|
||||||
|
behavior_mode: str
|
||||||
|
behavior_status: str
|
||||||
|
lifecycle_status: str
|
||||||
|
sample_count: int = 0
|
||||||
|
selected_numeric_entity_id: str | None = None
|
||||||
|
selected_context_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
sensors: list[RoomManagementSensor] = Field(default_factory=list)
|
||||||
|
prediction_rules: list[str] = Field(default_factory=list)
|
||||||
|
management_hint: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoomManagementGroup(BaseModel):
|
||||||
|
room: str
|
||||||
|
actuator_count: int
|
||||||
|
sensors: list[RoomManagementSensor] = Field(default_factory=list)
|
||||||
|
actuators: list[RoomManagementActuator] = Field(default_factory=list)
|
||||||
|
prediction_rules: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomManagementOverview(BaseModel):
|
||||||
|
rooms: list[RoomManagementGroup] = Field(default_factory=list)
|
||||||
|
unmanaged_actuators: list[ActuatorSuggestion] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||||
def discover_actuators(
|
def discover_actuators(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -306,30 +390,49 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
|
|||||||
|
|
||||||
@router.get("/dashboard", response_model=DashboardOverview)
|
@router.get("/dashboard", response_model=DashboardOverview)
|
||||||
def dashboard_overview(request: Request) -> DashboardOverview:
|
def dashboard_overview(request: Request) -> DashboardOverview:
|
||||||
cache_payload = _load_entity_cache_payload(request)
|
return _dashboard_overview(request, include_background=True, include_actuators=True)
|
||||||
raw_entities = cache_payload.get("entities", [])
|
|
||||||
if not isinstance(raw_entities, list):
|
|
||||||
raw_entities = []
|
@router.get("/dashboard/start", response_model=DashboardOverview)
|
||||||
raw_updated_at = cache_payload.get("updated_at")
|
def dashboard_start(request: Request) -> DashboardOverview:
|
||||||
|
return _dashboard_overview(request, include_background=False, include_actuators=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/system", response_model=DashboardOverview)
|
||||||
|
def dashboard_system(request: Request) -> DashboardOverview:
|
||||||
|
return _dashboard_overview(request, include_background=False, include_actuators=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_overview(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
include_background: bool,
|
||||||
|
include_actuators: bool,
|
||||||
|
) -> DashboardOverview:
|
||||||
|
cache_status = _load_entity_cache_status(request)
|
||||||
|
raw_updated_at = cache_status.get("updated_at")
|
||||||
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
|
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
|
||||||
raw_groups = cache_payload.get("discovery_groups", [])
|
raw_groups = cache_status.get("discovery_groups", [])
|
||||||
cached_groups = [
|
cached_groups = [
|
||||||
DashboardDiscoveryGroup.model_validate(group)
|
DashboardDiscoveryGroup.model_validate(group)
|
||||||
for group in raw_groups
|
for group in raw_groups
|
||||||
if isinstance(group, dict)
|
if isinstance(group, dict)
|
||||||
] if isinstance(raw_groups, list) else []
|
] if include_background and isinstance(raw_groups, list) else []
|
||||||
reconciliation = _reconciliation_state_or_default(request)
|
reconciliation = _reconciliation_state_or_default(request)
|
||||||
ws_status = getattr(request.app.state, "ws_status", None)
|
ws_status = getattr(request.app.state, "ws_status", None)
|
||||||
actuators = list_configured_summary(request)
|
actuators = list_configured_summary(request) if include_actuators else []
|
||||||
store = getattr(request.app.state, "actuator_store", None)
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
jobs = (
|
jobs = (
|
||||||
store.load_job_queue()
|
store.load_job_queue()
|
||||||
if isinstance(store, ActuatorStore)
|
if include_background and isinstance(store, ActuatorStore)
|
||||||
else JobQueueState()
|
else JobQueueState()
|
||||||
)
|
)
|
||||||
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
|
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
|
||||||
anomaly_count = sum(record.anomaly_count for record in actuators)
|
anomaly_count = sum(record.anomaly_count for record in actuators)
|
||||||
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
|
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
|
||||||
|
entity_count = cache_status.get("entity_count")
|
||||||
|
if not isinstance(entity_count, int):
|
||||||
|
entity_count = 0
|
||||||
return DashboardOverview(
|
return DashboardOverview(
|
||||||
system=DashboardSystemStatus(
|
system=DashboardSystemStatus(
|
||||||
websocket_status=getattr(ws_status, "status", "unavailable"),
|
websocket_status=getattr(ws_status, "status", "unavailable"),
|
||||||
@@ -339,7 +442,11 @@ def dashboard_overview(request: Request) -> DashboardOverview:
|
|||||||
if reconciliation.last_completed_at is not None
|
if reconciliation.last_completed_at is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
configured_actuators=len(actuators),
|
configured_actuators=(
|
||||||
|
len(actuators)
|
||||||
|
if include_actuators
|
||||||
|
else reconciliation.configured_actuators
|
||||||
|
),
|
||||||
trained_models=reconciliation.trained_models,
|
trained_models=reconciliation.trained_models,
|
||||||
review_required=reconciliation.review_required,
|
review_required=reconciliation.review_required,
|
||||||
job_p95_duration_ms=job_p95_duration_ms,
|
job_p95_duration_ms=job_p95_duration_ms,
|
||||||
@@ -349,9 +456,9 @@ def dashboard_overview(request: Request) -> DashboardOverview:
|
|||||||
critical_anomaly_count=critical_anomaly_count,
|
critical_anomaly_count=critical_anomaly_count,
|
||||||
),
|
),
|
||||||
cache=EntityCacheStatus(
|
cache=EntityCacheStatus(
|
||||||
available=bool(raw_entities),
|
available=bool(entity_count),
|
||||||
updated_at=updated_at,
|
updated_at=updated_at,
|
||||||
entity_count=len(raw_entities),
|
entity_count=entity_count,
|
||||||
),
|
),
|
||||||
actuators=actuators,
|
actuators=actuators,
|
||||||
discovery_groups=cached_groups,
|
discovery_groups=cached_groups,
|
||||||
@@ -382,6 +489,138 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
|
|||||||
return overview
|
return overview
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/rooms", response_model=RoomManagementOverview)
|
||||||
|
def room_management_overview(request: Request) -> RoomManagementOverview:
|
||||||
|
service = _service(request)
|
||||||
|
records = service.list_configured()
|
||||||
|
try:
|
||||||
|
entities = {entity.entity_id: entity for entity in service._ha_reader.read_entities()}
|
||||||
|
except Exception:
|
||||||
|
entities = _load_cached_entity_map(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
entity_id
|
||||||
|
for record in records
|
||||||
|
for entity_id in [
|
||||||
|
record.actuator_entity_id,
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
*[candidate.entity_id for candidate in record.numeric_candidates[:8]],
|
||||||
|
*[candidate.entity_id for candidate in record.context_candidates[:12]],
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
configured_ids = {record.actuator_entity_id for record in records}
|
||||||
|
rooms: dict[str, RoomManagementGroup] = {}
|
||||||
|
for record in records:
|
||||||
|
actuator = entities.get(record.actuator_entity_id)
|
||||||
|
room = (
|
||||||
|
actuator.area_name
|
||||||
|
if actuator is not None and actuator.area_name
|
||||||
|
else _candidate_room(record)
|
||||||
|
) or "Ohne Raum"
|
||||||
|
selected_context_ids = set(record.assignment.selected_context_entity_ids)
|
||||||
|
selected_numeric_id = record.assignment.selected_numeric_entity_id
|
||||||
|
selected_ids = {selected_numeric_id, *selected_context_ids} - {None}
|
||||||
|
ranked_candidates = _rank_management_candidates(record)
|
||||||
|
has_opening_context = any(
|
||||||
|
candidate.entity_id in selected_context_ids
|
||||||
|
and (candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
|
||||||
|
for candidate in ranked_candidates
|
||||||
|
)
|
||||||
|
sensors = [
|
||||||
|
_management_sensor(
|
||||||
|
candidate,
|
||||||
|
entities.get(candidate.entity_id),
|
||||||
|
active=candidate.entity_id in selected_ids,
|
||||||
|
optional=(
|
||||||
|
candidate.role is EntityRole.MEASUREMENT
|
||||||
|
and candidate.entity_id != selected_numeric_id
|
||||||
|
),
|
||||||
|
not_required=(
|
||||||
|
record.actuator_entity_id.startswith(("light.", "switch."))
|
||||||
|
and has_opening_context
|
||||||
|
and (candidate.device_class or "") == "illuminance"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for candidate in ranked_candidates[:12]
|
||||||
|
]
|
||||||
|
actuator_group = RoomManagementActuator(
|
||||||
|
actuator_entity_id=record.actuator_entity_id,
|
||||||
|
friendly_name=actuator.friendly_name if actuator is not None else None,
|
||||||
|
domain=record.actuator_entity_id.split(".", 1)[0],
|
||||||
|
behavior_mode=record.behavior.mode.value,
|
||||||
|
behavior_status=record.behavior.status.value,
|
||||||
|
lifecycle_status=record.lifecycle.status.value,
|
||||||
|
sample_count=record.behavior.sample_count,
|
||||||
|
selected_numeric_entity_id=selected_numeric_id,
|
||||||
|
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
|
||||||
|
sensors=sensors,
|
||||||
|
prediction_rules=_prediction_rule_lines(record, ranked_candidates),
|
||||||
|
management_hint=_management_hint(record, has_opening_context),
|
||||||
|
)
|
||||||
|
if room not in rooms:
|
||||||
|
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
|
||||||
|
rooms[room].actuators.append(actuator_group)
|
||||||
|
rooms[room].actuator_count += 1
|
||||||
|
rooms[room].prediction_rules = _unique_lines([
|
||||||
|
*rooms[room].prediction_rules,
|
||||||
|
*actuator_group.prediction_rules,
|
||||||
|
])[:8]
|
||||||
|
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors)
|
||||||
|
unmanaged = [
|
||||||
|
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
|
||||||
|
if suggestion.entity_id not in configured_ids
|
||||||
|
][:10]
|
||||||
|
return RoomManagementOverview(
|
||||||
|
rooms=sorted(rooms.values(), key=lambda item: item.room.lower()),
|
||||||
|
unmanaged_actuators=unmanaged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backup/export", response_model=BackupPayload)
|
||||||
|
def export_backup(request: Request) -> BackupPayload:
|
||||||
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
|
if not isinstance(store, ActuatorStore):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Actuator Store nicht initialisiert.",
|
||||||
|
)
|
||||||
|
return BackupPayload(
|
||||||
|
records=store.list(),
|
||||||
|
reconciliation=store.load_reconciliation_state(),
|
||||||
|
jobs=store.load_job_queue(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backup/restore", response_model=RestoreResult)
|
||||||
|
def restore_backup(payload: RestoreRequest, request: Request) -> RestoreResult:
|
||||||
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
|
if not isinstance(store, ActuatorStore):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Actuator Store nicht initialisiert.",
|
||||||
|
)
|
||||||
|
existing_ids = {record.actuator_entity_id for record in store.list()}
|
||||||
|
restored = 0
|
||||||
|
skipped = 0
|
||||||
|
for record in payload.backup.records:
|
||||||
|
if record.actuator_entity_id in existing_ids and not payload.replace_existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
store.upsert(record)
|
||||||
|
restored += 1
|
||||||
|
store.save_reconciliation_state(payload.backup.reconciliation)
|
||||||
|
store.save_job_queue(payload.backup.jobs)
|
||||||
|
return RestoreResult(restored_records=restored, skipped_existing=skipped)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/planning/refresh", response_model=list[ActuatorRecord])
|
||||||
|
def refresh_planning_insights(request: Request) -> list[ActuatorRecord]:
|
||||||
|
return _behavior(request).refresh_planning_insights()
|
||||||
|
|
||||||
|
|
||||||
@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()
|
||||||
@@ -408,6 +647,47 @@ def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord:
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{actuator_entity_id}/detail", response_model=ActuatorRecord)
|
||||||
|
def get_actuator_detail(actuator_entity_id: str, request: Request) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
record = _service(request).get_actuator(actuator_entity_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
selected_ids = {
|
||||||
|
entity_id
|
||||||
|
for entity_id in [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
}
|
||||||
|
compact_snapshots = [
|
||||||
|
snapshot.model_copy(update={"patterns": []})
|
||||||
|
for snapshot in record.behavior.model_snapshots[-3:]
|
||||||
|
]
|
||||||
|
compact_behavior = record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"patterns": [],
|
||||||
|
"model_snapshots": compact_snapshots,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return record.model_copy(
|
||||||
|
update={
|
||||||
|
"behavior": compact_behavior,
|
||||||
|
"numeric_candidates": [
|
||||||
|
candidate
|
||||||
|
for candidate in record.numeric_candidates
|
||||||
|
if candidate.entity_id in selected_ids
|
||||||
|
],
|
||||||
|
"context_candidates": [
|
||||||
|
candidate
|
||||||
|
for candidate in record.context_candidates
|
||||||
|
if candidate.entity_id in selected_ids
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{actuator_entity_id}", status_code=204)
|
@router.delete("/{actuator_entity_id}", status_code=204)
|
||||||
def delete_actuator(actuator_entity_id: str, request: Request) -> None:
|
def delete_actuator(actuator_entity_id: str, request: Request) -> None:
|
||||||
_service(request).delete_actuator(actuator_entity_id)
|
_service(request).delete_actuator(actuator_entity_id)
|
||||||
@@ -437,6 +717,28 @@ 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}/simulate", response_model=list[SimulationOutcome])
|
||||||
|
def simulate_actuator(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: SimulationRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> list[SimulationOutcome]:
|
||||||
|
try:
|
||||||
|
_validate_simulation_payload(payload)
|
||||||
|
return _behavior(request).simulate(
|
||||||
|
actuator_entity_id,
|
||||||
|
sensor_states=payload.sensor_states,
|
||||||
|
sensor_weights=payload.sensor_weights,
|
||||||
|
state_options=payload.state_options,
|
||||||
|
include_current=payload.include_current,
|
||||||
|
max_results=payload.max_results,
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
|
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
|
||||||
def record_feedback(
|
def record_feedback(
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -448,11 +750,24 @@ def record_feedback(
|
|||||||
actuator_entity_id,
|
actuator_entity_id,
|
||||||
correct=payload.correct,
|
correct=payload.correct,
|
||||||
expected_state=payload.expected_state,
|
expected_state=payload.expected_state,
|
||||||
|
kind=payload.kind,
|
||||||
)
|
)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/dry-run", response_model=ActuatorRecord)
|
||||||
|
def set_dry_run(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: DryRunRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
return _behavior(request).set_dry_run(actuator_entity_id, enabled=payload.enabled)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
|
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
|
||||||
def set_safety_profile(
|
def set_safety_profile(
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -742,6 +1057,236 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
|
|||||||
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_room(record: ActuatorRecord) -> str | None:
|
||||||
|
for candidate in [*record.context_candidates, *record.numeric_candidates]:
|
||||||
|
if candidate.area_name:
|
||||||
|
return candidate.area_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_management_candidates(record: ActuatorRecord) -> list[AssignmentCandidate]:
|
||||||
|
selected_ids = {
|
||||||
|
entity_id
|
||||||
|
for entity_id in [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
}
|
||||||
|
candidates = {
|
||||||
|
candidate.entity_id: candidate
|
||||||
|
for candidate in [*record.context_candidates, *record.numeric_candidates]
|
||||||
|
}
|
||||||
|
ranked = sorted(
|
||||||
|
candidates.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
item.entity_id not in selected_ids,
|
||||||
|
_management_sort_group(item),
|
||||||
|
-item.confidence,
|
||||||
|
-item.score,
|
||||||
|
item.entity_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def _management_sort_group(candidate: AssignmentCandidate) -> str:
|
||||||
|
device_class = candidate.device_class or ""
|
||||||
|
if device_class in {"door", "garage_door", "opening", "window"}:
|
||||||
|
return "01_opening"
|
||||||
|
if device_class in {"motion", "occupancy", "presence"}:
|
||||||
|
return "02_presence"
|
||||||
|
if device_class == "illuminance":
|
||||||
|
return "03_brightness"
|
||||||
|
if device_class in {"humidity", "moisture"}:
|
||||||
|
return "04_humidity"
|
||||||
|
if candidate.role is EntityRole.MEASUREMENT:
|
||||||
|
return "08_measurement"
|
||||||
|
return f"20_{candidate.domain}_{device_class}"
|
||||||
|
|
||||||
|
|
||||||
|
def _management_sensor(
|
||||||
|
candidate: AssignmentCandidate,
|
||||||
|
entity: HaEntitySummary | None,
|
||||||
|
*,
|
||||||
|
active: bool,
|
||||||
|
optional: bool,
|
||||||
|
not_required: bool,
|
||||||
|
) -> RoomManagementSensor:
|
||||||
|
if not_required:
|
||||||
|
reason = "Nicht nötig, weil ein Tür-/Öffnungskontakt die Lichtlogik direkt erklärt."
|
||||||
|
elif active:
|
||||||
|
reason = "Wird aktuell für Lernen und Vorhersage verwendet."
|
||||||
|
elif optional:
|
||||||
|
reason = "Optionaler Messwert; nur verwenden, wenn Helligkeit oder Verbrauch wirklich steuern soll."
|
||||||
|
else:
|
||||||
|
reason = ", ".join(candidate.evidence[:2]) or "Naheliegender Kontext aus Raum, Gerät oder Namen."
|
||||||
|
return RoomManagementSensor(
|
||||||
|
entity_id=candidate.entity_id,
|
||||||
|
domain=candidate.domain,
|
||||||
|
role=candidate.role.value,
|
||||||
|
category=_sensor_category_label(candidate),
|
||||||
|
friendly_name=candidate.friendly_name,
|
||||||
|
device_class=candidate.device_class,
|
||||||
|
state=entity.state if entity is not None else None,
|
||||||
|
confidence=candidate.confidence,
|
||||||
|
active=active,
|
||||||
|
optional=optional,
|
||||||
|
not_required=not_required,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_category_label(candidate: AssignmentCandidate) -> str:
|
||||||
|
device_class = candidate.device_class or ""
|
||||||
|
if device_class in {"door", "garage_door", "opening", "window"}:
|
||||||
|
return "Tür/Fenster"
|
||||||
|
if device_class in {"motion", "occupancy", "presence"}:
|
||||||
|
return "Präsenz"
|
||||||
|
if device_class == "illuminance":
|
||||||
|
return "Helligkeit"
|
||||||
|
if device_class in {"humidity", "moisture"}:
|
||||||
|
return "Luftfeuchtigkeit"
|
||||||
|
if device_class in {"power", "energy", "current", "voltage"}:
|
||||||
|
return "Energie"
|
||||||
|
if candidate.domain in {"cover"}:
|
||||||
|
return "Rollo/Cover"
|
||||||
|
if candidate.domain in {"zone", "person", "device_tracker"}:
|
||||||
|
return "Zone/Person"
|
||||||
|
return "Kontext"
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_room_sensors(
|
||||||
|
existing: list[RoomManagementSensor],
|
||||||
|
incoming: list[RoomManagementSensor],
|
||||||
|
) -> list[RoomManagementSensor]:
|
||||||
|
by_id = {sensor.entity_id: sensor for sensor in existing}
|
||||||
|
for sensor in incoming:
|
||||||
|
current = by_id.get(sensor.entity_id)
|
||||||
|
if current is None:
|
||||||
|
by_id[sensor.entity_id] = sensor
|
||||||
|
continue
|
||||||
|
by_id[sensor.entity_id] = current.model_copy(
|
||||||
|
update={
|
||||||
|
"active": current.active or sensor.active,
|
||||||
|
"optional": current.optional and sensor.optional,
|
||||||
|
"not_required": current.not_required and sensor.not_required,
|
||||||
|
"confidence": max(current.confidence, sensor.confidence),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
by_id.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
not item.active,
|
||||||
|
item.not_required,
|
||||||
|
item.category,
|
||||||
|
item.friendly_name or item.entity_id,
|
||||||
|
),
|
||||||
|
)[:18]
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_rule_lines(
|
||||||
|
record: ActuatorRecord,
|
||||||
|
candidates: list[AssignmentCandidate],
|
||||||
|
) -> list[str]:
|
||||||
|
lines = _pattern_rule_lines(record.behavior.patterns)
|
||||||
|
if lines:
|
||||||
|
return lines[:8]
|
||||||
|
selected_contexts = [
|
||||||
|
candidate
|
||||||
|
for candidate in candidates
|
||||||
|
if candidate.entity_id in set(record.assignment.selected_context_entity_ids)
|
||||||
|
]
|
||||||
|
result: list[str] = []
|
||||||
|
for candidate in selected_contexts:
|
||||||
|
label = candidate.friendly_name or candidate.entity_id
|
||||||
|
device_class = candidate.device_class or ""
|
||||||
|
if device_class in {"door", "garage_door", "opening", "window"}:
|
||||||
|
result.extend([
|
||||||
|
f"{label} geöffnet -> {record.actuator_entity_id} an.",
|
||||||
|
f"{label} geschlossen -> {record.actuator_entity_id} aus.",
|
||||||
|
])
|
||||||
|
elif device_class in {"motion", "occupancy", "presence"}:
|
||||||
|
result.extend([
|
||||||
|
f"{label} erkannt -> {record.actuator_entity_id} an, bei Licht bevorzugt gedimmt.",
|
||||||
|
f"{label} aus -> {record.actuator_entity_id} verzögert ausschalten.",
|
||||||
|
])
|
||||||
|
elif device_class in {"humidity", "moisture"}:
|
||||||
|
result.append(f"{label} hoch -> {record.actuator_entity_id} einschalten, bis Feuchte wieder normal ist.")
|
||||||
|
if record.assignment.selected_numeric_entity_id:
|
||||||
|
result.append(
|
||||||
|
f"{record.assignment.selected_numeric_entity_id} nur als Messwert verwenden, nicht als Pflichtsensor."
|
||||||
|
)
|
||||||
|
return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."]
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_rule_lines(patterns: list[BehaviorPattern]) -> list[str]:
|
||||||
|
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
|
||||||
|
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
|
||||||
|
for pattern in patterns[-120:]:
|
||||||
|
context = tuple(sorted(pattern.context_states.items()))
|
||||||
|
key = (pattern.target_state, context)
|
||||||
|
buckets[key] = buckets.get(key, 0) + 1
|
||||||
|
attrs[key] = pattern.target_attributes
|
||||||
|
ordered = sorted(buckets.items(), key=lambda item: (-item[1], item[0]))
|
||||||
|
lines: list[str] = []
|
||||||
|
for (target_state, context), count in ordered[:8]:
|
||||||
|
conditions = ", ".join(f"{entity}={state}" for entity, state in context[:3])
|
||||||
|
if not conditions:
|
||||||
|
conditions = "aktueller Zeit-/Nutzungskontext passt"
|
||||||
|
attr_text = _attribute_text(attrs.get((target_state, context), {}))
|
||||||
|
lines.append(f"{conditions} -> {target_state}{attr_text} ({count}x gelernt).")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _attribute_text(attributes: dict[str, object]) -> str:
|
||||||
|
if not attributes:
|
||||||
|
return ""
|
||||||
|
brightness = attributes.get("brightness")
|
||||||
|
if isinstance(brightness, int | float):
|
||||||
|
percent = round(max(0, min(255, float(brightness))) / 255 * 100)
|
||||||
|
return f", Helligkeit {percent} %"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _management_hint(record: ActuatorRecord, has_opening_context: bool) -> str:
|
||||||
|
if has_opening_context and record.actuator_entity_id.startswith(("light.", "switch.")):
|
||||||
|
return "Direkte Türlogik: kein Helligkeitssensor nötig, Sensor und Aktor reichen."
|
||||||
|
if record.behavior.activation_ready:
|
||||||
|
return "Regeln sind lernbereit; vor Aktivierung Vorhersagen prüfen."
|
||||||
|
if record.assignment.review_required:
|
||||||
|
return "Kontext prüfen: Vorschläge übernehmen oder unpassende Sensoren entfernen."
|
||||||
|
return "Weiter beobachten, bis genug eindeutige Schaltbeispiele vorhanden sind."
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_lines(lines: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
normalized = line.strip()
|
||||||
|
if not normalized or normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
result.append(normalized)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_simulation_payload(payload: SimulationRequest) -> None:
|
||||||
|
for entity_id in [
|
||||||
|
*payload.sensor_states.keys(),
|
||||||
|
*payload.sensor_weights.keys(),
|
||||||
|
*payload.state_options.keys(),
|
||||||
|
]:
|
||||||
|
if "." not in entity_id:
|
||||||
|
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
|
||||||
|
for entity_id, weight in payload.sensor_weights.items():
|
||||||
|
if not 0.0 <= weight <= 1.0:
|
||||||
|
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
|
||||||
|
for entity_id, states in payload.state_options.items():
|
||||||
|
if not states:
|
||||||
|
raise ValueError(f"Keine Zustände für {entity_id} angegeben.")
|
||||||
|
|
||||||
|
|
||||||
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
|
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
|
||||||
store = getattr(request.app.state, "actuator_store", None)
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
if not isinstance(store, ActuatorStore):
|
if not isinstance(store, ActuatorStore):
|
||||||
@@ -781,6 +1326,11 @@ def _load_cached_entity_map(
|
|||||||
) -> dict[str, HaEntitySummary]:
|
) -> dict[str, HaEntitySummary]:
|
||||||
if not entity_ids:
|
if not entity_ids:
|
||||||
return {}
|
return {}
|
||||||
|
cache = getattr(request.app.state, "dashboard_cache", None)
|
||||||
|
if isinstance(cache, DashboardCache):
|
||||||
|
cached_result = cache.load_entity_map(entity_ids)
|
||||||
|
if cached_result:
|
||||||
|
return cached_result
|
||||||
payload = _load_entity_cache_payload(request)
|
payload = _load_entity_cache_payload(request)
|
||||||
raw_entities = payload.get("entities", [])
|
raw_entities = payload.get("entities", [])
|
||||||
if not isinstance(raw_entities, list):
|
if not isinstance(raw_entities, list):
|
||||||
@@ -799,7 +1349,35 @@ def _load_cached_entity_map(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _load_entity_cache_status(request: Request) -> dict[str, object]:
|
||||||
|
cache = getattr(request.app.state, "dashboard_cache", None)
|
||||||
|
if isinstance(cache, DashboardCache):
|
||||||
|
status_payload = cache.load_status()
|
||||||
|
if status_payload.get("entity_count"):
|
||||||
|
return status_payload
|
||||||
|
path = _entity_cache_path(request)
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, TypeError, ValueError):
|
||||||
|
return {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
raw_entities = payload.get("entities", [])
|
||||||
|
return {
|
||||||
|
"updated_at": payload.get("updated_at"),
|
||||||
|
"discovery_groups": payload.get("discovery_groups", []),
|
||||||
|
"entity_count": len(raw_entities) if isinstance(raw_entities, list) else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
|
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
|
||||||
|
cache = getattr(request.app.state, "dashboard_cache", None)
|
||||||
|
if isinstance(cache, DashboardCache):
|
||||||
|
payload = cache.load_entities_payload()
|
||||||
|
if payload.get("entities"):
|
||||||
|
return payload
|
||||||
path = _entity_cache_path(request)
|
path = _entity_cache_path(request)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {}
|
return {}
|
||||||
@@ -811,18 +1389,18 @@ def _load_entity_cache_payload(request: Request) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
|
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
|
||||||
|
group_payload = _discovery_group_payload(entities)
|
||||||
|
cache = getattr(request.app.state, "dashboard_cache", None)
|
||||||
|
if isinstance(cache, DashboardCache):
|
||||||
|
cache.save_entities_payload(
|
||||||
|
entities=entities,
|
||||||
|
discovery_groups=group_payload,
|
||||||
|
)
|
||||||
path = _entity_cache_path(request)
|
path = _entity_cache_path(request)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
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 = {
|
payload = {
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"discovery_groups": [
|
"discovery_groups": group_payload,
|
||||||
{"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],
|
"entities": [entity.model_dump(mode="json") for entity in entities],
|
||||||
}
|
}
|
||||||
temporary = path.with_suffix(".json.tmp")
|
temporary = path.with_suffix(".json.tmp")
|
||||||
@@ -833,6 +1411,17 @@ def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) ->
|
|||||||
os.replace(temporary, path)
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
|
||||||
|
group_counts: dict[tuple[str, str], int] = {}
|
||||||
|
for entity in discover_entities(entities):
|
||||||
|
key = (entity.category, entity.role.value)
|
||||||
|
group_counts[key] = group_counts.get(key, 0) + 1
|
||||||
|
return [
|
||||||
|
{"category": category, "role": role, "count": count}
|
||||||
|
for (category, role), count in sorted(group_counts.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _deduplicate_actuator_ids(
|
def _deduplicate_actuator_ids(
|
||||||
discovered: list[tuple[str, str]],
|
discovered: list[tuple[str, str]],
|
||||||
entities: dict[str, HaEntitySummary],
|
entities: dict[str, HaEntitySummary],
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from itertools import product
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from time import perf_counter
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from app.actuators.models import (
|
from app.actuators.models import (
|
||||||
ActuatorRecord,
|
ActuatorRecord,
|
||||||
AdaptiveWeightUpdate,
|
AdaptiveWeightUpdate,
|
||||||
|
AgentInsight,
|
||||||
AnomalyEvent,
|
AnomalyEvent,
|
||||||
|
ActuatorGroup,
|
||||||
AutomationConflict,
|
AutomationConflict,
|
||||||
BehaviorMode,
|
BehaviorMode,
|
||||||
BehaviorPattern,
|
BehaviorPattern,
|
||||||
@@ -16,12 +20,17 @@ from app.actuators.models import (
|
|||||||
BehaviorState,
|
BehaviorState,
|
||||||
BehaviorStatus,
|
BehaviorStatus,
|
||||||
DecisionFactor,
|
DecisionFactor,
|
||||||
|
DecisionTrace,
|
||||||
ExecutionEvent,
|
ExecutionEvent,
|
||||||
|
FeedbackKind,
|
||||||
|
LatencyMeasurement,
|
||||||
ManualOverride,
|
ManualOverride,
|
||||||
ModelSnapshot,
|
ModelSnapshot,
|
||||||
RelatedAutomation,
|
RelatedAutomation,
|
||||||
SafetyProfile,
|
SafetyProfile,
|
||||||
SafetyStage,
|
SafetyStage,
|
||||||
|
SceneSuggestion,
|
||||||
|
SimulationOutcome,
|
||||||
TimeProfile,
|
TimeProfile,
|
||||||
)
|
)
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
@@ -32,12 +41,34 @@ from app.ha.models import HaEntitySummary
|
|||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
|
|
||||||
_MAX_PATTERNS = 500
|
_MAX_PATTERNS = 500
|
||||||
|
_MAX_MODEL_SNAPSHOTS = 3
|
||||||
|
_MAX_SNAPSHOT_PATTERNS = 120
|
||||||
_MAX_EXECUTION_EVENTS = 100
|
_MAX_EXECUTION_EVENTS = 100
|
||||||
|
_MAX_DECISION_TRACES = 30
|
||||||
|
_MAX_LATENCY_MEASUREMENTS = 50
|
||||||
|
_MAX_FEEDBACK_LOG = 50
|
||||||
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
||||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
_CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
|
||||||
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
||||||
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
|
_SAFE_ACTIVE_DOMAINS = frozenset({
|
||||||
|
"button",
|
||||||
|
"cover",
|
||||||
|
"fan",
|
||||||
|
"humidifier",
|
||||||
|
"input_button",
|
||||||
|
"light",
|
||||||
|
"switch",
|
||||||
|
})
|
||||||
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
|
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
|
||||||
|
_LIGHT_TARGET_ATTRIBUTES = frozenset({
|
||||||
|
"brightness",
|
||||||
|
"color_temp",
|
||||||
|
"color_temp_kelvin",
|
||||||
|
"effect",
|
||||||
|
"hs_color",
|
||||||
|
"rgb_color",
|
||||||
|
"xy_color",
|
||||||
|
})
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,7 +283,11 @@ class BehaviorEngine:
|
|||||||
context_state_overrides: dict[str, str | None] | None = None,
|
context_state_overrides: dict[str, str | None] | None = None,
|
||||||
context_changed_at_overrides: dict[str, datetime | None] | None = None,
|
context_changed_at_overrides: dict[str, datetime | None] | None = None,
|
||||||
current_entities: Sequence[HaEntitySummary] | None = None,
|
current_entities: Sequence[HaEntitySummary] | None = None,
|
||||||
|
trigger_entity_id: str | None = None,
|
||||||
|
trigger_state: str | None = None,
|
||||||
|
event_received_at: datetime | None = None,
|
||||||
) -> ActuatorRecord:
|
) -> ActuatorRecord:
|
||||||
|
started_perf = perf_counter()
|
||||||
record = self._store.get(actuator_entity_id)
|
record = self._store.get(actuator_entity_id)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if current_entities is None:
|
if current_entities is None:
|
||||||
@@ -317,10 +352,11 @@ class BehaviorEngine:
|
|||||||
record.behavior.patterns,
|
record.behavior.patterns,
|
||||||
current_context=current_context,
|
current_context=current_context,
|
||||||
current_context_changed_at=current_context_changed_at,
|
current_context_changed_at=current_context_changed_at,
|
||||||
|
context_weights=_context_weights_for(record),
|
||||||
now=now,
|
now=now,
|
||||||
min_support=self._settings.min_behavior_actions,
|
min_support=self._settings.min_behavior_actions,
|
||||||
window_minutes=self._settings.prediction_window_minutes,
|
window_minutes=self._settings.prediction_window_minutes,
|
||||||
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
|
causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240),
|
||||||
timezone_name=self._settings.timezone,
|
timezone_name=self._settings.timezone,
|
||||||
)
|
)
|
||||||
if prediction is not None:
|
if prediction is not None:
|
||||||
@@ -342,6 +378,7 @@ class BehaviorEngine:
|
|||||||
else:
|
else:
|
||||||
safety_allowed = False
|
safety_allowed = False
|
||||||
safety_blockers = ["Keine fällige Vorhersage."]
|
safety_blockers = ["Keine fällige Vorhersage."]
|
||||||
|
decision_to_service_ms: int | None = None
|
||||||
decision_factors = _decision_factors_for(record, current_context, prediction)
|
decision_factors = _decision_factors_for(record, current_context, prediction)
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={
|
update={
|
||||||
@@ -381,12 +418,51 @@ class BehaviorEngine:
|
|||||||
domain = actuator_entity_id.split(".", 1)[0]
|
domain = actuator_entity_id.split(".", 1)[0]
|
||||||
service = service_for_state(domain, prediction.target_state)
|
service = service_for_state(domain, prediction.target_state)
|
||||||
if service is not None:
|
if service is not None:
|
||||||
|
if record.behavior.dry_run_enabled:
|
||||||
|
behavior = behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"prediction": prediction.model_copy(
|
||||||
|
update={
|
||||||
|
"executed": False,
|
||||||
|
"execution_reason": (
|
||||||
|
"Dry-run: Aktion wäre ausgeführt worden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"dry_run_sample_count": record.behavior.dry_run_sample_count + 1,
|
||||||
|
"reason": (
|
||||||
|
f"Dry-run hätte {prediction.target_state!r} mit "
|
||||||
|
f"{prediction.confidence:.0%} Sicherheit ausgeführt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._save_behavior(
|
||||||
|
record,
|
||||||
|
_append_decision_trace(
|
||||||
|
behavior,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
prediction=prediction,
|
||||||
|
safety_blockers=safety_blockers,
|
||||||
|
duration_ms=_elapsed_ms(started_perf),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
decision_to_service_ms=None,
|
||||||
|
executed=False,
|
||||||
|
source="event" if event_received_at is not None else "manual",
|
||||||
|
),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
service_started_perf = perf_counter()
|
||||||
self._ha_reader.call_service(
|
self._ha_reader.call_service(
|
||||||
domain,
|
domain,
|
||||||
service,
|
service,
|
||||||
{"entity_id": actuator_entity_id},
|
_service_data_for_prediction(
|
||||||
|
actuator_entity_id,
|
||||||
|
domain,
|
||||||
|
prediction,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
decision_to_service_ms = _elapsed_ms(service_started_perf)
|
||||||
except (HaClientError, ValueError) as exc:
|
except (HaClientError, ValueError) as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Predicted action failed for %s: %s",
|
"Predicted action failed for %s: %s",
|
||||||
@@ -398,7 +474,21 @@ class BehaviorEngine:
|
|||||||
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
|
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(
|
||||||
|
record,
|
||||||
|
_append_decision_trace(
|
||||||
|
behavior,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
prediction=prediction,
|
||||||
|
safety_blockers=[str(exc)],
|
||||||
|
duration_ms=_elapsed_ms(started_perf),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
decision_to_service_ms=None,
|
||||||
|
executed=False,
|
||||||
|
source="event" if event_received_at is not None else "manual",
|
||||||
|
),
|
||||||
|
)
|
||||||
event = ExecutionEvent(
|
event = ExecutionEvent(
|
||||||
target_state=prediction.target_state,
|
target_state=prediction.target_state,
|
||||||
executed_at=now,
|
executed_at=now,
|
||||||
@@ -432,14 +522,139 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
behavior = _append_decision_trace(
|
||||||
|
behavior,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
prediction=prediction,
|
||||||
|
safety_blockers=safety_blockers,
|
||||||
|
duration_ms=_elapsed_ms(started_perf),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
decision_to_service_ms=(
|
||||||
|
decision_to_service_ms
|
||||||
|
),
|
||||||
|
executed=bool(prediction is not None and behavior.prediction is not None and behavior.prediction.executed),
|
||||||
|
source="event" if event_received_at is not None else "manual",
|
||||||
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(record, behavior)
|
||||||
|
|
||||||
|
def simulate(
|
||||||
|
self,
|
||||||
|
actuator_entity_id: str,
|
||||||
|
*,
|
||||||
|
sensor_states: dict[str, str],
|
||||||
|
sensor_weights: dict[str, float],
|
||||||
|
state_options: dict[str, list[str]],
|
||||||
|
max_results: int,
|
||||||
|
include_current: bool = True,
|
||||||
|
) -> list[SimulationOutcome]:
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
current_entities = self._ha_reader.read_entities()
|
||||||
|
entities = {entity.entity_id: entity for entity in current_entities}
|
||||||
|
actuator = entities.get(actuator_entity_id)
|
||||||
|
if actuator is None:
|
||||||
|
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
|
||||||
|
selected_context_ids = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
]
|
||||||
|
if not selected_context_ids:
|
||||||
|
return []
|
||||||
|
base_context = {
|
||||||
|
entity_id: entities[entity_id].state
|
||||||
|
for entity_id in selected_context_ids
|
||||||
|
if entity_id in entities and entities[entity_id].state is not None
|
||||||
|
}
|
||||||
|
base_changed_at = {
|
||||||
|
entity_id: entities[entity_id].last_changed
|
||||||
|
for entity_id in base_context
|
||||||
|
}
|
||||||
|
context_weights = _context_weights_for(record)
|
||||||
|
for entity_id, weight in sensor_weights.items():
|
||||||
|
if entity_id in selected_context_ids:
|
||||||
|
context_weights[entity_id] = max(0.0, min(1.0, weight))
|
||||||
|
scenarios = _simulation_contexts(
|
||||||
|
base_context,
|
||||||
|
sensor_states=sensor_states,
|
||||||
|
state_options=state_options,
|
||||||
|
selected_context_ids=selected_context_ids,
|
||||||
|
include_current=include_current,
|
||||||
|
)
|
||||||
|
outcomes: list[SimulationOutcome] = []
|
||||||
|
for index, context in enumerate(scenarios[:64], start=1):
|
||||||
|
prediction_context: dict[str, str | None] = dict(context)
|
||||||
|
changed_at = dict(base_changed_at)
|
||||||
|
for entity_id, state in context.items():
|
||||||
|
if base_context.get(entity_id) != state:
|
||||||
|
changed_at[entity_id] = now
|
||||||
|
prediction = predict_behavior(
|
||||||
|
record.behavior.patterns,
|
||||||
|
current_context=prediction_context,
|
||||||
|
current_context_changed_at=changed_at,
|
||||||
|
context_weights=context_weights,
|
||||||
|
now=now,
|
||||||
|
min_support=self._settings.min_behavior_actions,
|
||||||
|
window_minutes=self._settings.prediction_window_minutes,
|
||||||
|
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
|
||||||
|
timezone_name=self._settings.timezone,
|
||||||
|
)
|
||||||
|
if prediction is not None:
|
||||||
|
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
|
||||||
|
recommendation = (
|
||||||
|
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
|
||||||
|
if would_execute
|
||||||
|
else (
|
||||||
|
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
|
||||||
|
"aber blockiert: " + " ".join(blockers)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
would_execute = False
|
||||||
|
blockers = ["Keine fällige Vorhersage."]
|
||||||
|
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
|
||||||
|
outcomes.append(
|
||||||
|
SimulationOutcome(
|
||||||
|
scenario_id=f"scenario-{index}",
|
||||||
|
actuator_entity_id=actuator_entity_id,
|
||||||
|
sensor_states=context,
|
||||||
|
sensor_weights={
|
||||||
|
entity_id: round(context_weights.get(entity_id, 1.0), 4)
|
||||||
|
for entity_id in context
|
||||||
|
},
|
||||||
|
prediction=prediction,
|
||||||
|
decision_factors=_decision_factors_for(
|
||||||
|
record,
|
||||||
|
prediction_context,
|
||||||
|
prediction,
|
||||||
|
context_weights=context_weights,
|
||||||
|
),
|
||||||
|
would_execute=would_execute,
|
||||||
|
blockers=blockers,
|
||||||
|
score=round(prediction.confidence if prediction is not None else 0.0, 4),
|
||||||
|
recommendation=recommendation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
outcomes,
|
||||||
|
key=lambda item: (
|
||||||
|
item.prediction is None,
|
||||||
|
-item.score,
|
||||||
|
item.scenario_id,
|
||||||
|
),
|
||||||
|
)[:max_results]
|
||||||
|
|
||||||
def record_feedback(
|
def record_feedback(
|
||||||
self,
|
self,
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
*,
|
*,
|
||||||
correct: bool,
|
correct: bool,
|
||||||
expected_state: str | None = None,
|
expected_state: str | None = None,
|
||||||
|
kind: FeedbackKind | None = None,
|
||||||
) -> ActuatorRecord:
|
) -> 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)
|
||||||
@@ -483,6 +698,7 @@ class BehaviorEngine:
|
|||||||
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
|
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
|
||||||
correct_count = record.behavior.correct_feedback_count + 1
|
correct_count = record.behavior.correct_feedback_count + 1
|
||||||
incorrect_count = record.behavior.incorrect_feedback_count
|
incorrect_count = record.behavior.incorrect_feedback_count
|
||||||
|
feedback_kind = kind or FeedbackKind.CORRECT
|
||||||
else:
|
else:
|
||||||
target = prediction.target_state if prediction is not None else None
|
target = prediction.target_state if prediction is not None else None
|
||||||
if target:
|
if target:
|
||||||
@@ -513,11 +729,24 @@ class BehaviorEngine:
|
|||||||
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
||||||
correct_count = record.behavior.correct_feedback_count
|
correct_count = record.behavior.correct_feedback_count
|
||||||
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
||||||
|
feedback_kind = kind or FeedbackKind.WRONG
|
||||||
|
if feedback_kind is FeedbackKind.NEVER_AUTOMATE:
|
||||||
|
safety = record.behavior.safety.model_copy(
|
||||||
|
update={
|
||||||
|
"manual_block": True,
|
||||||
|
"updated_at": now,
|
||||||
|
"note": "Durch Nutzerfeedback dauerhaft blockiert.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
safety = record.behavior.safety
|
||||||
adaptive_updates, manual_override = _adapt_sensor_weights(
|
adaptive_updates, manual_override = _adapt_sensor_weights(
|
||||||
record,
|
record,
|
||||||
current_context,
|
current_context,
|
||||||
correct=correct,
|
correct=correct,
|
||||||
)
|
)
|
||||||
|
if correct and prediction is not None:
|
||||||
|
safety = record.behavior.safety
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={
|
update={
|
||||||
"patterns": patterns[-_MAX_PATTERNS:],
|
"patterns": patterns[-_MAX_PATTERNS:],
|
||||||
@@ -530,6 +759,11 @@ class BehaviorEngine:
|
|||||||
"last_trained_at": now,
|
"last_trained_at": now,
|
||||||
"correct_feedback_count": correct_count,
|
"correct_feedback_count": correct_count,
|
||||||
"incorrect_feedback_count": incorrect_count,
|
"incorrect_feedback_count": incorrect_count,
|
||||||
|
"feedback_log": [
|
||||||
|
*record.behavior.feedback_log,
|
||||||
|
feedback_kind,
|
||||||
|
][-_MAX_FEEDBACK_LOG:],
|
||||||
|
"safety": safety,
|
||||||
"adaptive_weight_updates": [
|
"adaptive_weight_updates": [
|
||||||
*record.behavior.adaptive_weight_updates,
|
*record.behavior.adaptive_weight_updates,
|
||||||
*adaptive_updates,
|
*adaptive_updates,
|
||||||
@@ -555,6 +789,43 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
return self._save_behavior(record_for_save, behavior)
|
return self._save_behavior(record_for_save, behavior)
|
||||||
|
|
||||||
|
def set_dry_run(self, actuator_entity_id: str, *, enabled: bool) -> ActuatorRecord:
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
behavior = record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"dry_run_enabled": enabled,
|
||||||
|
"dry_run_started_at": now if enabled else record.behavior.dry_run_started_at,
|
||||||
|
"reason": (
|
||||||
|
"Dry-run aktiv; freigegebene Aktionen werden protokolliert, aber nicht geschaltet."
|
||||||
|
if enabled
|
||||||
|
else "Dry-run beendet."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._save_behavior(record, behavior)
|
||||||
|
|
||||||
|
def refresh_planning_insights(self) -> list[ActuatorRecord]:
|
||||||
|
records = self._store.list()
|
||||||
|
groups = _derive_actuator_groups(records)
|
||||||
|
scenes = _derive_scene_suggestions(records)
|
||||||
|
insights_by_actuator = _derive_agent_insights(records)
|
||||||
|
updated: list[ActuatorRecord] = []
|
||||||
|
for record in records:
|
||||||
|
behavior = record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"actuator_groups": [
|
||||||
|
group for group in groups if record.actuator_entity_id in group.member_entity_ids
|
||||||
|
],
|
||||||
|
"scene_suggestions": [
|
||||||
|
scene for scene in scenes if record.actuator_entity_id in scene.member_entity_ids
|
||||||
|
],
|
||||||
|
"agent_insights": insights_by_actuator.get(record.actuator_entity_id, []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
updated.append(self._save_behavior(record, behavior))
|
||||||
|
return updated
|
||||||
|
|
||||||
def rollback_model(
|
def rollback_model(
|
||||||
self,
|
self,
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -857,7 +1128,7 @@ class BehaviorEngine:
|
|||||||
blockers.append(
|
blockers.append(
|
||||||
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
|
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
|
||||||
)
|
)
|
||||||
if current_state == prediction.target_state:
|
if _target_reached(record.actuator_entity_id, current_state, prediction):
|
||||||
blockers.append("Zielzustand ist bereits erreicht.")
|
blockers.append("Zielzustand ist bereits erreicht.")
|
||||||
if not self._cooldown_elapsed(
|
if not self._cooldown_elapsed(
|
||||||
record.behavior,
|
record.behavior,
|
||||||
@@ -900,12 +1171,16 @@ class BehaviorEngine:
|
|||||||
patterns.append(
|
patterns.append(
|
||||||
BehaviorPattern(
|
BehaviorPattern(
|
||||||
target_state=point.state,
|
target_state=point.state,
|
||||||
|
target_attributes=_target_attributes_for(point),
|
||||||
minute_of_day=local.hour * 60 + local.minute,
|
minute_of_day=local.hour * 60 + local.minute,
|
||||||
weekday=local.weekday(),
|
weekday=local.weekday(),
|
||||||
context_states=contexts,
|
context_states=contexts,
|
||||||
trigger_entity_id=trigger[0] if trigger else None,
|
trigger_entity_id=trigger[1] if trigger else None,
|
||||||
trigger_from_state=trigger[1] if trigger else None,
|
trigger_from_state=trigger[2] if trigger else None,
|
||||||
trigger_to_state=trigger[2] if trigger else None,
|
trigger_to_state=trigger[3] if trigger else None,
|
||||||
|
trigger_delay_seconds=(
|
||||||
|
int(trigger[0].total_seconds()) if trigger else None
|
||||||
|
),
|
||||||
source=source,
|
source=source,
|
||||||
weight=weight,
|
weight=weight,
|
||||||
observed_at=point.timestamp,
|
observed_at=point.timestamp,
|
||||||
@@ -937,6 +1212,11 @@ class BehaviorEngine:
|
|||||||
record: ActuatorRecord,
|
record: ActuatorRecord,
|
||||||
behavior: BehaviorState,
|
behavior: BehaviorState,
|
||||||
) -> ActuatorRecord:
|
) -> ActuatorRecord:
|
||||||
|
behavior = behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"model_snapshots": _compact_model_snapshots(behavior.model_snapshots),
|
||||||
|
}
|
||||||
|
)
|
||||||
updated = record.model_copy(
|
updated = record.model_copy(
|
||||||
update={
|
update={
|
||||||
"behavior": behavior,
|
"behavior": behavior,
|
||||||
@@ -959,11 +1239,19 @@ class BehaviorEngine:
|
|||||||
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
||||||
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
||||||
"""
|
"""
|
||||||
|
event_received_at = datetime.now(timezone.utc)
|
||||||
|
records = self._store.list()
|
||||||
# Aktor direkt evaluieren
|
# Aktor direkt evaluieren
|
||||||
for record in self._store.list():
|
for record in records:
|
||||||
if record.actuator_entity_id == entity_id:
|
if record.actuator_entity_id == entity_id:
|
||||||
try:
|
try:
|
||||||
self.evaluate(record.actuator_entity_id, current_entities=current_entities)
|
self.evaluate(
|
||||||
|
record.actuator_entity_id,
|
||||||
|
current_entities=current_entities,
|
||||||
|
trigger_entity_id=entity_id,
|
||||||
|
trigger_state=_event_state(new_state),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
)
|
||||||
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
|
||||||
@@ -972,7 +1260,7 @@ class BehaviorEngine:
|
|||||||
# 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
|
||||||
for record in self._store.list()
|
for record in records
|
||||||
if (
|
if (
|
||||||
record.assignment.selected_numeric_entity_id == entity_id
|
record.assignment.selected_numeric_entity_id == entity_id
|
||||||
or entity_id in record.assignment.selected_context_entity_ids
|
or entity_id in record.assignment.selected_context_entity_ids
|
||||||
@@ -985,6 +1273,9 @@ class BehaviorEngine:
|
|||||||
context_state_overrides={entity_id: event_state},
|
context_state_overrides={entity_id: event_state},
|
||||||
context_changed_at_overrides={entity_id: event_changed_at},
|
context_changed_at_overrides={entity_id: event_changed_at},
|
||||||
current_entities=current_entities,
|
current_entities=current_entities,
|
||||||
|
trigger_entity_id=entity_id,
|
||||||
|
trigger_state=event_state,
|
||||||
|
event_received_at=event_received_at,
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -1012,6 +1303,208 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_ms(started_perf: float) -> int:
|
||||||
|
return max(0, int((perf_counter() - started_perf) * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def _append_decision_trace(
|
||||||
|
behavior: BehaviorState,
|
||||||
|
*,
|
||||||
|
trigger_entity_id: str | None,
|
||||||
|
trigger_state: str | None,
|
||||||
|
prediction: BehaviorPrediction | None,
|
||||||
|
safety_blockers: list[str],
|
||||||
|
duration_ms: int,
|
||||||
|
event_received_at: datetime | None,
|
||||||
|
decision_to_service_ms: int | None,
|
||||||
|
executed: bool,
|
||||||
|
source: str,
|
||||||
|
) -> BehaviorState:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
blocked = prediction is None or bool(safety_blockers)
|
||||||
|
trace = DecisionTrace(
|
||||||
|
trace_id=f"{now.strftime('%Y%m%d%H%M%S%f')}.{trigger_entity_id or 'manual'}",
|
||||||
|
created_at=now,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
target_state=prediction.target_state if prediction is not None else None,
|
||||||
|
confidence=prediction.confidence if prediction is not None else None,
|
||||||
|
executed=executed,
|
||||||
|
blocked=blocked,
|
||||||
|
reason=(
|
||||||
|
prediction.execution_reason
|
||||||
|
if prediction is not None
|
||||||
|
else behavior.reason
|
||||||
|
),
|
||||||
|
blockers=safety_blockers if prediction is not None else ["Keine fällige Vorhersage."],
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
updated = behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"decision_timeline": [
|
||||||
|
*behavior.decision_timeline,
|
||||||
|
trace,
|
||||||
|
][-_MAX_DECISION_TRACES:],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if event_received_at is None:
|
||||||
|
return updated
|
||||||
|
return _append_latency_measurement(
|
||||||
|
updated,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
event_to_decision_ms=duration_ms,
|
||||||
|
decision_to_service_ms=decision_to_service_ms,
|
||||||
|
executed=executed,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_latency_measurement(
|
||||||
|
behavior: BehaviorState,
|
||||||
|
*,
|
||||||
|
trigger_entity_id: str | None,
|
||||||
|
event_received_at: datetime | None,
|
||||||
|
event_to_decision_ms: int | None,
|
||||||
|
decision_to_service_ms: int | None,
|
||||||
|
executed: bool,
|
||||||
|
source: str,
|
||||||
|
) -> BehaviorState:
|
||||||
|
if event_received_at is None:
|
||||||
|
return behavior
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_to_done_ms = max(0, int((now - event_received_at).total_seconds() * 1000))
|
||||||
|
measurement = LatencyMeasurement(
|
||||||
|
measured_at=now,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
event_to_decision_ms=event_to_decision_ms,
|
||||||
|
decision_to_service_ms=decision_to_service_ms,
|
||||||
|
event_to_done_ms=event_to_done_ms,
|
||||||
|
executed=executed,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
return behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"latency_measurements": [
|
||||||
|
*behavior.latency_measurements,
|
||||||
|
measurement,
|
||||||
|
][-_MAX_LATENCY_MEASUREMENTS:],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_actuator_groups(records: list[ActuatorRecord]) -> list[ActuatorGroup]:
|
||||||
|
by_area: dict[str, list[str]] = {}
|
||||||
|
for record in records:
|
||||||
|
area = _area_hint(record)
|
||||||
|
if area:
|
||||||
|
by_area.setdefault(area, []).append(record.actuator_entity_id)
|
||||||
|
return [
|
||||||
|
ActuatorGroup(
|
||||||
|
group_id=_slug(f"area_{area}"),
|
||||||
|
name=f"Raum {area}",
|
||||||
|
area_name=area,
|
||||||
|
member_entity_ids=sorted(entity_ids),
|
||||||
|
reason="Aktor-Gruppe aus gemeinsamer Raum-/Kontextzuordnung abgeleitet.",
|
||||||
|
)
|
||||||
|
for area, entity_ids in sorted(by_area.items())
|
||||||
|
if len(entity_ids) >= 2
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_scene_suggestions(records: list[ActuatorRecord]) -> list[SceneSuggestion]:
|
||||||
|
scenes: list[SceneSuggestion] = []
|
||||||
|
by_context: dict[tuple[str, str], list[str]] = {}
|
||||||
|
for record in records:
|
||||||
|
for pattern in record.behavior.patterns:
|
||||||
|
for entity_id, state in pattern.context_states.items():
|
||||||
|
by_context.setdefault((entity_id, state), []).append(record.actuator_entity_id)
|
||||||
|
for (entity_id, state), members in sorted(by_context.items()):
|
||||||
|
unique_members = sorted(set(members))
|
||||||
|
if len(unique_members) < 2:
|
||||||
|
continue
|
||||||
|
scenes.append(
|
||||||
|
SceneSuggestion(
|
||||||
|
scene_id=_slug(f"{entity_id}_{state}"),
|
||||||
|
label=f"{entity_id} ist {state}",
|
||||||
|
member_entity_ids=unique_members,
|
||||||
|
confidence=min(1.0, len(members) / max(3, len(unique_members) * 2)),
|
||||||
|
reason="Mehrere Aktoren reagieren historisch auf denselben Kontext.",
|
||||||
|
last_seen_at=max(
|
||||||
|
(
|
||||||
|
pattern.observed_at
|
||||||
|
for record in records
|
||||||
|
for pattern in record.behavior.patterns
|
||||||
|
if pattern.context_states.get(entity_id) == state
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return scenes[-20:]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_agent_insights(records: list[ActuatorRecord]) -> dict[str, list[AgentInsight]]:
|
||||||
|
result: dict[str, list[AgentInsight]] = {}
|
||||||
|
for record in records:
|
||||||
|
insights: list[AgentInsight] = []
|
||||||
|
if record.behavior.automation_conflicts:
|
||||||
|
insights.append(
|
||||||
|
AgentInsight(
|
||||||
|
insight_id=f"{record.actuator_entity_id}.automation_conflict",
|
||||||
|
severity="warning",
|
||||||
|
title="Automation-Konflikt prüfen",
|
||||||
|
detail="Eine passende HA-Automation kann parallel zu SillyHome schalten.",
|
||||||
|
action="Automation pausieren oder SillyHome im Shadow-Modus lassen.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if record.behavior.latency_measurements:
|
||||||
|
durations = [
|
||||||
|
item.event_to_done_ms
|
||||||
|
for item in record.behavior.latency_measurements
|
||||||
|
if item.event_to_done_ms is not None
|
||||||
|
]
|
||||||
|
if durations and max(durations) > 1500:
|
||||||
|
insights.append(
|
||||||
|
AgentInsight(
|
||||||
|
insight_id=f"{record.actuator_entity_id}.latency",
|
||||||
|
severity="warning",
|
||||||
|
title="Schalt-Latenz beobachten",
|
||||||
|
detail=f"Letzte maximale Event-Latenz: {max(durations)} ms.",
|
||||||
|
action="WebSocket-Status, HA-Servicezeit und Sensor-Routing pruefen.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if record.behavior.incorrect_feedback_count > record.behavior.correct_feedback_count:
|
||||||
|
insights.append(
|
||||||
|
AgentInsight(
|
||||||
|
insight_id=f"{record.actuator_entity_id}.feedback",
|
||||||
|
severity="warning",
|
||||||
|
title="Viele negative Feedbacks",
|
||||||
|
detail="Das Modell trifft aktuell mehr falsche als richtige Entscheidungen.",
|
||||||
|
action="Kontextzuordnung, Gewichtung oder Modell-Rollback pruefen.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result[record.actuator_entity_id] = insights[:5]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _area_hint(record: ActuatorRecord) -> str | None:
|
||||||
|
for candidate in [*record.context_candidates, *record.numeric_candidates]:
|
||||||
|
if candidate.area_name:
|
||||||
|
return candidate.area_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(value: str) -> str:
|
||||||
|
result = []
|
||||||
|
for char in value.lower():
|
||||||
|
if char.isalnum():
|
||||||
|
result.append(char)
|
||||||
|
elif char in {".", "_", "-", " "}:
|
||||||
|
result.append("_")
|
||||||
|
return "".join(result).strip("_")[:64] or "item"
|
||||||
|
|
||||||
|
|
||||||
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
|
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
|
||||||
if target_state == "on" and profile.min_confidence_on is not None:
|
if target_state == "on" and profile.min_confidence_on is not None:
|
||||||
return profile.min_confidence_on
|
return profile.min_confidence_on
|
||||||
@@ -1024,15 +1517,21 @@ def _decision_factors_for(
|
|||||||
record: ActuatorRecord,
|
record: ActuatorRecord,
|
||||||
current_context: dict[str, str | None],
|
current_context: dict[str, str | None],
|
||||||
prediction: BehaviorPrediction | None,
|
prediction: BehaviorPrediction | None,
|
||||||
|
*,
|
||||||
|
context_weights: dict[str, float] | None = None,
|
||||||
) -> list[DecisionFactor]:
|
) -> list[DecisionFactor]:
|
||||||
factors: list[DecisionFactor] = []
|
factors: list[DecisionFactor] = []
|
||||||
|
weights = context_weights or {}
|
||||||
candidates = {
|
candidates = {
|
||||||
candidate.entity_id: candidate
|
candidate.entity_id: candidate
|
||||||
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||||
}
|
}
|
||||||
for entity_id, state in current_context.items():
|
for entity_id, state in current_context.items():
|
||||||
candidate = candidates.get(entity_id)
|
candidate = candidates.get(entity_id)
|
||||||
weight = candidate.effective_weight if candidate is not None else 1.0
|
weight = weights.get(
|
||||||
|
entity_id,
|
||||||
|
candidate.effective_weight if candidate is not None else 1.0,
|
||||||
|
)
|
||||||
relevance = candidate.confidence if candidate is not None else 0.5
|
relevance = candidate.confidence if candidate is not None else 0.5
|
||||||
contribution = round(min(1.0, weight * relevance), 4)
|
contribution = round(min(1.0, weight * relevance), 4)
|
||||||
factors.append(
|
factors.append(
|
||||||
@@ -1068,6 +1567,62 @@ def _decision_factors_for(
|
|||||||
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
|
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
|
||||||
|
weights = {
|
||||||
|
candidate.entity_id: candidate.effective_weight
|
||||||
|
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||||
|
}
|
||||||
|
override = record.manual_override
|
||||||
|
if override is not None:
|
||||||
|
for entity_id, weight in override.sensor_weights.items():
|
||||||
|
weights[entity_id] = max(0.0, min(1.0, weight))
|
||||||
|
for group in override.sensor_weight_groups:
|
||||||
|
for entity_id in group.entity_ids:
|
||||||
|
weights[entity_id] = max(0.0, min(1.0, group.weight))
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def _simulation_contexts(
|
||||||
|
base_context: dict[str, str | None],
|
||||||
|
*,
|
||||||
|
sensor_states: dict[str, str],
|
||||||
|
state_options: dict[str, list[str]],
|
||||||
|
selected_context_ids: list[str],
|
||||||
|
include_current: bool,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
selected = set(selected_context_ids)
|
||||||
|
base = {
|
||||||
|
entity_id: state
|
||||||
|
for entity_id, state in base_context.items()
|
||||||
|
if entity_id in selected and state is not None
|
||||||
|
}
|
||||||
|
for entity_id, state in sensor_states.items():
|
||||||
|
if entity_id in selected:
|
||||||
|
base[entity_id] = state
|
||||||
|
option_items = [
|
||||||
|
(
|
||||||
|
entity_id,
|
||||||
|
list(dict.fromkeys(state for state in states if state))[:6],
|
||||||
|
)
|
||||||
|
for entity_id, states in state_options.items()
|
||||||
|
if entity_id in selected and states
|
||||||
|
][:6]
|
||||||
|
contexts: list[dict[str, str]] = []
|
||||||
|
if include_current or not option_items:
|
||||||
|
contexts.append(dict(base))
|
||||||
|
if option_items:
|
||||||
|
keys = [item[0] for item in option_items]
|
||||||
|
value_lists = [item[1] for item in option_items]
|
||||||
|
for values in product(*value_lists):
|
||||||
|
context = dict(base)
|
||||||
|
context.update(dict(zip(keys, values, strict=True)))
|
||||||
|
if context not in contexts:
|
||||||
|
contexts.append(context)
|
||||||
|
if len(contexts) >= 64:
|
||||||
|
break
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
def _knowledge_lines(
|
def _knowledge_lines(
|
||||||
record: ActuatorRecord,
|
record: ActuatorRecord,
|
||||||
sample_count: int,
|
sample_count: int,
|
||||||
@@ -1132,10 +1687,19 @@ def _next_model_snapshots(
|
|||||||
high_confidence_sample_count=trusted_actions,
|
high_confidence_sample_count=trusted_actions,
|
||||||
average_confidence=round(average_confidence, 4),
|
average_confidence=round(average_confidence, 4),
|
||||||
incorrect_feedback_count=incorrect_feedback_count,
|
incorrect_feedback_count=incorrect_feedback_count,
|
||||||
patterns=patterns,
|
patterns=patterns[-_MAX_SNAPSHOT_PATTERNS:],
|
||||||
reason=reason,
|
reason=reason,
|
||||||
)
|
)
|
||||||
return [*existing, snapshot][-10:]
|
return _compact_model_snapshots([*existing, snapshot])
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_model_snapshots(existing: list[ModelSnapshot]) -> list[ModelSnapshot]:
|
||||||
|
return [
|
||||||
|
snapshot.model_copy(
|
||||||
|
update={"patterns": snapshot.patterns[-_MAX_SNAPSHOT_PATTERNS:]}
|
||||||
|
)
|
||||||
|
for snapshot in existing[-_MAX_MODEL_SNAPSHOTS:]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _average(values: list[float]) -> float:
|
def _average(values: list[float]) -> float:
|
||||||
@@ -1392,6 +1956,7 @@ def predict_behavior(
|
|||||||
min_support: int,
|
min_support: int,
|
||||||
window_minutes: int,
|
window_minutes: int,
|
||||||
current_context_changed_at: dict[str, datetime | None] | None = None,
|
current_context_changed_at: dict[str, datetime | None] | None = None,
|
||||||
|
context_weights: dict[str, float] | None = None,
|
||||||
causal_window_seconds: int = 120,
|
causal_window_seconds: int = 120,
|
||||||
timezone_name: str = "Europe/Berlin",
|
timezone_name: str = "Europe/Berlin",
|
||||||
) -> BehaviorPrediction | None:
|
) -> BehaviorPrediction | None:
|
||||||
@@ -1401,6 +1966,7 @@ def predict_behavior(
|
|||||||
minute_of_day = local.hour * 60 + local.minute
|
minute_of_day = local.hour * 60 + local.minute
|
||||||
changed_at = current_context_changed_at or {}
|
changed_at = current_context_changed_at or {}
|
||||||
by_state: dict[str, list[float]] = {}
|
by_state: dict[str, list[float]] = {}
|
||||||
|
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
|
||||||
causal_support_by_state: dict[str, int] = {}
|
causal_support_by_state: dict[str, int] = {}
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
||||||
@@ -1414,7 +1980,11 @@ def predict_behavior(
|
|||||||
current_context.get(pattern.trigger_entity_id)
|
current_context.get(pattern.trigger_entity_id)
|
||||||
== pattern.trigger_to_state
|
== pattern.trigger_to_state
|
||||||
and trigger_age is not None
|
and trigger_age is not None
|
||||||
and 0 <= trigger_age <= causal_window_seconds
|
and _trigger_age_matches(
|
||||||
|
trigger_age,
|
||||||
|
pattern.trigger_delay_seconds,
|
||||||
|
causal_window_seconds,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
comparable = [
|
comparable = [
|
||||||
@@ -1422,17 +1992,16 @@ def predict_behavior(
|
|||||||
for entity_id, expected in pattern.context_states.items()
|
for entity_id, expected in pattern.context_states.items()
|
||||||
if entity_id in current_context
|
if entity_id in current_context
|
||||||
]
|
]
|
||||||
context_score = (
|
context_score = _weighted_context_score(
|
||||||
sum(
|
comparable,
|
||||||
current_context[entity_id] == expected
|
current_context,
|
||||||
for entity_id, expected in comparable
|
context_weights or {},
|
||||||
)
|
|
||||||
/ len(comparable)
|
|
||||||
if comparable
|
|
||||||
else 0.5
|
|
||||||
)
|
)
|
||||||
score = pattern.weight * (0.85 + 0.15 * context_score)
|
score = pattern.weight * (0.85 + 0.15 * context_score)
|
||||||
by_state.setdefault(pattern.target_state, []).append(score)
|
by_state.setdefault(pattern.target_state, []).append(score)
|
||||||
|
attributes_by_state.setdefault(pattern.target_state, []).append(
|
||||||
|
(score, pattern.target_attributes)
|
||||||
|
)
|
||||||
causal_support_by_state[pattern.target_state] = (
|
causal_support_by_state[pattern.target_state] = (
|
||||||
causal_support_by_state.get(pattern.target_state, 0) + 1
|
causal_support_by_state.get(pattern.target_state, 0) + 1
|
||||||
)
|
)
|
||||||
@@ -1453,16 +2022,18 @@ def predict_behavior(
|
|||||||
for entity_id, expected in pattern.context_states.items()
|
for entity_id, expected in pattern.context_states.items()
|
||||||
if entity_id in current_context
|
if entity_id in current_context
|
||||||
]
|
]
|
||||||
context_score = (
|
context_score = _weighted_context_score(
|
||||||
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
|
comparable,
|
||||||
/ len(comparable)
|
current_context,
|
||||||
if comparable
|
context_weights or {},
|
||||||
else 0.5
|
|
||||||
)
|
)
|
||||||
score = pattern.weight * (
|
score = pattern.weight * (
|
||||||
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
||||||
)
|
)
|
||||||
by_state.setdefault(pattern.target_state, []).append(score)
|
by_state.setdefault(pattern.target_state, []).append(score)
|
||||||
|
attributes_by_state.setdefault(pattern.target_state, []).append(
|
||||||
|
(score, pattern.target_attributes)
|
||||||
|
)
|
||||||
if not by_state:
|
if not by_state:
|
||||||
return None
|
return None
|
||||||
target_state, scores = max(
|
target_state, scores = max(
|
||||||
@@ -1476,6 +2047,9 @@ def predict_behavior(
|
|||||||
return None
|
return None
|
||||||
return BehaviorPrediction(
|
return BehaviorPrediction(
|
||||||
target_state=target_state,
|
target_state=target_state,
|
||||||
|
target_attributes=_aggregate_target_attributes(
|
||||||
|
attributes_by_state.get(target_state, [])
|
||||||
|
),
|
||||||
confidence=round(confidence, 4),
|
confidence=round(confidence, 4),
|
||||||
generated_at=now,
|
generated_at=now,
|
||||||
matching_patterns=support,
|
matching_patterns=support,
|
||||||
@@ -1490,9 +2064,106 @@ def predict_behavior(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_context_score(
|
||||||
|
comparable: list[tuple[str, str]],
|
||||||
|
current_context: dict[str, str | None],
|
||||||
|
context_weights: dict[str, float],
|
||||||
|
) -> float:
|
||||||
|
if not comparable:
|
||||||
|
return 0.5
|
||||||
|
total_weight = 0.0
|
||||||
|
matched_weight = 0.0
|
||||||
|
for entity_id, expected in comparable:
|
||||||
|
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
|
||||||
|
total_weight += weight
|
||||||
|
if current_context.get(entity_id) == expected:
|
||||||
|
matched_weight += weight
|
||||||
|
if total_weight <= 0:
|
||||||
|
return 0.5
|
||||||
|
return matched_weight / total_weight
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_age_matches(
|
||||||
|
trigger_age_seconds: float,
|
||||||
|
expected_delay_seconds: int | None,
|
||||||
|
causal_window_seconds: int,
|
||||||
|
) -> bool:
|
||||||
|
if trigger_age_seconds < 0:
|
||||||
|
return False
|
||||||
|
if expected_delay_seconds is None or expected_delay_seconds <= 10:
|
||||||
|
return trigger_age_seconds <= causal_window_seconds
|
||||||
|
tolerance = max(30, min(90, causal_window_seconds // 2))
|
||||||
|
return abs(trigger_age_seconds - expected_delay_seconds) <= tolerance
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_target_attributes(
|
||||||
|
weighted_attributes: list[tuple[float, dict[str, object]]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not weighted_attributes:
|
||||||
|
return {}
|
||||||
|
result: dict[str, object] = {}
|
||||||
|
numeric_values: dict[str, list[tuple[float, float]]] = {}
|
||||||
|
categorical_values: dict[str, dict[str, float]] = {}
|
||||||
|
for score, attributes in weighted_attributes:
|
||||||
|
for key, value in attributes.items():
|
||||||
|
if key not in _LIGHT_TARGET_ATTRIBUTES:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
numeric_values.setdefault(key, []).append((score, float(value)))
|
||||||
|
else:
|
||||||
|
categorical_values.setdefault(key, {}).setdefault(str(value), 0.0)
|
||||||
|
categorical_values[key][str(value)] += score
|
||||||
|
for key, values in numeric_values.items():
|
||||||
|
total_weight = sum(score for score, _ in values)
|
||||||
|
if total_weight <= 0:
|
||||||
|
continue
|
||||||
|
result[key] = round(sum(score * value for score, value in values) / total_weight)
|
||||||
|
for key, values in categorical_values.items():
|
||||||
|
if key in result:
|
||||||
|
continue
|
||||||
|
result[key] = max(values.items(), key=lambda item: (item[1], item[0]))[0]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _target_attributes_for(point: StateHistoryPoint) -> dict[str, object]:
|
||||||
|
if point.state != "on":
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in point.attributes.items()
|
||||||
|
if key in _LIGHT_TARGET_ATTRIBUTES and value is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _service_data_for_prediction(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
domain: str,
|
||||||
|
prediction: BehaviorPrediction,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
data: dict[str, object] = {"entity_id": actuator_entity_id}
|
||||||
|
if domain == "light" and prediction.target_state == "on":
|
||||||
|
data.update(prediction.target_attributes)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _target_reached(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
current_state: str,
|
||||||
|
prediction: BehaviorPrediction,
|
||||||
|
) -> bool:
|
||||||
|
domain = actuator_entity_id.split(".", 1)[0]
|
||||||
|
if domain == "light" and prediction.target_state == "on" and prediction.target_attributes:
|
||||||
|
return False
|
||||||
|
return current_state == prediction.target_state
|
||||||
|
|
||||||
|
|
||||||
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", "media_player", "remote", "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 in {"button", "input_button"}:
|
||||||
|
return "press"
|
||||||
if domain == "scene":
|
if domain == "scene":
|
||||||
return "turn_on" if target_state == "on" else None
|
return "turn_on" if target_state == "on" else None
|
||||||
if domain == "cover":
|
if domain == "cover":
|
||||||
@@ -1558,7 +2229,7 @@ def _recent_context_transition(
|
|||||||
history: dict[str, StateHistorySeries],
|
history: dict[str, StateHistorySeries],
|
||||||
context_ids: list[str],
|
context_ids: list[str],
|
||||||
timestamp: datetime,
|
timestamp: datetime,
|
||||||
) -> tuple[str, str, str] | None:
|
) -> tuple[timedelta, str, str, str] | None:
|
||||||
nearest: tuple[timedelta, str, str, str] | None = None
|
nearest: tuple[timedelta, str, str, str] | None = None
|
||||||
for entity_id in context_ids:
|
for entity_id in context_ids:
|
||||||
series = history.get(entity_id)
|
series = history.get(entity_id)
|
||||||
@@ -1577,7 +2248,7 @@ def _recent_context_transition(
|
|||||||
previous_state = point.state
|
previous_state = point.state
|
||||||
if nearest is None:
|
if nearest is None:
|
||||||
return None
|
return None
|
||||||
return nearest[1], nearest[2], nearest[3]
|
return nearest
|
||||||
|
|
||||||
|
|
||||||
def _circular_minute_distance(left: int, right: int) -> int:
|
def _circular_minute_distance(left: int, right: int) -> int:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Settings:
|
|||||||
prediction_interval_seconds: int = 60
|
prediction_interval_seconds: int = 60
|
||||||
execution_cooldown_seconds: int = 900
|
execution_cooldown_seconds: int = 900
|
||||||
timezone: str = "Europe/Berlin"
|
timezone: str = "Europe/Berlin"
|
||||||
|
ha_timeout_seconds: int = 25
|
||||||
|
dashboard_cache_refresh_seconds: int = 3600
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ha_configured(self) -> bool:
|
def ha_configured(self) -> bool:
|
||||||
@@ -55,4 +57,8 @@ def load_settings() -> Settings:
|
|||||||
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
|
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
|
||||||
),
|
),
|
||||||
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
|
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
|
||||||
|
ha_timeout_seconds=max(5, int(os.getenv("SILLYHOME_HA_TIMEOUT_SECONDS", "25"))),
|
||||||
|
dashboard_cache_refresh_seconds=max(
|
||||||
|
300, int(os.getenv("SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS", "3600"))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ class HaClient:
|
|||||||
"filter_entity_id": ",".join(entity_ids),
|
"filter_entity_id": ",".join(entity_ids),
|
||||||
"end_time": end_time.isoformat(),
|
"end_time": end_time.isoformat(),
|
||||||
"minimal_response": "1",
|
"minimal_response": "1",
|
||||||
"no_attributes": "1",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
|
|||||||
class StateHistoryPoint(BaseModel):
|
class StateHistoryPoint(BaseModel):
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
state: str
|
state: str
|
||||||
|
attributes: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
class StateHistorySeries(BaseModel):
|
class StateHistorySeries(BaseModel):
|
||||||
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
|
|||||||
timestamp = _parse_timestamp(
|
timestamp = _parse_timestamp(
|
||||||
raw_entry.get("last_changed") or raw_entry.get("last_updated")
|
raw_entry.get("last_changed") or raw_entry.get("last_updated")
|
||||||
)
|
)
|
||||||
if not points or points[-1].state != raw_state:
|
attributes = raw_entry.get("attributes")
|
||||||
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state))
|
if not isinstance(attributes, dict):
|
||||||
|
attributes = {}
|
||||||
|
if (
|
||||||
|
not points
|
||||||
|
or points[-1].state != raw_state
|
||||||
|
or _relevant_state_attributes(points[-1].attributes)
|
||||||
|
!= _relevant_state_attributes(attributes)
|
||||||
|
):
|
||||||
|
points.append(
|
||||||
|
StateHistoryPoint(
|
||||||
|
timestamp=timestamp,
|
||||||
|
state=raw_state,
|
||||||
|
attributes=_relevant_state_attributes(attributes),
|
||||||
|
)
|
||||||
|
)
|
||||||
if entity_id is not None and points:
|
if entity_id is not None and points:
|
||||||
points.sort(key=lambda point: point.timestamp)
|
points.sort(key=lambda point: point.timestamp)
|
||||||
normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
|
normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
|
||||||
@@ -176,3 +191,16 @@ def _optional_string(value: object) -> str | None:
|
|||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return None
|
return None
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _relevant_state_attributes(attributes: dict[str, object]) -> dict[str, object]:
|
||||||
|
keys = {
|
||||||
|
"brightness",
|
||||||
|
"color_temp",
|
||||||
|
"color_temp_kelvin",
|
||||||
|
"effect",
|
||||||
|
"hs_color",
|
||||||
|
"rgb_color",
|
||||||
|
"xy_color",
|
||||||
|
}
|
||||||
|
return {key: attributes[key] for key in keys if key in attributes}
|
||||||
|
|||||||
100
app/main.py
100
app/main.py
@@ -12,6 +12,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.api.v1.actuators import router as actuators_router
|
from app.api.v1.actuators import router as actuators_router
|
||||||
@@ -20,6 +21,7 @@ from app.behavior.engine import BehaviorEngine
|
|||||||
from app.config import load_settings
|
from app.config import load_settings
|
||||||
from app.core.exception_handlers import register_exception_handlers
|
from app.core.exception_handlers import register_exception_handlers
|
||||||
from app.ha.client import HaClient, HaClientSettings
|
from app.ha.client import HaClient, HaClientSettings
|
||||||
|
from app.ha.discovery import discover_entities
|
||||||
from app.ha.models import HaEntitySummary
|
from app.ha.models import HaEntitySummary
|
||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
from app.ml.registry.model_registry import ModelRegistry
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
@@ -47,8 +49,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
reconcile_task: asyncio.Task[None] | None = None
|
reconcile_task: asyncio.Task[None] | None = None
|
||||||
event_listener_task: asyncio.Task[None] | None = None
|
event_listener_task: asyncio.Task[None] | None = None
|
||||||
fallback_task: asyncio.Task[None] | None = None
|
fallback_task: asyncio.Task[None] | None = None
|
||||||
|
cache_refresh_task: asyncio.Task[None] | None = None
|
||||||
app.state.registry = ModelRegistry(settings.model_store)
|
app.state.registry = ModelRegistry(settings.model_store)
|
||||||
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
||||||
|
app.state.dashboard_cache = DashboardCache(
|
||||||
|
Path(settings.actuator_store).resolve() / "dashboard_cache.sqlite3"
|
||||||
|
)
|
||||||
if hasattr(app.state, "ha_reader"):
|
if hasattr(app.state, "ha_reader"):
|
||||||
del app.state.ha_reader
|
del app.state.ha_reader
|
||||||
if hasattr(app.state, "actuator_service"):
|
if hasattr(app.state, "actuator_service"):
|
||||||
@@ -60,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
settings=HaClientSettings(
|
settings=HaClientSettings(
|
||||||
url=cast(str, settings.ha_url),
|
url=cast(str, settings.ha_url),
|
||||||
token=cast(str, settings.ha_token),
|
token=cast(str, settings.ha_token),
|
||||||
|
timeout_seconds=settings.ha_timeout_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
app.state.ha_reader = HaReader(client=client)
|
app.state.ha_reader = HaReader(client=client)
|
||||||
@@ -79,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
||||||
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
||||||
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
||||||
|
cache_refresh_task = asyncio.create_task(_periodic_dashboard_cache_refresh(app))
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -98,6 +106,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
fallback_task.cancel()
|
fallback_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await fallback_task
|
await fallback_task
|
||||||
|
if cache_refresh_task is not None:
|
||||||
|
cache_refresh_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await cache_refresh_task
|
||||||
if client is not None:
|
if client is not None:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -105,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SillyHome Next API",
|
title="SillyHome Next API",
|
||||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||||
version="1.3.0",
|
version="1.7.4",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -159,6 +171,42 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
|
|||||||
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
|
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _periodic_dashboard_cache_refresh(app: FastAPI) -> None:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
while True:
|
||||||
|
await _refresh_dashboard_cache(app, trigger="scheduled")
|
||||||
|
await asyncio.sleep(app.state.settings.dashboard_cache_refresh_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_dashboard_cache(app: FastAPI, *, trigger: str) -> None:
|
||||||
|
ha_reader = getattr(app.state, "ha_reader", None)
|
||||||
|
cache = getattr(app.state, "dashboard_cache", None)
|
||||||
|
if not isinstance(ha_reader, HaReader) or not isinstance(cache, DashboardCache):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
entities = await asyncio.to_thread(ha_reader.read_entities)
|
||||||
|
groups = _discovery_group_payload(list(entities))
|
||||||
|
await asyncio.to_thread(
|
||||||
|
cache.save_entities_payload,
|
||||||
|
entities=list(entities),
|
||||||
|
discovery_groups=groups,
|
||||||
|
)
|
||||||
|
logger.info("Dashboard-Cache aktualisiert (%s): %d Entities", trigger, len(entities))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Dashboard-Cache konnte nicht aktualisiert werden (%s): %s", trigger, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
|
||||||
|
group_counts: dict[tuple[str, str], int] = {}
|
||||||
|
for entity in discover_entities(entities):
|
||||||
|
key = (entity.category, entity.role.value)
|
||||||
|
group_counts[key] = group_counts.get(key, 0) + 1
|
||||||
|
return [
|
||||||
|
{"category": category, "role": role, "count": count}
|
||||||
|
for (category, role), count in sorted(group_counts.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _startup_reconciliation(app: FastAPI) -> None:
|
async def _startup_reconciliation(app: FastAPI) -> None:
|
||||||
delay_seconds = 5
|
delay_seconds = 5
|
||||||
while True:
|
while True:
|
||||||
@@ -207,15 +255,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||||
auth_token = cast(str, settings.ha_token)
|
auth_token = cast(str, settings.ha_token)
|
||||||
ws_status = getattr(app.state, "ws_status", None)
|
ws_status = getattr(app.state, "ws_status", None)
|
||||||
|
reconnect_delay = 1.0
|
||||||
|
relevant_entity_ids: set[str] = set()
|
||||||
|
relevant_loaded_at = 0.0
|
||||||
while True:
|
while True:
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "connecting"
|
ws_status.status = "connecting"
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
async with websockets.connect(ws_url, ping_interval=None) as websocket:
|
||||||
ws_url,
|
|
||||||
ping_interval=20,
|
|
||||||
ping_timeout=10,
|
|
||||||
) as websocket:
|
|
||||||
auth_required_msg = await websocket.recv()
|
auth_required_msg = await websocket.recv()
|
||||||
auth_required_data = json.loads(auth_required_msg)
|
auth_required_data = json.loads(auth_required_msg)
|
||||||
if auth_required_data.get("type") != "auth_required":
|
if auth_required_data.get("type") != "auth_required":
|
||||||
@@ -239,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
|
|
||||||
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
||||||
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
||||||
|
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||||
|
relevant_loaded_at = asyncio.get_running_loop().time()
|
||||||
|
reconnect_delay = 1.0
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "connected"
|
ws_status.status = "connected"
|
||||||
ws_status.error = None
|
ws_status.error = None
|
||||||
@@ -265,10 +315,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
entity_id = event_data.get("entity_id")
|
entity_id = event_data.get("entity_id")
|
||||||
if not entity_id:
|
if not entity_id:
|
||||||
continue
|
continue
|
||||||
|
loop_time = asyncio.get_running_loop().time()
|
||||||
|
if loop_time - relevant_loaded_at >= 10:
|
||||||
|
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||||
|
relevant_loaded_at = loop_time
|
||||||
|
if entity_id not in relevant_entity_ids:
|
||||||
|
continue
|
||||||
new_state = event_data.get("new_state")
|
new_state = event_data.get("new_state")
|
||||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||||
if not _is_relevant_state_change(store, str(entity_id)):
|
|
||||||
continue
|
|
||||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -286,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
websockets.exceptions.InvalidStatus,
|
websockets.exceptions.InvalidStatus,
|
||||||
OSError,
|
OSError,
|
||||||
) as exc:
|
) as exc:
|
||||||
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
|
delay = reconnect_delay
|
||||||
|
logger.warning(
|
||||||
|
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
|
||||||
|
exc,
|
||||||
|
delay,
|
||||||
|
)
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "reconnecting"
|
ws_status.status = "reconnecting"
|
||||||
ws_status.error = str(exc)
|
ws_status.error = str(exc)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(delay)
|
||||||
|
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "error"
|
ws_status.status = "error"
|
||||||
ws_status.error = str(exc)
|
ws_status.error = str(exc)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(reconnect_delay)
|
||||||
|
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||||
|
|
||||||
|
|
||||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||||
@@ -311,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
|||||||
await asyncio.sleep(
|
await asyncio.sleep(
|
||||||
app.state.settings.prediction_interval_seconds
|
app.state.settings.prediction_interval_seconds
|
||||||
if websocket_connected
|
if websocket_connected
|
||||||
else min(5, app.state.settings.prediction_interval_seconds)
|
else max(30, app.state.settings.prediction_interval_seconds)
|
||||||
)
|
)
|
||||||
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
||||||
ws_status = getattr(app.state, "ws_status", None)
|
ws_status = getattr(app.state, "ws_status", None)
|
||||||
@@ -347,15 +408,14 @@ def _update_ha_state_cache(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
|
def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
|
||||||
|
result: set[str] = set()
|
||||||
for record in store.list():
|
for record in store.list():
|
||||||
if record.actuator_entity_id == entity_id:
|
result.add(record.actuator_entity_id)
|
||||||
return True
|
if record.assignment.selected_numeric_entity_id:
|
||||||
if record.assignment.selected_numeric_entity_id == entity_id:
|
result.add(record.assignment.selected_numeric_entity_id)
|
||||||
return True
|
result.update(record.assignment.selected_context_entity_ids)
|
||||||
if entity_id in record.assignment.selected_context_entity_ids:
|
return result
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _ha_entity_from_event(
|
def _ha_entity_from_event(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
42
docs/V1_4_0_OPERATING_GUIDE.md
Normal file
42
docs/V1_4_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# SillyHome Next v1.4.0 Operating Guide
|
||||||
|
|
||||||
|
v1.4.0 überarbeitet das Dashboard für mobile Nutzung, deutsche Verständlichkeit
|
||||||
|
und stabileren Datenabruf.
|
||||||
|
|
||||||
|
## Schneller Startpfad
|
||||||
|
|
||||||
|
- Die Startseite lädt zuerst nur die Bedienoberfläche und den kompakten
|
||||||
|
Dashboard-Startdatensatz.
|
||||||
|
- Neuer Start-Endpunkt: `GET /v1/actuators/dashboard/start`.
|
||||||
|
- Der Start-Endpunkt liefert keine Discovery-Gruppen und keine Aufgabenliste.
|
||||||
|
- Status, Aufgabenliste, Reconciliation-Zeitpunkt und Detail-Kontext werden
|
||||||
|
danach im Hintergrund geladen.
|
||||||
|
- Auf der Startansicht werden zunächst nur die ersten 24 Aktoren gerendert.
|
||||||
|
Weitere Geräte werden auf Knopfdruck nachgerendert.
|
||||||
|
|
||||||
|
## Deutsche Oberfläche
|
||||||
|
|
||||||
|
Interne Protokollwerte bleiben stabil, werden in der Oberfläche aber übersetzt:
|
||||||
|
|
||||||
|
- `observe` -> `Nur beobachten`
|
||||||
|
- `suggest` -> `Vorschläge anzeigen`
|
||||||
|
- `shadow` -> `Prüfmodus ohne Schalten`
|
||||||
|
- `partial` -> `Teilfreigabe`
|
||||||
|
- `active` -> `Aktiv freigegeben`
|
||||||
|
- Job-Status wie `running`, `completed`, `failed` erscheinen als `läuft`,
|
||||||
|
`abgeschlossen`, `fehlgeschlagen`.
|
||||||
|
- Anomalie-Schweregrade erscheinen als `Hinweis`, `Warnung`, `Kritisch`.
|
||||||
|
|
||||||
|
## Stabilität
|
||||||
|
|
||||||
|
- Startdaten und Statusdaten sind getrennt. Ein langsamer Statuscheck blockiert
|
||||||
|
nicht mehr die Geräteübersicht.
|
||||||
|
- Die Aufgabenliste wird separat geladen und kann ausfallen, ohne die
|
||||||
|
Bedienoberfläche zu blockieren.
|
||||||
|
- Detaildaten bleiben gestuft: zuerst Shell und gespeicherte Werte, danach
|
||||||
|
Kontextvorschläge.
|
||||||
|
|
||||||
|
## Performance-Regel
|
||||||
|
|
||||||
|
3 Sekunden bleiben die harte Grenze für den Startpfad. Alles, was schwerer ist
|
||||||
|
als Startdaten, muss nachgelagert oder auf Nutzeraktion geladen werden.
|
||||||
47
docs/V1_5_0_OPERATING_GUIDE.md
Normal file
47
docs/V1_5_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# SillyHome Next v1.5.0 Operating Guide
|
||||||
|
|
||||||
|
v1.5.0 trennt Dashboard-Ansichten, Datenabruf und Detaildaten weiter auf. Ziel
|
||||||
|
ist, dass die Seite auf mobiler Datenverbindung schneller nutzbar wird und keine
|
||||||
|
schweren Lern-, Discovery- oder Detaildaten beim Start lädt.
|
||||||
|
|
||||||
|
## Menüstruktur
|
||||||
|
|
||||||
|
- Startseite / System: Systemübersicht, Cache, Performance, Status.
|
||||||
|
- Lernen: konfigurierte Aktoren und Lernstand.
|
||||||
|
- Details: genau ein ausgewählter Aktor.
|
||||||
|
- Discovery & Einrichtung: Geräteliste, Vorschläge und neue Aktoren.
|
||||||
|
- Einstellungen: Sprache und Standardverhalten.
|
||||||
|
- Ablauf: Bedienhinweise.
|
||||||
|
|
||||||
|
Beim Öffnen der Seite wird immer nur die Startseite geladen. Andere Ansichten
|
||||||
|
laden erst beim Öffnen.
|
||||||
|
|
||||||
|
## Kompakte Detaildaten
|
||||||
|
|
||||||
|
Neuer Endpunkt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /v1/actuators/{actuator_entity_id}/detail
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Endpunkt entfernt große Musterlisten und Snapshot-Muster aus dem ersten
|
||||||
|
Detailabruf. Geladen werden nur die Werte, die für die erste Detailansicht
|
||||||
|
benötigt werden. Kontextvorschläge bleiben ein separater Abruf und laufen erst
|
||||||
|
auf Nutzeraktion.
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
Die Sprache kann unter `Einstellungen` gewählt werden. Deutsch ist Standard.
|
||||||
|
Technische API-Werte bleiben stabil, werden aber im Dashboard über die
|
||||||
|
Sprachschicht angezeigt.
|
||||||
|
|
||||||
|
## Performance-Regeln
|
||||||
|
|
||||||
|
- Kein Discovery beim Start.
|
||||||
|
- Keine Aufgabenliste beim Start.
|
||||||
|
- Keine Kontextvorschläge beim Öffnen eines Aktors.
|
||||||
|
- Keine Musterlisten im ersten Detailabruf.
|
||||||
|
- Geräteübersicht rendert begrenzt und lädt weitere Karten per Button nach.
|
||||||
|
|
||||||
|
Die Angabe „bereit in X ms“ beschreibt nur den jeweiligen API-/Ansichtsabruf.
|
||||||
|
Sie ist nicht gleichzusetzen mit der kompletten HA/Ingress-Navigationszeit.
|
||||||
32
docs/V1_5_1_OPERATING_GUIDE.md
Normal file
32
docs/V1_5_1_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# SillyHome Next v1.5.1 Operating Guide
|
||||||
|
|
||||||
|
v1.5.1 ist ein Stabilisierungshotfix für die nach v1.2.0 entstandenen
|
||||||
|
Dashboard-Änderungen. Fachlich gehört diese Arbeit zur v1.2.x-Patchlinie; die
|
||||||
|
höhere technische Versionsnummer ist nur nötig, weil Home Assistant bereits
|
||||||
|
v1.5.0 installiert hat und Add-on-Updates monoton nach oben laufen.
|
||||||
|
|
||||||
|
## Korrekturen
|
||||||
|
|
||||||
|
- Die System-Startseite nutzt `GET /v1/actuators/dashboard/system` und lädt
|
||||||
|
keine Aktorenliste.
|
||||||
|
- Sichtbare 3-Sekunden-Abbrüche mit Browsertexten wie `signal is aborted
|
||||||
|
without reason` wurden entfernt.
|
||||||
|
- Startdaten und Detaildaten werden ohne künstlichen Frontend-Abbruch geladen.
|
||||||
|
- Timeout-Meldungen werden deutsch und verständlich angezeigt, wenn sie bei
|
||||||
|
Nebenprüfungen auftreten.
|
||||||
|
- `summary`-Zeilen wie `anzeigenaufklappen` haben jetzt Abstand und Layout.
|
||||||
|
|
||||||
|
## Ladeverhalten
|
||||||
|
|
||||||
|
- Statische Seite wird sofort gerendert.
|
||||||
|
- Systemdaten laden im Hintergrund.
|
||||||
|
- Lernen/Geräte laden nur im Menü `Lernen`.
|
||||||
|
- Discovery lädt nur im Menü `Discovery & Einrichtung`.
|
||||||
|
- Aktorwerte laden erst beim Öffnen der Detailansicht.
|
||||||
|
- Kontextvorschläge laden erst auf Nutzeraktion.
|
||||||
|
|
||||||
|
## Hinweis zur Performance-Anzeige
|
||||||
|
|
||||||
|
Die App zeigt keine echte HA/Ingress-Navigationszeit an. Gemessen werden nur
|
||||||
|
einzelne interne Abrufe nach Start der Seite. Aussagen zur gesamten Ladezeit
|
||||||
|
müssen über Browser/Ingress oder HA-Messung geprüft werden.
|
||||||
32
docs/V1_5_2_OPERATING_GUIDE.md
Normal file
32
docs/V1_5_2_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# SillyHome Next v1.5.2 Operating Guide
|
||||||
|
|
||||||
|
v1.5.2 begrenzt den Rollback-Speicher und entschärft Home-Assistant-Timeouts,
|
||||||
|
die in den Add-on-Logs sichtbar wurden.
|
||||||
|
|
||||||
|
## Rollback-Speicher
|
||||||
|
|
||||||
|
- Pro Aktor bleiben maximal 3 Modell-Snapshots erhalten.
|
||||||
|
- Pro Snapshot bleiben maximal 120 Muster erhalten.
|
||||||
|
- Beim Speichern eines Aktors werden ältere oder zu große Snapshots automatisch
|
||||||
|
gekappt.
|
||||||
|
- Der kompakte Detail-Endpunkt liefert ebenfalls maximal 3 Rollback-Snapshots
|
||||||
|
und keine Musterlisten.
|
||||||
|
|
||||||
|
Damit bleibt Rollback nutzbar, ohne dass die JSON-Dateien mit alten Modellen
|
||||||
|
stark wachsen.
|
||||||
|
|
||||||
|
## Home-Assistant-Zugriffe
|
||||||
|
|
||||||
|
- REST-Zugriffe auf Home Assistant haben jetzt standardmäßig 25 Sekunden
|
||||||
|
Timeout statt 10 Sekunden.
|
||||||
|
- Der Wert ist über `SILLYHOME_HA_TIMEOUT_SECONDS` konfigurierbar.
|
||||||
|
- WebSocket-Keepalive wurde auf 30 Sekunden Ping-Intervall und 30 Sekunden
|
||||||
|
Ping-Timeout entschärft.
|
||||||
|
|
||||||
|
## Log-Einordnung
|
||||||
|
|
||||||
|
- `GET ... HTTP/1.1` ist bei Uvicorn/HA-Ingress normal und kein Fehler.
|
||||||
|
- `Zeitüberschreitung beim Zugriff auf Home Assistant` bedeutet, dass HA selbst
|
||||||
|
zu langsam geantwortet hat oder der Ingress/Netzpfad verzögert war.
|
||||||
|
- `keepalive ping timeout` bedeutet, dass die HA-WebSocket-Verbindung nicht
|
||||||
|
rechtzeitig geantwortet hat. SillyHome reconnectet automatisch.
|
||||||
37
docs/V1_5_3_OPERATING_GUIDE.md
Normal file
37
docs/V1_5_3_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# SillyHome Next v1.5.3 Operating Guide
|
||||||
|
|
||||||
|
v1.5.3 führt eine SQLite-Cache-Schicht für Ingress-Dashboarddaten ein.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Die Ingress-Seite soll nicht bei jedem Aufruf live Home Assistant abfragen.
|
||||||
|
Home-Assistant-Daten werden geplant aktualisiert und lokal gelesen.
|
||||||
|
|
||||||
|
## SQLite-Cache
|
||||||
|
|
||||||
|
- Cache-Datei: `<actuator_store>/dashboard_cache.sqlite3`
|
||||||
|
- Tabelle `ha_entities`: aktuelle HA-Entity-Summaries als JSON
|
||||||
|
- Tabelle `cache_meta`: Aktualisierungszeitpunkt und Discovery-Gruppen
|
||||||
|
|
||||||
|
Dashboard-APIs lesen bevorzugt aus SQLite. Der alte JSON-Cache bleibt als
|
||||||
|
Fallback erhalten.
|
||||||
|
|
||||||
|
## Aktualisierung
|
||||||
|
|
||||||
|
- Beim App-Start läuft ein Hintergrund-Refresh nach kurzer Verzögerung.
|
||||||
|
- Danach läuft der Refresh stündlich.
|
||||||
|
- Konfiguration: `SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS`
|
||||||
|
- Mindestwert: 300 Sekunden.
|
||||||
|
- Explizite Discovery aktualisiert SQLite und JSON-Fallback.
|
||||||
|
|
||||||
|
## Schaltpfad
|
||||||
|
|
||||||
|
Das direkte Schalten bleibt unverändert: Safety prüft lokale Daten, danach geht
|
||||||
|
der Home-Assistant-Service-Call direkt raus. Der Dashboard-Cache liegt nicht im
|
||||||
|
Schaltpfad.
|
||||||
|
|
||||||
|
## Noch offen
|
||||||
|
|
||||||
|
Diese Version verschiebt Entity-/Discovery-Daten in SQLite. Die vollständige
|
||||||
|
Migration aller Aktor-Konfigurationen und Workflows aus JSON in relationale
|
||||||
|
Tabellen ist ein größerer Folgeschritt und muss mit Migrationsplan erfolgen.
|
||||||
35
docs/V1_5_4_OPERATING_GUIDE.md
Normal file
35
docs/V1_5_4_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# SillyHome Next v1.5.4 Operating Guide
|
||||||
|
|
||||||
|
Diese Version korrigiert Ingress-Logging und Dashboard-Navigation.
|
||||||
|
|
||||||
|
## Ingress-/Access-Logs
|
||||||
|
|
||||||
|
- Das Add-on startet Uvicorn ohne `--proxy-headers` und ohne
|
||||||
|
`--forwarded-allow-ips='*'`.
|
||||||
|
- Vorher konnte Uvicorn LAN-Adressen aus `X-Forwarded-For` anzeigen. Diese
|
||||||
|
Adresse war dann der urspruengliche Client oder Home-Assistant-Proxy, nicht
|
||||||
|
der direkte Container-Peer.
|
||||||
|
- Nach dem Update sollten Access-Logs den direkten Docker-/Ingress-Peer zeigen.
|
||||||
|
`GET ... HTTP/1.1` bleibt normal und ist kein Hinweis auf fehlendes Streaming.
|
||||||
|
|
||||||
|
## Dashboard-Verhalten
|
||||||
|
|
||||||
|
- Die Startseite nutzt weiter `/v1/actuators/dashboard/system`.
|
||||||
|
- Die Lernuebersicht nutzt weiter `/v1/actuators/dashboard/start`.
|
||||||
|
- Bereits geladene System-, Lern- und Discovery-Daten bleiben beim Wechseln der
|
||||||
|
Ansichten im Browser erhalten und werden nur im Hintergrund aufgefrischt.
|
||||||
|
- Details sind kein eigener Menuepunkt mehr. Sie werden nur ueber ein
|
||||||
|
ausgewaehltes beobachtetes Geraet geoeffnet.
|
||||||
|
- Ein bereits geoeffneter Aktor zeigt seine Detaildaten sofort aus dem
|
||||||
|
Browser-Cache. Neue Detaildaten werden erst ueber `Details aktualisieren`
|
||||||
|
oder nach einer Speichern-/Schaltaktion geladen.
|
||||||
|
|
||||||
|
## Pruefung
|
||||||
|
|
||||||
|
1. Add-on aktualisieren und neu starten.
|
||||||
|
2. Ingress hart neu laden.
|
||||||
|
3. Zwischen Startseite, Lernen und Discovery wechseln.
|
||||||
|
4. Erwartung: Bereits geladene Inhalte bleiben sichtbar; keine volle
|
||||||
|
Neuladung bei jedem Ansichtswechsel.
|
||||||
|
5. Details eines Aktors oeffnen, wegwechseln und wieder Details oeffnen.
|
||||||
|
Erwartung: Die zuletzt geladene Detailansicht steht sofort wieder da.
|
||||||
36
docs/V1_6_0_OPERATING_GUIDE.md
Normal file
36
docs/V1_6_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# SillyHome Next v1.6.0 Operating Guide
|
||||||
|
|
||||||
|
v1.6.0 trennt Startansicht, Aktoruebersicht, Discovery und Detaildaten staerker.
|
||||||
|
|
||||||
|
## API-Pfade
|
||||||
|
|
||||||
|
- `GET /v1/actuators/dashboard/system`
|
||||||
|
- nur System- und Cache-Metadaten
|
||||||
|
- keine Aktorenliste
|
||||||
|
- kein vollstaendiges Entity-Payload aus SQLite
|
||||||
|
- `GET /v1/actuators/dashboard/start`
|
||||||
|
- Aktor-Summaries
|
||||||
|
- Entity-Metadaten nur fuer konfigurierte Aktoren
|
||||||
|
- keine Discovery-Gruppen und keine Jobliste
|
||||||
|
- `GET /v1/actuators/discovery`
|
||||||
|
- steuerbare HA-Entities
|
||||||
|
- nutzt SQLite-Cache, liest HA nur bei Cache-Miss oder `refresh=true`
|
||||||
|
- `GET /v1/actuators/{id}/detail`
|
||||||
|
- genau ein ausgewaehlter Aktor
|
||||||
|
- kompakte Modell-/Kontextdaten
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
- Frontend zeigt Daten an und loest gezielte Aktionen aus.
|
||||||
|
- Backend liefert schlanke View-Daten.
|
||||||
|
- Worker aktualisieren HA-Entity-/Discovery-Cache beim Start und danach
|
||||||
|
stündlich.
|
||||||
|
- Die Detailansicht gehoert zu einem Aktor und hat eigene Navigation:
|
||||||
|
Zurueck, anderes Geraet, Aktualisieren.
|
||||||
|
|
||||||
|
## Erwartete Wirkung
|
||||||
|
|
||||||
|
- Systemstart muss ohne Entity-Materialisierung reagieren.
|
||||||
|
- Lernen und Details laden nur ihren eigenen Datenkern.
|
||||||
|
- Discovery bleibt ein eigener Bedarfspfad.
|
||||||
|
- Texte im Dashboard sind kurz und handlungsnah.
|
||||||
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# SillyHome Next v1.7.0 Operating Guide
|
||||||
|
|
||||||
|
v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und
|
||||||
|
Planungshilfen.
|
||||||
|
|
||||||
|
## Diagnose
|
||||||
|
|
||||||
|
- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor.
|
||||||
|
- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`.
|
||||||
|
- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde
|
||||||
|
vorhergesagt, wurde geschaltet oder blockiert, und warum.
|
||||||
|
|
||||||
|
## Backup und Restore
|
||||||
|
|
||||||
|
- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status
|
||||||
|
und Job-Historie als JSON.
|
||||||
|
- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein.
|
||||||
|
- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben.
|
||||||
|
|
||||||
|
## Dry-run
|
||||||
|
|
||||||
|
- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus.
|
||||||
|
- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht
|
||||||
|
an Home Assistant gesendet.
|
||||||
|
|
||||||
|
## Feedback
|
||||||
|
|
||||||
|
Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen:
|
||||||
|
|
||||||
|
- `correct`
|
||||||
|
- `wrong`
|
||||||
|
- `too_early`
|
||||||
|
- `too_late`
|
||||||
|
- `never_automate`
|
||||||
|
|
||||||
|
`never_automate` setzt eine manuelle Sicherheitssperre am Aktor.
|
||||||
|
|
||||||
|
## Planung
|
||||||
|
|
||||||
|
`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise:
|
||||||
|
|
||||||
|
- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten
|
||||||
|
- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten
|
||||||
|
- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "1.3.0"
|
version = "1.7.6"
|
||||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ def _service(
|
|||||||
|
|
||||||
|
|
||||||
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
|
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
|
||||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
start = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
entities = [
|
entities = [
|
||||||
HaEntitySummary(
|
HaEntitySummary(
|
||||||
entity_id="light.abstellkammer",
|
entity_id="light.abstellkammer",
|
||||||
@@ -141,6 +141,46 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
|
|||||||
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
|
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
|
||||||
|
|
||||||
|
|
||||||
|
def test_light_with_opening_context_does_not_require_brightness_sensor(tmp_path: Path) -> None:
|
||||||
|
start = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="light.abstellkammer",
|
||||||
|
domain="light",
|
||||||
|
friendly_name="Abstellkammer Licht",
|
||||||
|
area_name="Abstellkammer",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="sensor.abstellkammer_illuminance",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="illuminance",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_of_measurement="lx",
|
||||||
|
friendly_name="Abstellkammer Helligkeit",
|
||||||
|
area_name="Abstellkammer",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.abstellkammer_tuer",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="door",
|
||||||
|
friendly_name="Tür Abstellkammer",
|
||||||
|
area_name="Abstellkammer",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = _service(
|
||||||
|
tmp_path,
|
||||||
|
entities,
|
||||||
|
{"sensor.abstellkammer_illuminance": _points(8, start, 10.0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
record = service.configure_actuator("light.abstellkammer")
|
||||||
|
|
||||||
|
assert record.assignment.selected_numeric_entity_id is None
|
||||||
|
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_tuer"]
|
||||||
|
assert record.assignment.review_required is False
|
||||||
|
assert "kein Helligkeitssensor erforderlich" in record.assignment.reason
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
|
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
|
||||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
entities = [
|
entities = [
|
||||||
@@ -352,6 +392,100 @@ def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
|
|||||||
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
|
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None:
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="light.lidl_kuche",
|
||||||
|
domain="light",
|
||||||
|
friendly_name="Lidl Küche",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="light.lidl_wohnzimmer",
|
||||||
|
domain="light",
|
||||||
|
friendly_name="Lidl Wohnzimmer",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.pir_kuche_motion_detection",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="motion",
|
||||||
|
friendly_name="Bewegungsmelder",
|
||||||
|
device_name="PIR_Küche",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="motion",
|
||||||
|
friendly_name="Bewegungsmelder",
|
||||||
|
device_name="PIR_Wohnzimmer",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = _service(tmp_path, entities, {})
|
||||||
|
|
||||||
|
record = service.configure_actuator("light.lidl_kuche")
|
||||||
|
|
||||||
|
assert record.assignment.selected_context_entity_ids == [
|
||||||
|
"binary_sensor.pir_kuche_motion_detection"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None:
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="button.smart_mailbox_als_geleert_markieren",
|
||||||
|
domain="button",
|
||||||
|
friendly_name="Smart Mailbox Als geleert markieren",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.schrank_strasse_open",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="door",
|
||||||
|
friendly_name="Schrank Straße",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = _service(tmp_path, entities, {})
|
||||||
|
|
||||||
|
record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren")
|
||||||
|
|
||||||
|
assert record.assignment.selected_context_entity_ids == [
|
||||||
|
"binary_sensor.schrank_strasse_open"
|
||||||
|
]
|
||||||
|
assert record.assignment.review_required is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None:
|
||||||
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="humidifier.gastewc_luftung",
|
||||||
|
domain="humidifier",
|
||||||
|
friendly_name="GästeWC Lüftung",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="sensor.pir_gastewc_humidity",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="humidity",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_of_measurement="%",
|
||||||
|
friendly_name="Gäste WC Luftfeuchtigkeit",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="input_boolean.gaste_wc_occupied",
|
||||||
|
domain="input_boolean",
|
||||||
|
friendly_name="gaste_wc_occupied",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = _service(
|
||||||
|
tmp_path,
|
||||||
|
entities,
|
||||||
|
{"sensor.pir_gastewc_humidity": _points(8, start, 55.0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
record = service.configure_actuator("humidifier.gastewc_luftung")
|
||||||
|
|
||||||
|
assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity"
|
||||||
|
assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids
|
||||||
|
|
||||||
|
|
||||||
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
|
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
|
||||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
entities = [
|
entities = [
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from time import perf_counter
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1.actuators import _deduplicate_actuator_ids
|
||||||
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import JobStatus, ModelSnapshot
|
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.api.v1.actuators import _deduplicate_actuator_ids
|
|
||||||
from app.ha.discovery import DiscoveredEntity
|
from app.ha.discovery import DiscoveredEntity
|
||||||
from app.ha.discovery import discover_entities
|
from app.ha.discovery import discover_entities
|
||||||
from app.ha.history import (
|
from app.ha.history import (
|
||||||
@@ -146,6 +149,7 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
app.state.registry = ModelRegistry(tmp_path / "models")
|
app.state.registry = ModelRegistry(tmp_path / "models")
|
||||||
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
|
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
|
||||||
|
app.state.dashboard_cache = DashboardCache(tmp_path / "actuators" / "dashboard_cache.sqlite3")
|
||||||
app.state.ha_reader = FakeHaReader(
|
app.state.ha_reader = FakeHaReader(
|
||||||
entities,
|
entities,
|
||||||
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
|
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
|
||||||
@@ -269,6 +273,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No
|
|||||||
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_actuator_simulation_ranks_sensor_states_without_switching(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/assignment",
|
||||||
|
json={
|
||||||
|
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||||
|
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
store = app.state.actuator_store
|
||||||
|
record = store.get("light.abstellkammer")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
local = now.astimezone(ZoneInfo("Europe/Berlin"))
|
||||||
|
local_minute = local.hour * 60 + local.minute
|
||||||
|
patterns = [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=local_minute,
|
||||||
|
weekday=now.weekday(),
|
||||||
|
context_states={
|
||||||
|
"sensor.abstellkammer_illuminance": "12",
|
||||||
|
"binary_sensor.abstellkammer_motion": "on",
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
patterns.extend(
|
||||||
|
[
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="off",
|
||||||
|
minute_of_day=local_minute,
|
||||||
|
weekday=now.weekday(),
|
||||||
|
context_states={
|
||||||
|
"sensor.abstellkammer_illuminance": "12",
|
||||||
|
"binary_sensor.abstellkammer_motion": "off",
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
weight=0.5,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
store.upsert(
|
||||||
|
record.model_copy(
|
||||||
|
update={
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"patterns": patterns,
|
||||||
|
"sample_count": len(patterns),
|
||||||
|
"high_confidence_sample_count": len(patterns),
|
||||||
|
"activation_ready": True,
|
||||||
|
"activation_reason": "Testfreigabe.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/simulate",
|
||||||
|
json={
|
||||||
|
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
|
||||||
|
"sensor_weights": {
|
||||||
|
"binary_sensor.abstellkammer_motion": 1.0,
|
||||||
|
"sensor.abstellkammer_illuminance": 0.25,
|
||||||
|
},
|
||||||
|
"max_results": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload) == 2
|
||||||
|
assert payload[0]["prediction"]["target_state"] == "on"
|
||||||
|
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
|
||||||
|
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
|
||||||
|
assert app.state.ha_reader.service_calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
|
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
@@ -351,6 +440,51 @@ def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -
|
|||||||
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators",
|
||||||
|
json={"actuator_entity_id": "light.abstellkammer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/feedback",
|
||||||
|
json={"correct": False, "kind": "never_automate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feedback.status_code == 200
|
||||||
|
payload = feedback.json()
|
||||||
|
assert payload["behavior"]["safety"]["manual_block"] is True
|
||||||
|
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
backup = client.get("/v1/actuators/backup/export")
|
||||||
|
dry_run = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/dry-run",
|
||||||
|
json={"enabled": True},
|
||||||
|
)
|
||||||
|
planning = client.post("/v1/actuators/planning/refresh")
|
||||||
|
restore = client.post(
|
||||||
|
"/v1/actuators/backup/restore",
|
||||||
|
json={"backup": backup.json(), "replace_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert backup.status_code == 200
|
||||||
|
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
|
||||||
|
assert dry_run.status_code == 200
|
||||||
|
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
|
||||||
|
assert planning.status_code == 200
|
||||||
|
assert "agent_insights" in planning.json()[0]["behavior"]
|
||||||
|
assert restore.status_code == 200
|
||||||
|
assert restore.json()["restored_records"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
@@ -418,7 +552,7 @@ def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) -
|
|||||||
root_elapsed = perf_counter() - root_started_at
|
root_elapsed = perf_counter() - root_started_at
|
||||||
|
|
||||||
dashboard_started_at = perf_counter()
|
dashboard_started_at = perf_counter()
|
||||||
dashboard_response = client.get("/v1/actuators/dashboard")
|
dashboard_response = client.get("/v1/actuators/dashboard/start")
|
||||||
dashboard_elapsed = perf_counter() - dashboard_started_at
|
dashboard_elapsed = perf_counter() - dashboard_started_at
|
||||||
|
|
||||||
assert root_response.status_code == 200
|
assert root_response.status_code == 200
|
||||||
@@ -445,10 +579,18 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
|
|||||||
store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig")
|
store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig")
|
||||||
|
|
||||||
dashboard_response = client.get("/v1/actuators/dashboard")
|
dashboard_response = client.get("/v1/actuators/dashboard")
|
||||||
|
start_response = client.get("/v1/actuators/dashboard/start")
|
||||||
|
system_response = client.get("/v1/actuators/dashboard/system")
|
||||||
anomalies_response = client.get("/v1/actuators/anomalies")
|
anomalies_response = client.get("/v1/actuators/anomalies")
|
||||||
|
|
||||||
assert dashboard_response.status_code == 200
|
assert dashboard_response.status_code == 200
|
||||||
|
assert start_response.status_code == 200
|
||||||
|
assert system_response.status_code == 200
|
||||||
system = dashboard_response.json()["system"]
|
system = dashboard_response.json()["system"]
|
||||||
|
start_payload = start_response.json()
|
||||||
|
assert start_payload["jobs"]["jobs"] == []
|
||||||
|
assert start_payload["discovery_groups"] == []
|
||||||
|
assert system_response.json()["actuators"] == []
|
||||||
assert system["performance_budget_ms"] == 3000
|
assert system["performance_budget_ms"] == 3000
|
||||||
assert system["slow_job_count"] == 1
|
assert system["slow_job_count"] == 1
|
||||||
assert system["performance_status"] == "slow"
|
assert system["performance_status"] == "slow"
|
||||||
@@ -457,6 +599,66 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
|
|||||||
assert anomalies_response.json()
|
assert anomalies_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.get("/v1/actuators/discovery")
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
|
||||||
|
raise AssertionError("full entity payload must not be loaded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
|
||||||
|
|
||||||
|
system_response = client.get("/v1/actuators/dashboard/system")
|
||||||
|
start_response = client.get("/v1/actuators/dashboard/start")
|
||||||
|
|
||||||
|
assert system_response.status_code == 200
|
||||||
|
assert system_response.json()["actuators"] == []
|
||||||
|
assert system_response.json()["cache"]["entity_count"] == 4
|
||||||
|
assert start_response.status_code == 200
|
||||||
|
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
response = client.get("/v1/actuators/settings/rooms")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
room = payload["rooms"][0]
|
||||||
|
assert room["room"] == "Abstellkammer"
|
||||||
|
assert room["actuator_count"] == 1
|
||||||
|
actuator = room["actuators"][0]
|
||||||
|
assert actuator["actuator_entity_id"] == "light.abstellkammer"
|
||||||
|
assert actuator["sensors"]
|
||||||
|
assert actuator["prediction_rules"]
|
||||||
|
assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.get("/v1/actuators/discovery")
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
response = client.get("/v1/actuators/light.abstellkammer/detail")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["behavior"]["patterns"] == []
|
||||||
|
assert all(
|
||||||
|
snapshot["patterns"] == []
|
||||||
|
for snapshot in payload["behavior"]["model_snapshots"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
|
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
|
|||||||
@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
|
|||||||
) is None
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_prediction_respects_learned_context_delay() -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
patterns = [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"input_boolean.gaste_wc_occupied": "on"},
|
||||||
|
trigger_entity_id="input_boolean.gaste_wc_occupied",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
trigger_delay_seconds=180,
|
||||||
|
source="automation",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago in (3, 2, 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
early = predict_behavior(
|
||||||
|
patterns,
|
||||||
|
current_context={"input_boolean.gaste_wc_occupied": "on"},
|
||||||
|
current_context_changed_at={
|
||||||
|
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=30)
|
||||||
|
},
|
||||||
|
now=now,
|
||||||
|
min_support=3,
|
||||||
|
window_minutes=30,
|
||||||
|
causal_window_seconds=240,
|
||||||
|
)
|
||||||
|
due = predict_behavior(
|
||||||
|
patterns,
|
||||||
|
current_context={"input_boolean.gaste_wc_occupied": "on"},
|
||||||
|
current_context_changed_at={
|
||||||
|
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=185)
|
||||||
|
},
|
||||||
|
now=now,
|
||||||
|
min_support=3,
|
||||||
|
window_minutes=30,
|
||||||
|
causal_window_seconds=240,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert early is None
|
||||||
|
assert due is not None
|
||||||
|
assert due.target_state == "on"
|
||||||
|
|
||||||
|
|
||||||
|
def test_light_prediction_carries_brightness_attributes() -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
patterns = [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
target_attributes={"brightness": brightness},
|
||||||
|
minute_of_day=now.astimezone().hour * 60 + now.astimezone().minute,
|
||||||
|
weekday=now.astimezone().weekday(),
|
||||||
|
context_states={"binary_sensor.pir_kuche_motion_detection": "on"},
|
||||||
|
source="automation",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago, brightness in zip((3, 2, 1), (80, 90, 100), strict=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
prediction = predict_behavior(
|
||||||
|
patterns,
|
||||||
|
current_context={"binary_sensor.pir_kuche_motion_detection": "on"},
|
||||||
|
now=now,
|
||||||
|
min_support=3,
|
||||||
|
window_minutes=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prediction is not None
|
||||||
|
assert prediction.target_attributes["brightness"] == 90
|
||||||
|
|
||||||
|
|
||||||
def test_state_change_uses_websocket_context_state_for_immediate_action(
|
def test_state_change_uses_websocket_context_state_for_immediate_action(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -778,3 +853,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
|
|||||||
assert reader.service_calls == [
|
assert reader.service_calls == [
|
||||||
("light", "turn_on", {"entity_id": "light.storage"})
|
("light", "turn_on", {"entity_id": "light.storage"})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_evaluation_records_decision_timeline_and_latency(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
store = ActuatorStore(settings.actuator_store)
|
||||||
|
record = store.configure("light.storage")
|
||||||
|
record = record.model_copy(
|
||||||
|
update={
|
||||||
|
"assignment": record.assignment.model_copy(
|
||||||
|
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
|
||||||
|
),
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"mode": BehaviorMode.ACTIVE,
|
||||||
|
"status": BehaviorStatus.TRAINED,
|
||||||
|
"activation_ready": True,
|
||||||
|
"patterns": [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"binary_sensor.storage_door": "on"},
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
source="automation",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago in (3, 2, 1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.upsert(record)
|
||||||
|
reader = FakeBehaviorReader(
|
||||||
|
entities=[
|
||||||
|
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.storage_door",
|
||||||
|
domain="binary_sensor",
|
||||||
|
state="on",
|
||||||
|
last_changed=now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
history=[],
|
||||||
|
logbook=[],
|
||||||
|
)
|
||||||
|
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||||
|
|
||||||
|
result = engine.evaluate(
|
||||||
|
"light.storage",
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_state="on",
|
||||||
|
event_received_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = result.behavior.decision_timeline[-1]
|
||||||
|
latency = result.behavior.latency_measurements[-1]
|
||||||
|
assert trace.trigger_entity_id == "binary_sensor.storage_door"
|
||||||
|
assert trace.target_state == "on"
|
||||||
|
assert trace.executed is True
|
||||||
|
assert latency.trigger_entity_id == "binary_sensor.storage_door"
|
||||||
|
assert latency.executed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_records_without_calling_service(tmp_path: Path) -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
store = ActuatorStore(settings.actuator_store)
|
||||||
|
record = store.configure("light.storage")
|
||||||
|
record = record.model_copy(
|
||||||
|
update={
|
||||||
|
"assignment": record.assignment.model_copy(
|
||||||
|
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
|
||||||
|
),
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"mode": BehaviorMode.ACTIVE,
|
||||||
|
"status": BehaviorStatus.TRAINED,
|
||||||
|
"activation_ready": True,
|
||||||
|
"dry_run_enabled": True,
|
||||||
|
"patterns": [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"binary_sensor.storage_door": "on"},
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
source="automation",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago in (3, 2, 1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.upsert(record)
|
||||||
|
reader = FakeBehaviorReader(
|
||||||
|
entities=[
|
||||||
|
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.storage_door",
|
||||||
|
domain="binary_sensor",
|
||||||
|
state="on",
|
||||||
|
last_changed=now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
history=[],
|
||||||
|
logbook=[],
|
||||||
|
)
|
||||||
|
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||||
|
|
||||||
|
result = engine.evaluate("light.storage")
|
||||||
|
|
||||||
|
assert reader.service_calls == []
|
||||||
|
assert result.behavior.dry_run_sample_count == 1
|
||||||
|
assert result.behavior.decision_timeline[-1].executed is False
|
||||||
|
|||||||
@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
|
|||||||
assert [point.state for point in result[0].points] == ["off", "on"]
|
assert [point.state for point in result[0].points] == ["off", "on"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_state_history_keeps_light_attribute_changes() -> None:
|
||||||
|
result = normalize_state_history_payload(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "light.office",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {"brightness": 80, "friendly_name": "Office"},
|
||||||
|
"last_changed": "2026-06-01T08:00:00+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {"brightness": 120, "friendly_name": "Office"},
|
||||||
|
"last_changed": "2026-06-01T08:05:00+00:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [point.attributes["brightness"] for point in result[0].points] == [80, 120]
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_logbook_preserves_action_origin() -> None:
|
def test_normalize_logbook_preserves_action_origin() -> None:
|
||||||
result = normalize_logbook_payload(
|
result = normalize_logbook_payload(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
|
|||||||
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
|
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
|
||||||
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
|
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
|
||||||
assert config_copy < repository_clone
|
assert config_copy < repository_clone
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_does_not_trust_forwarded_lan_ips() -> None:
|
||||||
|
run_script = Path("addon/run.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "--proxy-headers" not in run_script
|
||||||
|
assert "--forwarded-allow-ips" not in run_script
|
||||||
|
|||||||
@@ -9,29 +9,36 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "SillyHome Next" in response.text
|
assert "SillyHome Next" in response.text
|
||||||
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
|
assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
|
||||||
assert "So gehst du vor" in response.text
|
assert "So gehst du vor" not in response.text
|
||||||
assert "Steuerung" in response.text
|
assert "Discovery & Einrichtung" in response.text
|
||||||
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
|
assert "Entity-ID" in response.text
|
||||||
assert "Oder aus Liste wählen" in response.text
|
assert "Geräteliste" in response.text
|
||||||
assert "Liste durchsuchen" in response.text
|
assert "Liste durchsuchen" in response.text
|
||||||
assert "Geräteliste bei Bedarf laden" in response.text
|
assert "Geräteliste bei Bedarf laden" in response.text
|
||||||
assert "Vorschläge können Home Assistant stark abfragen" in response.text
|
assert "Vorschläge können Home Assistant stark abfragen" not in response.text
|
||||||
assert "Wie gewohnt bedienen" in response.text
|
assert '<option value="detail">Details</option>' not in response.text
|
||||||
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
|
assert "Wie gewohnt bedienen" not in response.text
|
||||||
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
|
assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
|
||||||
|
assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
|
||||||
assert "Freigabestatus" in response.text
|
assert "Freigabestatus" in response.text
|
||||||
|
assert "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort." in response.text
|
||||||
|
assert "room-management" in response.text
|
||||||
|
assert 'api("v1/actuators/settings/rooms")' in response.text
|
||||||
|
assert "Auswahl speichern" in response.text
|
||||||
assert "SillyHome übernehmen lassen" in response.text
|
assert "SillyHome übernehmen lassen" in response.text
|
||||||
assert "Passende Home-Assistant-Automationen" in response.text
|
assert "Passende Home-Assistant-Automationen" in response.text
|
||||||
assert "Pausieren" in response.text
|
assert "Pausieren" in response.text
|
||||||
assert "Davon erkannte HA-Automationen" in response.text
|
assert "Erkannte HA-Automationen" in response.text
|
||||||
assert "Aktuelle Situation auswerten" in response.text
|
assert "Aktuelle Situation auswerten" in response.text
|
||||||
assert "Kontext selbst festlegen" in response.text
|
assert "Kontext selbst festlegen" in response.text
|
||||||
assert "Entity-IDs manuell ergänzen" in response.text
|
assert "Entity-IDs manuell ergänzen" in response.text
|
||||||
|
assert "Zurück zur Übersicht" in response.text
|
||||||
|
assert "Anderes Gerät" in response.text
|
||||||
assert "manual-context-freeform" in response.text
|
assert "manual-context-freeform" in response.text
|
||||||
assert "Diese Kontext-Auswahl speichern" in response.text
|
assert "Diese Kontext-Auswahl speichern" in response.text
|
||||||
assert "manual-context-select" in response.text
|
assert "manual-context-select" in response.text
|
||||||
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
|
assert "Die Prüfung simuliert keinen Sensorwechsel" not in response.text
|
||||||
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
|
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
|
||||||
assert "Vorhersage jetzt prüfen" not in response.text
|
assert "Vorhersage jetzt prüfen" not in response.text
|
||||||
assert "record.behavior.activation_ready" in response.text
|
assert "record.behavior.activation_ready" in response.text
|
||||||
@@ -40,7 +47,9 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
assert 'api("v1/actuators")' not in response.text
|
assert 'api("v1/actuators")' not in response.text
|
||||||
assert 'api("v1/actuators/summary")' in response.text
|
assert 'api("v1/actuators/summary")' in response.text
|
||||||
assert 'api("v1/entities")' not in response.text
|
assert 'api("v1/entities")' not in response.text
|
||||||
assert 'details class="collapsible"' in response.text
|
assert 'details class="collapsible"' not in response.text
|
||||||
assert 'class="group-panel"' in response.text
|
assert 'class="group-panel"' in response.text
|
||||||
|
assert "cachedDetailHtml" in response.text
|
||||||
|
assert "refreshOverviewInBackground" in response.text
|
||||||
assert "Automation-Entwurf" not in response.text
|
assert "Automation-Entwurf" not in response.text
|
||||||
assert "Manuelle Overrides" not in response.text
|
assert "Manuelle Overrides" not in response.text
|
||||||
|
|||||||
@@ -92,11 +92,10 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
connect.assert_called_once_with(
|
connect.assert_called_once_with(
|
||||||
"ws://homeassistant:8123/api/websocket",
|
"ws://homeassistant:8123/api/websocket",
|
||||||
ping_interval=20,
|
ping_interval=None,
|
||||||
ping_timeout=10,
|
)
|
||||||
)
|
|
||||||
assert fake_ws.sent == [
|
assert fake_ws.sent == [
|
||||||
{"type": "auth", "access_token": "test-token"},
|
{"type": "auth", "access_token": "test-token"},
|
||||||
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
||||||
@@ -127,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
assert mock_app.state.ws_status.error is None
|
assert mock_app.state.ws_status.error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
|
||||||
|
async def run_test() -> None:
|
||||||
|
fake_ws = _FakeWebSocket(
|
||||||
|
[
|
||||||
|
'{"type":"auth_required"}',
|
||||||
|
'{"type":"auth_ok"}',
|
||||||
|
(
|
||||||
|
'{"type":"event","event":{"event_type":"state_changed",'
|
||||||
|
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
|
||||||
|
),
|
||||||
|
asyncio.CancelledError(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("websockets.connect", return_value=fake_ws):
|
||||||
|
try:
|
||||||
|
await _ha_event_listener(mock_app, mock_client)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.state.settings = MagicMock()
|
||||||
|
mock_app.state.settings.ha_url = "http://homeassistant:8123"
|
||||||
|
mock_app.state.settings.ha_token = "test-token"
|
||||||
|
mock_app.state.ws_status = MagicMock()
|
||||||
|
mock_engine = _RecordingBehaviorEngine(tmp_path)
|
||||||
|
mock_app.state.behavior_engine = mock_engine
|
||||||
|
mock_app.state.ha_reader = _FakeHaReader()
|
||||||
|
mock_store = ActuatorStore(tmp_path / "store")
|
||||||
|
mock_store.configure("light.test")
|
||||||
|
mock_app.state.actuator_store = mock_store
|
||||||
|
mock_client = MagicMock()
|
||||||
|
|
||||||
|
anyio.run(run_test)
|
||||||
|
assert mock_engine.state_changes == []
|
||||||
|
|
||||||
|
|
||||||
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.state.settings = MagicMock()
|
app.state.settings = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user