Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8070a85b52 | |||
| 575211f0db | |||
| d9dc186f9b | |||
| 214b384b70 | |||
| 6323b93f23 | |||
| 1d176cce45 | |||
| bd087728e1 | |||
| 10f9113547 | |||
| 47fa8eb0ce | |||
| bc4e33ddd8 | |||
| 9419a9cd8c | |||
| 2ec2c64cba | |||
| 0101596e93 | |||
| ca253d1e6c | |||
| b9b5def7bb |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
|
/.actuator_store/
|
||||||
|
/MagicMock/
|
||||||
|
|||||||
84
CHANGELOG.md
84
CHANGELOG.md
@@ -1,5 +1,89 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.7.0 - 2026-06-18
|
||||||
|
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
|
||||||
|
Event-Latenzmessungen und Dry-run pro Aktor.
|
||||||
|
- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und
|
||||||
|
sichtbare Job-Historie.
|
||||||
|
- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und
|
||||||
|
`never_automate` speichern; `never_automate` setzt eine manuelle Sperre.
|
||||||
|
- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und
|
||||||
|
lokale Agent-Insights aus vorhandenen Daten.
|
||||||
|
- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro
|
||||||
|
Home-Assistant-State-Change.
|
||||||
|
|
||||||
|
## 1.6.1 - 2026-06-18
|
||||||
|
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
|
||||||
|
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
|
||||||
|
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
|
||||||
|
|
||||||
|
## 1.6.0 - 2026-06-18
|
||||||
|
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
|
||||||
|
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
|
||||||
|
materialisieren.
|
||||||
|
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
|
||||||
|
der Aktor-IDs.
|
||||||
|
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
|
||||||
|
Versions-Chip im Einrichtungsbereich.
|
||||||
|
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
|
||||||
|
anderen beobachteten Geraets.
|
||||||
|
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
|
||||||
|
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
|
||||||
|
|
||||||
|
## 1.5.4 - 2026-06-18
|
||||||
|
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
|
||||||
|
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.
|
||||||
|
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
|
||||||
|
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
|
||||||
|
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
|
||||||
|
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
|
||||||
|
laden nur bei expliziter Aktualisierung neu.
|
||||||
|
|
||||||
|
## 1.2.0 - 2026-06-17
|
||||||
|
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
||||||
|
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
||||||
|
wertet sie vorsichtig ab.
|
||||||
|
- Modell-Snapshots mit aktivem Modellstand und Rollback-API ergaenzt.
|
||||||
|
- Dashboard zeigt Modell-Snapshots, Rollback, Zeitprofile,
|
||||||
|
adaptive Gewichtungsupdates und Automation-Konflikte.
|
||||||
|
- Automation-Refresh markiert Konflikte, wenn SillyHome aktiv ist und passende
|
||||||
|
HA-Automationen parallel aktiv bleiben.
|
||||||
|
- Zeitprofile fuer Nacht, Morgen, Tag, Abend und Wochenende werden aus
|
||||||
|
gelernten Handlungen gebildet.
|
||||||
|
|
||||||
|
## 1.1.0 - 2026-06-17
|
||||||
|
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
|
||||||
|
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
|
||||||
|
Unsicherheiten und Beitragsfaktoren pro Aktor.
|
||||||
|
- Lokales Safety-Profil pro Aktor eingefuehrt: manuelle Sperre,
|
||||||
|
Freigabestufe, Mindest-Confidence und optionaler Cooldown werden vor
|
||||||
|
autonomem Schalten ausgewertet.
|
||||||
|
- Sofort-Schaltpfad bleibt schnell: Safety prueft nur lokale Daten; der
|
||||||
|
Home-Assistant-Serviceaufruf wird nicht durch Discovery, Training oder
|
||||||
|
Statistik blockiert.
|
||||||
|
- Sichtbare Job-Queue fuer Discovery, Reconciliation, Training, Evaluation
|
||||||
|
und Automation-Refresh mit Status, Dauer, Fehler und Zusammenfassung.
|
||||||
|
- Entscheidungsstatistik erweitert: Sensor-/Kontextfaktoren, aktive
|
||||||
|
Gewichtungen, Sample-/Confidence-Trends und Feedbackzaehler werden
|
||||||
|
persistiert.
|
||||||
|
|
||||||
|
## 1.0.5 - 2026-06-17
|
||||||
|
- Lange Friendly Names, Entity-IDs, Chips, Tabellenwerte und Metriken brechen
|
||||||
|
im Dashboard responsiv um und laufen nicht mehr aus Karten oder Boxen.
|
||||||
|
- Automatisierter Performance-Budget-Test fuer Root-HTML und
|
||||||
|
`/v1/actuators/dashboard` gegen das 5-Sekunden-Limit ergaenzt.
|
||||||
|
- HA-/Ingress-Verifikation mit Supervisor-Status, Backup, Watchdog,
|
||||||
|
Hard-Reload und Rollback im Operating Guide dokumentiert.
|
||||||
|
|
||||||
|
## 1.0.4 - 2026-06-17
|
||||||
|
- Sensor-Relevanz ist in der Aktor-Detailansicht sichtbar: automatische
|
||||||
|
Relevanz, aktive Gewichtung und Score werden pro verwendetem Sensor/Zustand
|
||||||
|
angezeigt.
|
||||||
|
- Gewichtungen koennen im Dashboard korrigiert und per API unter
|
||||||
|
`/v1/actuators/{actuator_entity_id}/weights` gespeichert werden.
|
||||||
|
- Gruppen-Gewichtungen buendeln mehrere Sensoren/Zustaende fuer einen Aktor,
|
||||||
|
damit verbundene Kontextsignale gemeinsam bewertet werden koennen.
|
||||||
|
|
||||||
## 1.0.3 - 2026-06-17
|
## 1.0.3 - 2026-06-17
|
||||||
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
|
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
|
||||||
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.
|
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -15,6 +15,24 @@ nach einer ausdrücklichen Freigabe ausführen.
|
|||||||
[`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md)
|
[`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md)
|
||||||
- Version 1.0.x Abnahme und offene Punkte:
|
- Version 1.0.x Abnahme und offene Punkte:
|
||||||
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
|
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
|
||||||
|
- Version 1.1.0 Safety, Transparenz und Job-Queue:
|
||||||
|
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
|
||||||
|
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.3.0 Anomalie- und Performance-Überwachung:
|
||||||
|
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
|
||||||
|
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
|
||||||
|
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
|
||||||
|
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.2 Rollback-Speicher und HA-Timeouts:
|
||||||
|
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
|
||||||
|
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
|
||||||
|
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
|
||||||
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
||||||
|
|
||||||
## Reifegrad
|
## Reifegrad
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.0.3"
|
version: "1.7.1"
|
||||||
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),
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ from app.actuators.models import (
|
|||||||
ManualOverride,
|
ManualOverride,
|
||||||
ModelLifecycleState,
|
ModelLifecycleState,
|
||||||
ReconciliationState,
|
ReconciliationState,
|
||||||
|
SensorWeightGroup,
|
||||||
model_id_for_actuator,
|
model_id_for_actuator,
|
||||||
)
|
)
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
@@ -239,6 +240,10 @@ class ActuatorReconciliationService:
|
|||||||
override = ManualOverride(
|
override = ManualOverride(
|
||||||
numeric_entity_id=numeric_entity_id,
|
numeric_entity_id=numeric_entity_id,
|
||||||
context_entity_ids=selected_context_ids,
|
context_entity_ids=selected_context_ids,
|
||||||
|
sensor_weights=record.manual_override.sensor_weights if record.manual_override else {},
|
||||||
|
sensor_weight_groups=(
|
||||||
|
record.manual_override.sensor_weight_groups if record.manual_override else []
|
||||||
|
),
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
note=note,
|
note=note,
|
||||||
)
|
)
|
||||||
@@ -253,17 +258,23 @@ class ActuatorReconciliationService:
|
|||||||
update={
|
update={
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"manual_override": override,
|
"manual_override": override,
|
||||||
"numeric_candidates": _merge_manual_candidates(
|
"numeric_candidates": _apply_weight_overrides(
|
||||||
record.numeric_candidates,
|
_merge_manual_candidates(
|
||||||
entities,
|
record.numeric_candidates,
|
||||||
[numeric_entity_id] if numeric_entity_id else [],
|
entities,
|
||||||
role=EntityRole.MEASUREMENT,
|
[numeric_entity_id] if numeric_entity_id else [],
|
||||||
|
role=EntityRole.MEASUREMENT,
|
||||||
|
),
|
||||||
|
override,
|
||||||
),
|
),
|
||||||
"context_candidates": _merge_manual_candidates(
|
"context_candidates": _apply_weight_overrides(
|
||||||
record.context_candidates,
|
_merge_manual_candidates(
|
||||||
entities,
|
record.context_candidates,
|
||||||
selected_context_ids,
|
entities,
|
||||||
role=EntityRole.CONTEXT,
|
selected_context_ids,
|
||||||
|
role=EntityRole.CONTEXT,
|
||||||
|
),
|
||||||
|
override,
|
||||||
),
|
),
|
||||||
"lifecycle": lifecycle,
|
"lifecycle": lifecycle,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
@@ -271,6 +282,65 @@ class ActuatorReconciliationService:
|
|||||||
)
|
)
|
||||||
return self._store.upsert(updated)
|
return self._store.upsert(updated)
|
||||||
|
|
||||||
|
def set_weight_overrides(
|
||||||
|
self,
|
||||||
|
actuator_entity_id: str,
|
||||||
|
*,
|
||||||
|
sensor_weights: dict[str, float],
|
||||||
|
sensor_weight_groups: list[SensorWeightGroup],
|
||||||
|
note: str | None = None,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
selected_ids = {
|
||||||
|
entity_id
|
||||||
|
for entity_id in [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
}
|
||||||
|
selected_ids.update(sensor_weights)
|
||||||
|
for group in sensor_weight_groups:
|
||||||
|
selected_ids.update(group.entity_ids)
|
||||||
|
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
|
||||||
|
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(sorted(missing))}")
|
||||||
|
|
||||||
|
previous = record.manual_override
|
||||||
|
override = ManualOverride(
|
||||||
|
numeric_entity_id=(
|
||||||
|
previous.numeric_entity_id
|
||||||
|
if previous is not None
|
||||||
|
else record.assignment.selected_numeric_entity_id
|
||||||
|
),
|
||||||
|
context_entity_ids=(
|
||||||
|
previous.context_entity_ids
|
||||||
|
if previous is not None
|
||||||
|
else record.assignment.selected_context_entity_ids
|
||||||
|
),
|
||||||
|
sensor_weights={entity_id: round(weight, 4) for entity_id, weight in sensor_weights.items()},
|
||||||
|
sensor_weight_groups=sensor_weight_groups,
|
||||||
|
updated_at=now,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
updated = record.model_copy(
|
||||||
|
update={
|
||||||
|
"manual_override": override,
|
||||||
|
"numeric_candidates": _apply_weight_overrides(
|
||||||
|
record.numeric_candidates,
|
||||||
|
override,
|
||||||
|
),
|
||||||
|
"context_candidates": _apply_weight_overrides(
|
||||||
|
record.context_candidates,
|
||||||
|
override,
|
||||||
|
),
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._store.upsert(updated)
|
||||||
|
|
||||||
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
|
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
|
||||||
state = self._store.load_reconciliation_state().model_copy(
|
state = self._store.load_reconciliation_state().model_copy(
|
||||||
update={
|
update={
|
||||||
@@ -376,6 +446,9 @@ class ActuatorReconciliationService:
|
|||||||
),
|
),
|
||||||
context=True,
|
context=True,
|
||||||
)
|
)
|
||||||
|
if record.manual_override is not None:
|
||||||
|
numeric_candidates = _apply_weight_overrides(numeric_candidates, record.manual_override)
|
||||||
|
context_candidates = _apply_weight_overrides(context_candidates, record.manual_override)
|
||||||
assignment = (
|
assignment = (
|
||||||
self._manual_assignment(record.manual_override)
|
self._manual_assignment(record.manual_override)
|
||||||
if record.manual_override is not None
|
if record.manual_override is not None
|
||||||
@@ -918,6 +991,41 @@ def _merge_manual_candidates(
|
|||||||
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
|
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_weight_overrides(
|
||||||
|
candidates: list[AssignmentCandidate],
|
||||||
|
override: ManualOverride,
|
||||||
|
) -> list[AssignmentCandidate]:
|
||||||
|
if not override.sensor_weights and not override.sensor_weight_groups:
|
||||||
|
return candidates
|
||||||
|
group_weights: dict[str, float] = {}
|
||||||
|
for group in override.sensor_weight_groups:
|
||||||
|
for entity_id in group.entity_ids:
|
||||||
|
group_weights[entity_id] = max(group_weights.get(entity_id, 0.0), group.weight)
|
||||||
|
weighted: list[AssignmentCandidate] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
explicit = override.sensor_weights.get(candidate.entity_id)
|
||||||
|
group_weight = group_weights.get(candidate.entity_id)
|
||||||
|
manual_weight = explicit if explicit is not None else group_weight
|
||||||
|
effective_weight = manual_weight if manual_weight is not None else 1.0
|
||||||
|
evidence = [
|
||||||
|
item
|
||||||
|
for item in candidate.evidence
|
||||||
|
if not item.startswith("Manuelle Gewichtung:")
|
||||||
|
]
|
||||||
|
if manual_weight is not None:
|
||||||
|
evidence.append(f"Manuelle Gewichtung: {round(manual_weight * 100)} %.")
|
||||||
|
weighted.append(
|
||||||
|
candidate.model_copy(
|
||||||
|
update={
|
||||||
|
"manual_weight": manual_weight,
|
||||||
|
"effective_weight": round(effective_weight, 4),
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(weighted, key=lambda item: (-item.confidence * item.effective_weight, item.entity_id))
|
||||||
|
|
||||||
|
|
||||||
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
||||||
if context:
|
if context:
|
||||||
mapping = {
|
mapping = {
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ class BehaviorStatus(StrEnum):
|
|||||||
BLOCKED = "blocked"
|
BLOCKED = "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyStage(StrEnum):
|
||||||
|
OBSERVE = "observe"
|
||||||
|
SUGGEST = "suggest"
|
||||||
|
SHADOW = "shadow"
|
||||||
|
PARTIAL = "partial"
|
||||||
|
ACTIVE = "active"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackKind(StrEnum):
|
||||||
|
CORRECT = "correct"
|
||||||
|
WRONG = "wrong"
|
||||||
|
TOO_EARLY = "too_early"
|
||||||
|
TOO_LATE = "too_late"
|
||||||
|
NEVER_AUTOMATE = "never_automate"
|
||||||
|
|
||||||
|
|
||||||
class AssignmentCandidate(BaseModel):
|
class AssignmentCandidate(BaseModel):
|
||||||
entity_id: str
|
entity_id: str
|
||||||
domain: str
|
domain: str
|
||||||
@@ -49,6 +72,8 @@ class AssignmentCandidate(BaseModel):
|
|||||||
device_name: str | None = None
|
device_name: str | None = None
|
||||||
score: float = Field(ge=0.0)
|
score: float = Field(ge=0.0)
|
||||||
confidence: float = Field(ge=0.0, le=1.0)
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
manual_weight: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
effective_weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
auto_accepted: bool = False
|
auto_accepted: bool = False
|
||||||
evidence: list[str] = Field(default_factory=list)
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -62,9 +87,18 @@ class AssignmentSelection(BaseModel):
|
|||||||
reason: str = "Noch keine Zuordnung vorhanden."
|
reason: str = "Noch keine Zuordnung vorhanden."
|
||||||
|
|
||||||
|
|
||||||
|
class SensorWeightGroup(BaseModel):
|
||||||
|
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class ManualOverride(BaseModel):
|
class ManualOverride(BaseModel):
|
||||||
numeric_entity_id: str | None = None
|
numeric_entity_id: str | None = None
|
||||||
context_entity_ids: list[str] = Field(default_factory=list)
|
context_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
@@ -110,11 +144,148 @@ class BehaviorPrediction(BaseModel):
|
|||||||
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
|
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionFactor(BaseModel):
|
||||||
|
entity_id: str | None = None
|
||||||
|
label: str
|
||||||
|
factor_type: str = Field(max_length=40)
|
||||||
|
state: str | None = None
|
||||||
|
weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
|
contribution: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationOutcome(BaseModel):
|
||||||
|
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
actuator_entity_id: str
|
||||||
|
sensor_states: dict[str, str] = Field(default_factory=dict)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
prediction: BehaviorPrediction | None = None
|
||||||
|
decision_factors: list[DecisionFactor] = Field(default_factory=list)
|
||||||
|
would_execute: bool = False
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
recommendation: str = Field(default="", max_length=700)
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionTrace(BaseModel):
|
||||||
|
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
trigger_entity_id: str | None = None
|
||||||
|
trigger_state: str | None = None
|
||||||
|
target_state: str | None = None
|
||||||
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
executed: bool = False
|
||||||
|
blocked: bool = False
|
||||||
|
reason: str = Field(default="", max_length=700)
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LatencyMeasurement(BaseModel):
|
||||||
|
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
trigger_entity_id: str | None = None
|
||||||
|
event_to_decision_ms: int | None = Field(default=None, ge=0)
|
||||||
|
decision_to_service_ms: int | None = Field(default=None, ge=0)
|
||||||
|
event_to_done_ms: int | None = Field(default=None, ge=0)
|
||||||
|
executed: bool = False
|
||||||
|
source: str = Field(default="manual", max_length=40)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveWeightUpdate(BaseModel):
|
||||||
|
entity_id: str
|
||||||
|
previous_weight: float = Field(ge=0.0, le=1.0)
|
||||||
|
new_weight: float = Field(ge=0.0, le=1.0)
|
||||||
|
reason: str = Field(max_length=300)
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyRule(BaseModel):
|
||||||
|
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
label: str = Field(min_length=1, max_length=160)
|
||||||
|
enabled: bool = True
|
||||||
|
blocking: bool = True
|
||||||
|
reason: str = Field(default="", max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
def default_safety_rules() -> list[SafetyRule]:
|
||||||
|
return [
|
||||||
|
SafetyRule(
|
||||||
|
rule_id="activation_ready",
|
||||||
|
label="Nur nach Lernfreigabe aktiv schalten",
|
||||||
|
reason="Der Aktor muss genug eindeutiges Verhalten gelernt haben.",
|
||||||
|
),
|
||||||
|
SafetyRule(
|
||||||
|
rule_id="confidence_threshold",
|
||||||
|
label="Mindest-Sicherheit einhalten",
|
||||||
|
reason="Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus.",
|
||||||
|
),
|
||||||
|
SafetyRule(
|
||||||
|
rule_id="cooldown",
|
||||||
|
label="Sicherheits-Cooldown gegen Hin-und-her-Schalten",
|
||||||
|
reason="Gleiche Zielzustände werden nicht zu schnell wiederholt.",
|
||||||
|
),
|
||||||
|
SafetyRule(
|
||||||
|
rule_id="manual_block",
|
||||||
|
label="Manuelle Sperre respektieren",
|
||||||
|
reason="Nutzer können jeden Aktor sofort blockieren.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyProfile(BaseModel):
|
||||||
|
stage: SafetyStage = SafetyStage.SHADOW
|
||||||
|
manual_block: bool = False
|
||||||
|
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
|
||||||
|
min_confidence_on: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
min_confidence_off: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
cooldown_seconds: int | None = Field(default=None, ge=0)
|
||||||
|
rules: list[SafetyRule] = Field(default_factory=default_safety_rules)
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class ExecutionEvent(BaseModel):
|
class ExecutionEvent(BaseModel):
|
||||||
target_state: str
|
target_state: str
|
||||||
executed_at: datetime
|
executed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSnapshot(BaseModel):
|
||||||
|
version_id: str
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
sample_count: int = Field(default=0, ge=0)
|
||||||
|
high_confidence_sample_count: int = Field(default=0, ge=0)
|
||||||
|
average_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
incorrect_feedback_count: int = Field(default=0, ge=0)
|
||||||
|
patterns: list[BehaviorPattern] = Field(default_factory=list)
|
||||||
|
reason: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationConflict(BaseModel):
|
||||||
|
automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
|
||||||
|
severity: str = Field(default="info", max_length=20)
|
||||||
|
status: str = Field(default="open", max_length=40)
|
||||||
|
reason: str = Field(max_length=500)
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class AnomalyEvent(BaseModel):
|
||||||
|
anomaly_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
severity: str = Field(default="info", max_length=20)
|
||||||
|
category: str = Field(max_length=40)
|
||||||
|
title: str = Field(min_length=1, max_length=160)
|
||||||
|
detail: str = Field(min_length=1, max_length=500)
|
||||||
|
detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
resolved: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TimeProfile(BaseModel):
|
||||||
|
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
label: str = Field(min_length=1, max_length=80)
|
||||||
|
sample_count: int = Field(default=0, ge=0)
|
||||||
|
dominant_state: str | None = None
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class RelatedAutomation(BaseModel):
|
class RelatedAutomation(BaseModel):
|
||||||
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
|
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
|
||||||
config_id: str = Field(min_length=1, max_length=120)
|
config_id: str = Field(min_length=1, max_length=120)
|
||||||
@@ -122,6 +293,32 @@ class RelatedAutomation(BaseModel):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ActuatorGroup(BaseModel):
|
||||||
|
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
area_name: str | None = Field(default=None, max_length=120)
|
||||||
|
member_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
reason: str = Field(default="", max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneSuggestion(BaseModel):
|
||||||
|
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
label: str = Field(min_length=1, max_length=120)
|
||||||
|
member_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
reason: str = Field(default="", max_length=500)
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentInsight(BaseModel):
|
||||||
|
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
severity: str = Field(default="info", max_length=20)
|
||||||
|
title: str = Field(min_length=1, max_length=160)
|
||||||
|
detail: str = Field(min_length=1, max_length=700)
|
||||||
|
action: str | None = Field(default=None, max_length=300)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class BehaviorState(BaseModel):
|
class BehaviorState(BaseModel):
|
||||||
mode: BehaviorMode = BehaviorMode.SHADOW
|
mode: BehaviorMode = BehaviorMode.SHADOW
|
||||||
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
||||||
@@ -139,6 +336,32 @@ class BehaviorState(BaseModel):
|
|||||||
related_automations: list[RelatedAutomation] = Field(default_factory=list)
|
related_automations: list[RelatedAutomation] = Field(default_factory=list)
|
||||||
paused_automation_entity_ids: list[str] = Field(default_factory=list)
|
paused_automation_entity_ids: list[str] = Field(default_factory=list)
|
||||||
reason: str = "Historische Aktorhandlungen werden analysiert."
|
reason: str = "Historische Aktorhandlungen werden analysiert."
|
||||||
|
safety: SafetyProfile = Field(default_factory=SafetyProfile)
|
||||||
|
decision_factors: list[DecisionFactor] = Field(default_factory=list)
|
||||||
|
knowledge: list[str] = Field(default_factory=list)
|
||||||
|
assumptions: list[str] = Field(default_factory=list)
|
||||||
|
uncertainties: list[str] = Field(default_factory=list)
|
||||||
|
safety_blockers: list[str] = Field(default_factory=list)
|
||||||
|
sample_trend: list[int] = Field(default_factory=list)
|
||||||
|
confidence_trend: list[float] = Field(default_factory=list)
|
||||||
|
correct_feedback_count: int = Field(default=0, ge=0)
|
||||||
|
incorrect_feedback_count: int = Field(default=0, ge=0)
|
||||||
|
model_snapshots: list[ModelSnapshot] = Field(default_factory=list)
|
||||||
|
active_model_version: str | None = None
|
||||||
|
adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
|
||||||
|
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
||||||
|
time_profiles: list[TimeProfile] = Field(default_factory=list)
|
||||||
|
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
||||||
|
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
|
||||||
|
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
|
||||||
|
feedback_log: list[FeedbackKind] = Field(default_factory=list)
|
||||||
|
dry_run_enabled: bool = False
|
||||||
|
dry_run_started_at: datetime | None = None
|
||||||
|
dry_run_sample_count: int = Field(default=0, ge=0)
|
||||||
|
dry_run_hit_count: int = Field(default=0, ge=0)
|
||||||
|
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
|
||||||
|
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
|
||||||
|
agent_insights: list[AgentInsight] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ActuatorRecord(BaseModel):
|
class ActuatorRecord(BaseModel):
|
||||||
@@ -165,5 +388,22 @@ class ReconciliationState(BaseModel):
|
|||||||
last_summary: str = "Noch keine Reconciliation ausgeführt."
|
last_summary: str = "Noch keine Reconciliation ausgeführt."
|
||||||
|
|
||||||
|
|
||||||
|
class JobQueueItem(BaseModel):
|
||||||
|
job_id: str = Field(min_length=1, max_length=120)
|
||||||
|
kind: str = Field(min_length=1, max_length=40)
|
||||||
|
target: str | None = Field(default=None, max_length=160)
|
||||||
|
trigger: str = Field(default="manual", max_length=40)
|
||||||
|
status: JobStatus = JobStatus.PENDING
|
||||||
|
started_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
error: str | None = Field(default=None, max_length=500)
|
||||||
|
summary: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class JobQueueState(BaseModel):
|
||||||
|
jobs: list[JobQueueItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def model_id_for_actuator(actuator_entity_id: str) -> str:
|
def model_id_for_actuator(actuator_entity_id: str) -> str:
|
||||||
return f"actuator.{actuator_entity_id}"
|
return f"actuator.{actuator_entity_id}"
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from threading import RLock
|
|||||||
|
|
||||||
from app.actuators.models import (
|
from app.actuators.models import (
|
||||||
ActuatorRecord,
|
ActuatorRecord,
|
||||||
|
JobQueueItem,
|
||||||
|
JobQueueState,
|
||||||
|
JobStatus,
|
||||||
LifecycleStatus,
|
LifecycleStatus,
|
||||||
ModelLifecycleState,
|
ModelLifecycleState,
|
||||||
ReconciliationState,
|
ReconciliationState,
|
||||||
@@ -22,6 +25,7 @@ class ActuatorStore:
|
|||||||
self._actuators_root.mkdir(parents=True, exist_ok=True)
|
self._actuators_root.mkdir(parents=True, exist_ok=True)
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
self._reconciliation_state_path = self._root / "reconciliation_state.json"
|
self._reconciliation_state_path = self._root / "reconciliation_state.json"
|
||||||
|
self._job_queue_path = self._root / "job_queue.json"
|
||||||
|
|
||||||
def list(self) -> list[ActuatorRecord]:
|
def list(self) -> list[ActuatorRecord]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -85,6 +89,80 @@ class ActuatorStore:
|
|||||||
self._persist_reconciliation_state(state)
|
self._persist_reconciliation_state(state)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
def load_job_queue(self) -> JobQueueState:
|
||||||
|
with self._lock:
|
||||||
|
if not self._job_queue_path.exists():
|
||||||
|
return JobQueueState()
|
||||||
|
try:
|
||||||
|
return JobQueueState.model_validate_json(
|
||||||
|
self._job_queue_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Ungültiger Job-Queue-Status.") from exc
|
||||||
|
|
||||||
|
def save_job_queue(self, queue: JobQueueState) -> JobQueueState:
|
||||||
|
with self._lock:
|
||||||
|
self._persist_job_queue(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def start_job(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
trigger: str,
|
||||||
|
target: str | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
) -> JobQueueItem:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
job = JobQueueItem(
|
||||||
|
job_id=f"{now.strftime('%Y%m%d%H%M%S%f')}-{kind}-{target or 'all'}",
|
||||||
|
kind=kind,
|
||||||
|
target=target,
|
||||||
|
trigger=trigger,
|
||||||
|
status=JobStatus.RUNNING,
|
||||||
|
started_at=now,
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
queue = self.load_job_queue()
|
||||||
|
queue.jobs = [*queue.jobs, job][-50:]
|
||||||
|
self._persist_job_queue(queue)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def finish_job(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
status: JobStatus,
|
||||||
|
summary: str = "",
|
||||||
|
error: str | None = None,
|
||||||
|
) -> JobQueueItem | None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self._lock:
|
||||||
|
queue = self.load_job_queue()
|
||||||
|
updated_job: JobQueueItem | None = None
|
||||||
|
jobs: list[JobQueueItem] = []
|
||||||
|
for job in queue.jobs:
|
||||||
|
if job.job_id != job_id:
|
||||||
|
jobs.append(job)
|
||||||
|
continue
|
||||||
|
duration_ms = None
|
||||||
|
if job.started_at is not None:
|
||||||
|
duration_ms = max(0, int((now - job.started_at).total_seconds() * 1000))
|
||||||
|
updated_job = job.model_copy(
|
||||||
|
update={
|
||||||
|
"status": status,
|
||||||
|
"completed_at": now,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"summary": summary or job.summary,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
jobs.append(updated_job)
|
||||||
|
queue.jobs = jobs[-50:]
|
||||||
|
self._persist_job_queue(queue)
|
||||||
|
return updated_job
|
||||||
|
|
||||||
def _target(self, actuator_entity_id: str) -> Path:
|
def _target(self, actuator_entity_id: str) -> Path:
|
||||||
if "." not in actuator_entity_id:
|
if "." not in actuator_entity_id:
|
||||||
raise ValueError("Ungültige actuator_entity_id.")
|
raise ValueError("Ungültige actuator_entity_id.")
|
||||||
@@ -108,6 +186,14 @@ class ActuatorStore:
|
|||||||
)
|
)
|
||||||
os.replace(temporary, self._reconciliation_state_path)
|
os.replace(temporary, self._reconciliation_state_path)
|
||||||
|
|
||||||
|
def _persist_job_queue(self, state: JobQueueState) -> None:
|
||||||
|
temporary = self._job_queue_path.with_suffix(".json.tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, self._job_queue_path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load(path: Path) -> ActuatorRecord:
|
def _load(path: Path) -> ActuatorRecord:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,8 +8,17 @@ 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, ReconciliationState
|
from app.actuators.models import (
|
||||||
|
ActuatorRecord,
|
||||||
|
AnomalyEvent,
|
||||||
|
FeedbackKind,
|
||||||
|
ReconciliationState,
|
||||||
|
SensorWeightGroup,
|
||||||
|
SimulationOutcome,
|
||||||
|
)
|
||||||
|
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
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -44,9 +53,54 @@ class ManualAssignmentRequest(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightOverrideRequest(BaseModel):
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
|
||||||
|
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):
|
||||||
|
safety: SafetyProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRollbackRequest(BaseModel):
|
||||||
|
version_id: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
class ActuatorSuggestion(BaseModel):
|
class ActuatorSuggestion(BaseModel):
|
||||||
@@ -74,6 +128,8 @@ class ActuatorSummary(BaseModel):
|
|||||||
activation_ready: bool
|
activation_ready: bool
|
||||||
activation_reason: str
|
activation_reason: str
|
||||||
sample_count: int
|
sample_count: int
|
||||||
|
anomaly_count: int = 0
|
||||||
|
critical_anomaly_count: int = 0
|
||||||
prediction_target_state: str | None = None
|
prediction_target_state: str | None = None
|
||||||
prediction_confidence: float | None = None
|
prediction_confidence: float | None = None
|
||||||
updated_at: str
|
updated_at: str
|
||||||
@@ -93,6 +149,12 @@ class DashboardSystemStatus(BaseModel):
|
|||||||
configured_actuators: int = 0
|
configured_actuators: int = 0
|
||||||
trained_models: int = 0
|
trained_models: int = 0
|
||||||
review_required: int = 0
|
review_required: int = 0
|
||||||
|
performance_budget_ms: int = 3000
|
||||||
|
job_p95_duration_ms: int | None = None
|
||||||
|
slow_job_count: int = 0
|
||||||
|
performance_status: str = "unknown"
|
||||||
|
anomaly_count: int = 0
|
||||||
|
critical_anomaly_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class DashboardDiscoveryGroup(BaseModel):
|
class DashboardDiscoveryGroup(BaseModel):
|
||||||
@@ -106,6 +168,13 @@ class DashboardOverview(BaseModel):
|
|||||||
cache: EntityCacheStatus
|
cache: EntityCacheStatus
|
||||||
actuators: list[ActuatorSummary]
|
actuators: list[ActuatorSummary]
|
||||||
discovery_groups: list[DashboardDiscoveryGroup]
|
discovery_groups: list[DashboardDiscoveryGroup]
|
||||||
|
jobs: JobQueueState = Field(default_factory=JobQueueState)
|
||||||
|
|
||||||
|
|
||||||
|
class AnomalyOverview(BaseModel):
|
||||||
|
actuator_entity_id: str
|
||||||
|
friendly_name: str | None = None
|
||||||
|
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||||
@@ -118,8 +187,19 @@ def discover_actuators(
|
|||||||
if cached_entities:
|
if cached_entities:
|
||||||
entities = {entity.entity_id: entity for entity in cached_entities}
|
entities = {entity.entity_id: entity for entity in cached_entities}
|
||||||
else:
|
else:
|
||||||
fresh_entities = list(ha_reader.read_entities())
|
job = _start_job(
|
||||||
_save_cached_entities(request, fresh_entities)
|
request,
|
||||||
|
kind="discovery",
|
||||||
|
trigger="manual" if refresh else "cache-miss",
|
||||||
|
summary="Home-Assistant-Entities werden gelesen und klassifiziert.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
fresh_entities = list(ha_reader.read_entities())
|
||||||
|
_save_cached_entities(request, fresh_entities)
|
||||||
|
except Exception as exc:
|
||||||
|
_finish_job(job, request, status=JobStatus.FAILED, summary="Discovery fehlgeschlagen.", error=str(exc))
|
||||||
|
raise
|
||||||
|
_finish_job(job, request, status=JobStatus.COMPLETED, summary=f"{len(fresh_entities)} Entities klassifiziert.")
|
||||||
entities = {entity.entity_id: entity for entity in fresh_entities}
|
entities = {entity.entity_id: entity for entity in fresh_entities}
|
||||||
discovered = discover_entities(list(entities.values()))
|
discovered = discover_entities(list(entities.values()))
|
||||||
actuator_ids = _deduplicate_actuator_ids(
|
actuator_ids = _deduplicate_actuator_ids(
|
||||||
@@ -239,6 +319,14 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
|
|||||||
activation_ready=record.behavior.activation_ready,
|
activation_ready=record.behavior.activation_ready,
|
||||||
activation_reason=record.behavior.activation_reason,
|
activation_reason=record.behavior.activation_reason,
|
||||||
sample_count=record.behavior.sample_count,
|
sample_count=record.behavior.sample_count,
|
||||||
|
anomaly_count=len([item for item in record.behavior.anomalies if not item.resolved]),
|
||||||
|
critical_anomaly_count=len(
|
||||||
|
[
|
||||||
|
item
|
||||||
|
for item in record.behavior.anomalies
|
||||||
|
if not item.resolved and item.severity == "critical"
|
||||||
|
]
|
||||||
|
),
|
||||||
prediction_target_state=(
|
prediction_target_state=(
|
||||||
record.behavior.prediction.target_state
|
record.behavior.prediction.target_state
|
||||||
if record.behavior.prediction is not None
|
if record.behavior.prediction is not None
|
||||||
@@ -257,21 +345,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)
|
||||||
|
jobs = (
|
||||||
|
store.load_job_queue()
|
||||||
|
if include_background and isinstance(store, ActuatorStore)
|
||||||
|
else JobQueueState()
|
||||||
|
)
|
||||||
|
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
|
||||||
|
anomaly_count = sum(record.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"),
|
||||||
@@ -281,20 +397,95 @@ 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,
|
||||||
|
slow_job_count=slow_job_count,
|
||||||
|
performance_status=performance_status,
|
||||||
|
anomaly_count=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,
|
||||||
|
jobs=jobs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anomalies", response_model=list[AnomalyOverview])
|
||||||
|
def list_anomalies(request: Request) -> list[AnomalyOverview]:
|
||||||
|
records = _service(request).list_configured()
|
||||||
|
entity_map = _load_cached_entity_map(
|
||||||
|
request,
|
||||||
|
{record.actuator_entity_id for record in records},
|
||||||
|
)
|
||||||
|
overview: list[AnomalyOverview] = []
|
||||||
|
for record in records:
|
||||||
|
active = [item for item in record.behavior.anomalies if not item.resolved]
|
||||||
|
if not active:
|
||||||
|
continue
|
||||||
|
entity = entity_map.get(record.actuator_entity_id)
|
||||||
|
overview.append(
|
||||||
|
AnomalyOverview(
|
||||||
|
actuator_entity_id=record.actuator_entity_id,
|
||||||
|
friendly_name=entity.friendly_name if entity is not None else None,
|
||||||
|
anomalies=active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return overview
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
@@ -321,6 +512,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)
|
||||||
@@ -350,6 +582,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,
|
||||||
@@ -361,11 +615,50 @@ 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)
|
||||||
|
def set_safety_profile(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: SafetyProfileRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
return _behavior(request).set_safety_profile(actuator_entity_id, profile=payload.safety)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/model/rollback", response_model=ActuatorRecord)
|
||||||
|
def rollback_model(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: ModelRollbackRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
return _behavior(request).rollback_model(actuator_entity_id, version_id=payload.version_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
|
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
|
||||||
def set_activation(
|
def set_activation(
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -406,6 +699,27 @@ def set_manual_assignment(
|
|||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/weights", response_model=ActuatorRecord)
|
||||||
|
def set_weight_overrides(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: WeightOverrideRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
_validate_weight_payload(payload)
|
||||||
|
record = _service(request).set_weight_overrides(
|
||||||
|
actuator_entity_id,
|
||||||
|
sensor_weights=payload.sensor_weights,
|
||||||
|
sensor_weight_groups=payload.sensor_weight_groups,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{actuator_entity_id}/related-automations/refresh",
|
"/{actuator_entity_id}/related-automations/refresh",
|
||||||
response_model=ActuatorRecord,
|
response_model=ActuatorRecord,
|
||||||
@@ -414,11 +728,27 @@ def refresh_related_automations(
|
|||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> ActuatorRecord:
|
) -> ActuatorRecord:
|
||||||
|
job = _start_job(
|
||||||
|
request,
|
||||||
|
kind="automation_refresh",
|
||||||
|
trigger="manual",
|
||||||
|
target=actuator_entity_id,
|
||||||
|
summary="Passende HA-Automationen werden gesucht.",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return _behavior(request).refresh_related_automations(actuator_entity_id)
|
record = _behavior(request).refresh_related_automations(actuator_entity_id)
|
||||||
|
_finish_job(
|
||||||
|
job,
|
||||||
|
request,
|
||||||
|
status=JobStatus.COMPLETED,
|
||||||
|
summary=f"{len(record.behavior.related_automations)} Automationen gefunden.",
|
||||||
|
)
|
||||||
|
return record
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except (ValueError, HaClientError) as exc:
|
except (ValueError, HaClientError) as exc:
|
||||||
|
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -459,12 +789,105 @@ def run_reconciliation(
|
|||||||
request: Request,
|
request: Request,
|
||||||
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
|
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
|
||||||
) -> ReconciliationState:
|
) -> ReconciliationState:
|
||||||
state = _service(request).reconcile_all(trigger=trigger)
|
reconciliation_job = _start_job(
|
||||||
_behavior(request).train_all()
|
request,
|
||||||
_behavior(request).evaluate_all()
|
kind="reconciliation",
|
||||||
|
trigger=trigger,
|
||||||
|
summary="Kontext, Zuordnung und Modelle werden abgeglichen.",
|
||||||
|
)
|
||||||
|
training_job: JobQueueItem | None = None
|
||||||
|
evaluation_job: JobQueueItem | None = None
|
||||||
|
try:
|
||||||
|
state = _service(request).reconcile_all(trigger=trigger)
|
||||||
|
_finish_job(reconciliation_job, request, status=JobStatus.COMPLETED, summary=state.last_summary)
|
||||||
|
reconciliation_job = None
|
||||||
|
training_job = _start_job(
|
||||||
|
request,
|
||||||
|
kind="training",
|
||||||
|
trigger=trigger,
|
||||||
|
summary="Gelernte Aktorhandlungen werden aktualisiert.",
|
||||||
|
)
|
||||||
|
_behavior(request).train_all()
|
||||||
|
_finish_job(training_job, request, status=JobStatus.COMPLETED, summary="Training abgeschlossen.")
|
||||||
|
training_job = None
|
||||||
|
evaluation_job = _start_job(
|
||||||
|
request,
|
||||||
|
kind="evaluation",
|
||||||
|
trigger=trigger,
|
||||||
|
summary="Aktuelle Vorhersagen werden neu berechnet.",
|
||||||
|
)
|
||||||
|
_behavior(request).evaluate_all()
|
||||||
|
_finish_job(evaluation_job, request, status=JobStatus.COMPLETED, summary="Evaluation abgeschlossen.")
|
||||||
|
evaluation_job = None
|
||||||
|
except Exception as exc:
|
||||||
|
for job in [reconciliation_job, training_job, evaluation_job]:
|
||||||
|
if isinstance(job, JobQueueItem) and job.status is JobStatus.RUNNING:
|
||||||
|
_finish_job(job, request, status=JobStatus.FAILED, summary="Job fehlgeschlagen.", error=str(exc))
|
||||||
|
raise
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/job-queue/state", response_model=JobQueueState)
|
||||||
|
def get_job_queue(request: Request) -> JobQueueState:
|
||||||
|
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 store.load_job_queue()
|
||||||
|
|
||||||
|
|
||||||
|
def _start_job(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
trigger: str,
|
||||||
|
target: str | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
) -> JobQueueItem | None:
|
||||||
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
|
if not isinstance(store, ActuatorStore):
|
||||||
|
return None
|
||||||
|
return store.start_job(kind=kind, trigger=trigger, target=target, summary=summary)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_job(
|
||||||
|
job: JobQueueItem | None,
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
status: JobStatus,
|
||||||
|
summary: str,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
|
if not isinstance(store, ActuatorStore):
|
||||||
|
return
|
||||||
|
store.finish_job(job.job_id, status=status, summary=summary, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
def _performance_status(jobs: JobQueueState) -> tuple[int | None, int, str]:
|
||||||
|
budget_ms = 3000
|
||||||
|
durations = sorted(
|
||||||
|
job.duration_ms
|
||||||
|
for job in jobs.jobs
|
||||||
|
if job.status is JobStatus.COMPLETED and job.duration_ms is not None
|
||||||
|
)
|
||||||
|
slow_count = sum(1 for duration in durations if duration >= budget_ms)
|
||||||
|
if durations:
|
||||||
|
index = min(len(durations) - 1, int(round((len(durations) - 1) * 0.95)))
|
||||||
|
p95: int | None = durations[index]
|
||||||
|
status_value = "slow" if slow_count else "ok"
|
||||||
|
else:
|
||||||
|
p95 = None
|
||||||
|
status_value = "unknown"
|
||||||
|
if any(job.status is JobStatus.RUNNING for job in jobs.jobs):
|
||||||
|
status_value = "running" if status_value == "unknown" else status_value
|
||||||
|
return p95, slow_count, status_value
|
||||||
|
|
||||||
|
|
||||||
def _service(request: Request) -> ActuatorReconciliationService:
|
def _service(request: Request) -> ActuatorReconciliationService:
|
||||||
service = getattr(request.app.state, "actuator_service", None)
|
service = getattr(request.app.state, "actuator_service", None)
|
||||||
if not isinstance(service, ActuatorReconciliationService):
|
if not isinstance(service, ActuatorReconciliationService):
|
||||||
@@ -485,6 +908,36 @@ def _behavior(request: Request) -> BehaviorEngine:
|
|||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
|
||||||
|
for entity_id, weight in payload.sensor_weights.items():
|
||||||
|
if "." not in entity_id:
|
||||||
|
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
|
||||||
|
if not 0.0 <= weight <= 1.0:
|
||||||
|
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
|
||||||
|
for group in payload.sensor_weight_groups:
|
||||||
|
if not group.entity_ids:
|
||||||
|
raise ValueError(f"Gruppe {group.name} enthält keine Entities.")
|
||||||
|
for entity_id in group.entity_ids:
|
||||||
|
if "." not in entity_id:
|
||||||
|
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
@@ -524,6 +977,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):
|
||||||
@@ -542,7 +1000,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 {}
|
||||||
@@ -554,18 +1040,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")
|
||||||
@@ -576,6 +1062,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],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
58
app/main.py
58
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.0.3",
|
version="1.7.1",
|
||||||
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:
|
||||||
@@ -211,11 +259,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "connecting"
|
ws_status.status = "connecting"
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
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":
|
||||||
@@ -267,8 +311,6 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
continue
|
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(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,21 @@ wget -qO /tmp/summary.json http://58adbe1e-sillyhome-next:8000/v1/actuators/summ
|
|||||||
wget -qO /tmp/dashboard.json http://58adbe1e-sillyhome-next:8000/v1/actuators/dashboard
|
wget -qO /tmp/dashboard.json http://58adbe1e-sillyhome-next:8000/v1/actuators/dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Wenn der Add-on-Container aus dem Agent-Host nicht direkt routbar ist, gilt der
|
||||||
|
Home-Assistant-Supervisor als Verifikationsquelle:
|
||||||
|
|
||||||
|
- Add-on-Info pruefen: Version, `version_latest`, `update_available`, `state`,
|
||||||
|
`boot` und `watchdog`.
|
||||||
|
- Vor Updates eine Home-Assistant-Teil-Sicherung fuer **SillyHome Next**
|
||||||
|
erstellen.
|
||||||
|
- Nach einem Store-Reload und Update muss `version == version_latest`,
|
||||||
|
`update_available == false`, `state == started`, `boot == auto` und
|
||||||
|
`watchdog == true` gelten.
|
||||||
|
- Den HA-/Ingress-Tab nach jedem Update hart neu laden, weil Home Assistant
|
||||||
|
sonst alte HTML-/JavaScript-Ressourcen aus dem bestehenden Tab verwenden kann.
|
||||||
|
- Rollback erfolgt ueber die vorherige Add-on-Teil-Sicherung oder den letzten
|
||||||
|
Git-Tag; beide Referenzen im Release-/Abnahmeprotokoll notieren.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
Der stabile Vor-1.0-Stand ist `v0.7.21`. Vor dem 1.0.0-Umbau wurde ein
|
Der stabile Vor-1.0-Stand ist `v0.7.21`. Vor dem 1.0.0-Umbau wurde ein
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ expliziter Freigabe.
|
|||||||
- `ruff check .`
|
- `ruff check .`
|
||||||
- `mypy app backend tests`
|
- `mypy app backend tests`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
- Performance-Budget:
|
||||||
|
- Automatisierter Test prueft Root-HTML und `/v1/actuators/dashboard` gegen
|
||||||
|
das 5-Sekunden-Budget mit kontrollierten Fake-HA-/Cache-Daten.
|
||||||
|
- HA-/Ingress-Verifikation:
|
||||||
|
- Supervisor-Update, Add-on-Status, Watchdog, Backup, Ingress-Hard-Reload
|
||||||
|
und Rollback sind im Operating Guide dokumentiert.
|
||||||
|
|
||||||
## Teilweise Erfuellt
|
## Teilweise Erfuellt
|
||||||
|
|
||||||
@@ -62,14 +68,10 @@ expliziter Freigabe.
|
|||||||
|
|
||||||
## Offen Fuer v1.0.x
|
## Offen Fuer v1.0.x
|
||||||
|
|
||||||
- Echte Dashboard-Performance-Budget-Tests, die Start-HTML und
|
|
||||||
`/v1/actuators/dashboard` gegen ein 5-Sekunden-Limit messen.
|
|
||||||
- Dashboard-Jobstatus fuer Reconciliation, Training, Discovery und
|
- Dashboard-Jobstatus fuer Reconciliation, Training, Discovery und
|
||||||
Automation-Refresh.
|
Automation-Refresh.
|
||||||
- Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark
|
- Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark
|
||||||
beigetragen haben, wie sich Confidence und Sample Count entwickeln.
|
beigetragen haben, wie sich Confidence und Sample Count entwickeln.
|
||||||
- Dokumentierte HA-Installationspruefung mit Supervisor-/Ingress-Hinweisen,
|
|
||||||
weil direkte Container-HTTP-Pruefung ausserhalb HA nicht immer routbar ist.
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
|
|||||||
72
docs/V1_1_0_OPERATING_GUIDE.md
Normal file
72
docs/V1_1_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# SillyHome Next v1.1.0 Operating Guide
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
v1.1.0 macht das Dashboard zur Zentrale fuer Visualisierung, Einrichtung,
|
||||||
|
Sicherheit und manuelles Gegensteuern. Autonomes Schalten bleibt ein kurzer
|
||||||
|
lokaler Pfad: Vorhersage und Safety-Profil werden aus bereits vorhandenen Daten
|
||||||
|
bewertet, danach folgt direkt der Home-Assistant-Serviceaufruf.
|
||||||
|
|
||||||
|
## Sicherheitsmodell
|
||||||
|
|
||||||
|
Jeder Aktor hat ein Safety-Profil:
|
||||||
|
|
||||||
|
- `stage`: Beobachten, Vorschlagen, Shadow, Teilaktiv oder Aktiv.
|
||||||
|
- `manual_block`: harte manuelle Sperre.
|
||||||
|
- `min_confidence`: Mindest-Sicherheit fuer autonomes Schalten.
|
||||||
|
- `cooldown_seconds`: optionaler Aktor-Cooldown gegen schnelles Hin-und-her.
|
||||||
|
- Safety-Regeln: Freigabe, Confidence, Cooldown und manuelle Sperre.
|
||||||
|
|
||||||
|
Ein Aktor schaltet nur, wenn alle lokalen Safety-Regeln frei sind, der
|
||||||
|
Behavior-Modus aktiv ist, die Freigabe bereit ist, die Confidence passt, der
|
||||||
|
Zielzustand noch nicht erreicht ist und der Cooldown abgelaufen ist.
|
||||||
|
|
||||||
|
## Transparenz
|
||||||
|
|
||||||
|
Die Aktor-Detailansicht trennt:
|
||||||
|
|
||||||
|
- Wissen: belegte Fakten aus Historie, Zuordnung und Automationen.
|
||||||
|
- Annahmen: heuristische Schluesse wie Zeit-/Kontext-Aehnlichkeit.
|
||||||
|
- Unsicherheiten: geringe Datenmenge, unklare Quellen, Review-Bedarf oder
|
||||||
|
negatives Feedback.
|
||||||
|
- Beitragsfaktoren: Sensoren, Kontextsignale, aktive Gewichtung und Beitrag.
|
||||||
|
- Safety-Blocker: Gruende, warum nicht geschaltet wird.
|
||||||
|
|
||||||
|
## Job-Queue
|
||||||
|
|
||||||
|
Das Dashboard zeigt die letzten Jobs mit Status, Dauer, Fehler und
|
||||||
|
Zusammenfassung. Sichtbar sind:
|
||||||
|
|
||||||
|
- Discovery
|
||||||
|
- Reconciliation
|
||||||
|
- Training
|
||||||
|
- Evaluation
|
||||||
|
- Automation-Refresh
|
||||||
|
|
||||||
|
Die Queue ist persistent in `job_queue.json` und dient als Betriebsanzeige. Sie
|
||||||
|
blockiert nicht den Startpfad und nicht den Schaltpfad.
|
||||||
|
|
||||||
|
## Manuelles Gegensteuern
|
||||||
|
|
||||||
|
Im Dashboard koennen pro Aktor gesetzt werden:
|
||||||
|
|
||||||
|
- manuelle Sicherheitssperre
|
||||||
|
- Freigabestufe
|
||||||
|
- Mindest-Confidence
|
||||||
|
- optionaler Cooldown
|
||||||
|
- Sensor-Gewichtungen und Gruppen-Gewichtungen
|
||||||
|
- Kontextauswahl
|
||||||
|
- Feedback: Vorhersage korrekt/falsch
|
||||||
|
- HA-Automationen pausieren/fortsetzen
|
||||||
|
|
||||||
|
## Qualitaetspruefung
|
||||||
|
|
||||||
|
Vor Release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest -q
|
||||||
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/mypy app backend tests
|
||||||
|
git diff --check
|
||||||
|
node --check /tmp/sillyhome-dashboard.js
|
||||||
|
```
|
||||||
62
docs/V1_2_0_OPERATING_GUIDE.md
Normal file
62
docs/V1_2_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# SillyHome Next v1.2.0 Operating Guide
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
v1.2.0 erweitert die sichere v1.1-Grundlage um adaptive Lernfunktionen. Diese
|
||||||
|
Funktionen laufen bei Feedback, Training oder Automation-Refresh und blockieren
|
||||||
|
nicht den direkten Schaltpfad.
|
||||||
|
|
||||||
|
## Adaptive Gewichtung
|
||||||
|
|
||||||
|
Feedback passt die Gewichtung aktuell beteiligter Kontextsignale vorsichtig an:
|
||||||
|
|
||||||
|
- korrektes Feedback: +3 Prozentpunkte bis maximal 100 %
|
||||||
|
- falsches Feedback: -8 Prozentpunkte bis minimal 10 %
|
||||||
|
|
||||||
|
Die Aenderungen werden als `adaptive_weight_updates` gespeichert und im
|
||||||
|
Dashboard angezeigt. Manuelle Gewichtungen bleiben weiter direkt korrigierbar.
|
||||||
|
|
||||||
|
## Modell-Snapshots und Rollback
|
||||||
|
|
||||||
|
Bei jedem Training wird ein Snapshot gespeichert:
|
||||||
|
|
||||||
|
- Version-ID
|
||||||
|
- Sample Count
|
||||||
|
- eindeutig zugeordnete Handlungen
|
||||||
|
- durchschnittliche Confidence
|
||||||
|
- negative Feedbacks
|
||||||
|
- Musterliste
|
||||||
|
- Begruendung
|
||||||
|
|
||||||
|
Ueber das Dashboard kann auf einen frueheren Snapshot zurueckgerollt werden.
|
||||||
|
|
||||||
|
## Automation-Konflikte
|
||||||
|
|
||||||
|
Beim Automation-Refresh markiert SillyHome Konflikte, wenn:
|
||||||
|
|
||||||
|
- SillyHome fuer einen Aktor aktiv ist
|
||||||
|
- eine passende Home-Assistant-Automation ebenfalls aktiv bleibt
|
||||||
|
|
||||||
|
Pausierte Automationen werden als kontrolliert markiert.
|
||||||
|
|
||||||
|
## Zeitprofile
|
||||||
|
|
||||||
|
SillyHome bildet Profile fuer:
|
||||||
|
|
||||||
|
- Nacht
|
||||||
|
- Morgen
|
||||||
|
- Tag
|
||||||
|
- Abend
|
||||||
|
- Wochenende
|
||||||
|
|
||||||
|
Diese Profile zeigen Sample Count, dominanten Zielzustand und Profilklarheit.
|
||||||
|
|
||||||
|
## Performance-Grenze
|
||||||
|
|
||||||
|
v1.2-Funktionen duerfen den Schaltmoment nicht verlangsamen. Der direkte
|
||||||
|
Schaltpfad bleibt:
|
||||||
|
|
||||||
|
1. vorhandene aktuelle States nutzen
|
||||||
|
2. lokale Safety-Pruefung
|
||||||
|
3. direkter Home-Assistant-Serviceaufruf
|
||||||
|
4. Persistenz der Entscheidung
|
||||||
68
docs/V1_3_0_OPERATING_GUIDE.md
Normal file
68
docs/V1_3_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# SillyHome Next v1.3.0 Operating Guide
|
||||||
|
|
||||||
|
v1.3.0 ergänzt die v1.2-Lernfunktionen um Anomalie-Erkennung und
|
||||||
|
Performance-Überwachung. Das Dashboard bleibt Visualisierung und Einrichtung;
|
||||||
|
der direkte Schaltpfad bleibt kurz und führt vor dem Home-Assistant-Service-Call
|
||||||
|
keine Discovery, kein Training und keine Modellanalyse aus.
|
||||||
|
|
||||||
|
## Performance-Budget
|
||||||
|
|
||||||
|
- Dashboard-Start und `/v1/actuators/dashboard` haben ein Budget von 3000 ms.
|
||||||
|
- Das Dashboard zeigt die eigene Ladezeit, das aktive Budget, Job-p95 und die
|
||||||
|
Anzahl langsamer Jobs.
|
||||||
|
- Jobs ab 3000 ms werden in der Job-Queue als langsam markiert.
|
||||||
|
- Der automatisierte API-Test prüft den Root- und Dashboard-Startpfad gegen das
|
||||||
|
3-Sekunden-Budget.
|
||||||
|
|
||||||
|
## Anomalie-Erkennung
|
||||||
|
|
||||||
|
Anomalien werden pro Aktor gespeichert und im Aktor-Detail angezeigt. Erkannt
|
||||||
|
werden aktuell:
|
||||||
|
|
||||||
|
- fehlender Sensor-/Kontextbezug
|
||||||
|
- zu wenige Lernbeispiele
|
||||||
|
- unklare Quellen historischer Schaltungen
|
||||||
|
- veraltetes Training
|
||||||
|
- Vorhersagen unter der Sicherheitsgrenze
|
||||||
|
- aktive manuelle Sicherheitssperren
|
||||||
|
- Safety-Blocker
|
||||||
|
- parallele HA-Automationen bei aktivem SillyHome
|
||||||
|
- hohe negative Feedbackquote
|
||||||
|
|
||||||
|
Die Anomalien sind Hinweise für Setup und manuelles Gegensteuern. Sie lösen
|
||||||
|
keine automatische Eskalation und keine langsamere Schaltung aus.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /v1/actuators/dashboard` liefert jetzt zusätzlich:
|
||||||
|
- `performance_budget_ms`
|
||||||
|
- `job_p95_duration_ms`
|
||||||
|
- `slow_job_count`
|
||||||
|
- `performance_status`
|
||||||
|
- `anomaly_count`
|
||||||
|
- `critical_anomaly_count`
|
||||||
|
- `GET /v1/actuators/anomalies` liefert offene Anomalien gruppiert nach Aktor.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
Bei Ladezeiten ab 3 Sekunden gilt die Seite als nicht performant. Dann zuerst
|
||||||
|
prüfen:
|
||||||
|
|
||||||
|
1. Dashboard-Statistik: Ladezeit, Job-p95, langsame Jobs.
|
||||||
|
2. Job-Queue: welche Aktion langsam war.
|
||||||
|
3. Aktor-Detail: Anomalien, Safety-Blocker und Automation-Konflikte.
|
||||||
|
4. Falls Discovery oder Training langsam war: nicht in den Startpfad ziehen,
|
||||||
|
sondern geplant, manuell oder über Queue laufen lassen.
|
||||||
|
|
||||||
|
## Qualität
|
||||||
|
|
||||||
|
Vor Release/Installation ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -q
|
||||||
|
ruff check .
|
||||||
|
mypy app backend tests
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich das eingebettete Dashboard-JavaScript mit `node --check` prüfen.
|
||||||
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.0.3"
|
version = "1.7.1"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1.actuators import _deduplicate_actuator_ids
|
||||||
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
|
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.api.v1.actuators import _deduplicate_actuator_ids
|
|
||||||
from app.ha.discovery import DiscoveredEntity
|
from app.ha.discovery import DiscoveredEntity
|
||||||
from app.ha.discovery import discover_entities
|
from app.ha.discovery import discover_entities
|
||||||
from app.ha.history import (
|
from app.ha.history import (
|
||||||
@@ -29,6 +34,7 @@ class FakeHaReader(HaReader):
|
|||||||
self._entities = entities
|
self._entities = entities
|
||||||
self._history = history
|
self._history = history
|
||||||
self.read_entities_calls = 0
|
self.read_entities_calls = 0
|
||||||
|
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
|
||||||
|
|
||||||
def read_entities(self) -> list[HaEntitySummary]:
|
def read_entities(self) -> list[HaEntitySummary]:
|
||||||
self.read_entities_calls += 1
|
self.read_entities_calls += 1
|
||||||
@@ -85,6 +91,7 @@ class FakeHaReader(HaReader):
|
|||||||
service: str,
|
service: str,
|
||||||
service_data: dict[str, object],
|
service_data: dict[str, object],
|
||||||
) -> list[object]:
|
) -> list[object]:
|
||||||
|
self.service_calls.append((domain, service, service_data))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def find_automations_for_entity(
|
def find_automations_for_entity(
|
||||||
@@ -110,6 +117,7 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
unit_of_measurement="lx",
|
unit_of_measurement="lx",
|
||||||
friendly_name="Abstellkammer Helligkeit",
|
friendly_name="Abstellkammer Helligkeit",
|
||||||
area_name="Abstellkammer",
|
area_name="Abstellkammer",
|
||||||
|
state="12",
|
||||||
),
|
),
|
||||||
HaEntitySummary(
|
HaEntitySummary(
|
||||||
entity_id="binary_sensor.abstellkammer_motion",
|
entity_id="binary_sensor.abstellkammer_motion",
|
||||||
@@ -117,6 +125,7 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
device_class="motion",
|
device_class="motion",
|
||||||
friendly_name="Abstellkammer Bewegung",
|
friendly_name="Abstellkammer Bewegung",
|
||||||
area_name="Abstellkammer",
|
area_name="Abstellkammer",
|
||||||
|
state="off",
|
||||||
),
|
),
|
||||||
HaEntitySummary(
|
HaEntitySummary(
|
||||||
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
||||||
@@ -140,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]},
|
||||||
@@ -215,6 +225,266 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/assignment",
|
||||||
|
json={
|
||||||
|
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||||
|
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/weights",
|
||||||
|
json={
|
||||||
|
"sensor_weights": {
|
||||||
|
"sensor.abstellkammer_illuminance": 0.75,
|
||||||
|
"binary_sensor.abstellkammer_motion": 0.5,
|
||||||
|
},
|
||||||
|
"sensor_weight_groups": [
|
||||||
|
{
|
||||||
|
"group_id": "abstellkammer_context",
|
||||||
|
"name": "Abstellkammer Kontext",
|
||||||
|
"entity_ids": [
|
||||||
|
"sensor.abstellkammer_illuminance",
|
||||||
|
"binary_sensor.abstellkammer_motion",
|
||||||
|
],
|
||||||
|
"weight": 0.8,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"note": "Gewichtung korrigiert",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["manual_override"]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.75
|
||||||
|
assert payload["manual_override"]["sensor_weight_groups"][0]["group_id"] == (
|
||||||
|
"abstellkammer_context"
|
||||||
|
)
|
||||||
|
numeric = {
|
||||||
|
candidate["entity_id"]: candidate
|
||||||
|
for candidate in payload["numeric_candidates"]
|
||||||
|
}
|
||||||
|
assert numeric["sensor.abstellkammer_illuminance"]["manual_weight"] == 0.75
|
||||||
|
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_actuator_simulation_ranks_sensor_states_without_switching(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/assignment",
|
||||||
|
json={
|
||||||
|
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||||
|
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
store = app.state.actuator_store
|
||||||
|
record = store.get("light.abstellkammer")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
local = now.astimezone(ZoneInfo("Europe/Berlin"))
|
||||||
|
local_minute = local.hour * 60 + local.minute
|
||||||
|
patterns = [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=local_minute,
|
||||||
|
weekday=now.weekday(),
|
||||||
|
context_states={
|
||||||
|
"sensor.abstellkammer_illuminance": "12",
|
||||||
|
"binary_sensor.abstellkammer_motion": "on",
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
patterns.extend(
|
||||||
|
[
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="off",
|
||||||
|
minute_of_day=local_minute,
|
||||||
|
weekday=now.weekday(),
|
||||||
|
context_states={
|
||||||
|
"sensor.abstellkammer_illuminance": "12",
|
||||||
|
"binary_sensor.abstellkammer_motion": "off",
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
weight=0.5,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
store.upsert(
|
||||||
|
record.model_copy(
|
||||||
|
update={
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"patterns": patterns,
|
||||||
|
"sample_count": len(patterns),
|
||||||
|
"high_confidence_sample_count": len(patterns),
|
||||||
|
"activation_ready": True,
|
||||||
|
"activation_reason": "Testfreigabe.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/simulate",
|
||||||
|
json={
|
||||||
|
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
|
||||||
|
"sensor_weights": {
|
||||||
|
"binary_sensor.abstellkammer_motion": 1.0,
|
||||||
|
"sensor.abstellkammer_illuminance": 0.25,
|
||||||
|
},
|
||||||
|
"max_results": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload) == 2
|
||||||
|
assert payload[0]["prediction"]["target_state"] == "on"
|
||||||
|
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
|
||||||
|
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
|
||||||
|
assert app.state.ha_reader.service_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/safety",
|
||||||
|
json={
|
||||||
|
"safety": {
|
||||||
|
"stage": "shadow",
|
||||||
|
"manual_block": True,
|
||||||
|
"min_confidence": 0.9,
|
||||||
|
"cooldown_seconds": 120,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"rule_id": "manual_block",
|
||||||
|
"label": "Manuelle Sperre respektieren",
|
||||||
|
"enabled": True,
|
||||||
|
"blocking": True,
|
||||||
|
"reason": "Test",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"note": "Test",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["behavior"]["safety"]["manual_block"] is True
|
||||||
|
assert payload["behavior"]["safety"]["min_confidence"] == 0.9
|
||||||
|
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators",
|
||||||
|
json={"actuator_entity_id": "light.abstellkammer"},
|
||||||
|
)
|
||||||
|
record = app.state.actuator_store.get("light.abstellkammer")
|
||||||
|
version_id = "model-test"
|
||||||
|
snapshot = ModelSnapshot(
|
||||||
|
version_id=version_id,
|
||||||
|
sample_count=1,
|
||||||
|
high_confidence_sample_count=1,
|
||||||
|
average_confidence=0.9,
|
||||||
|
patterns=[],
|
||||||
|
reason="Test-Snapshot",
|
||||||
|
)
|
||||||
|
app.state.actuator_store.upsert(
|
||||||
|
record.model_copy(
|
||||||
|
update={
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"model_snapshots": [snapshot],
|
||||||
|
"active_model_version": "model-current",
|
||||||
|
"sample_count": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/feedback",
|
||||||
|
json={"correct": False, "expected_state": "off"},
|
||||||
|
)
|
||||||
|
rollback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/model/rollback",
|
||||||
|
json={"version_id": version_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feedback.status_code == 200
|
||||||
|
feedback_payload = feedback.json()
|
||||||
|
assert feedback_payload["behavior"]["adaptive_weight_updates"]
|
||||||
|
assert feedback_payload["manual_override"]["sensor_weights"]
|
||||||
|
assert rollback.status_code == 200
|
||||||
|
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators",
|
||||||
|
json={"actuator_entity_id": "light.abstellkammer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/feedback",
|
||||||
|
json={"correct": False, "kind": "never_automate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feedback.status_code == 200
|
||||||
|
payload = feedback.json()
|
||||||
|
assert payload["behavior"]["safety"]["manual_block"] is True
|
||||||
|
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
backup = client.get("/v1/actuators/backup/export")
|
||||||
|
dry_run = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/dry-run",
|
||||||
|
json={"enabled": True},
|
||||||
|
)
|
||||||
|
planning = client.post("/v1/actuators/planning/refresh")
|
||||||
|
restore = client.post(
|
||||||
|
"/v1/actuators/backup/restore",
|
||||||
|
json={"backup": backup.json(), "replace_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert backup.status_code == 200
|
||||||
|
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
|
||||||
|
assert dry_run.status_code == 200
|
||||||
|
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
|
||||||
|
assert planning.status_code == 200
|
||||||
|
assert "agent_insights" in planning.json()[0]["behavior"]
|
||||||
|
assert restore.status_code == 200
|
||||||
|
assert restore.json()["restored_records"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
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)
|
||||||
@@ -249,6 +519,125 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N
|
|||||||
assert payload["cache"]["entity_count"] == 4
|
assert payload["cache"]["entity_count"] == 4
|
||||||
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
||||||
assert payload["discovery_groups"]
|
assert payload["discovery_groups"]
|
||||||
|
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
response = client.post("/v1/actuators/reconciliation/run")
|
||||||
|
jobs = client.get("/v1/actuators/job-queue/state")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert jobs.status_code == 200
|
||||||
|
payload = jobs.json()
|
||||||
|
assert [job["kind"] for job in payload["jobs"][-3:]] == [
|
||||||
|
"reconciliation",
|
||||||
|
"training",
|
||||||
|
"evaluation",
|
||||||
|
]
|
||||||
|
assert payload["jobs"][-1]["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.get("/v1/actuators/discovery")
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
root_started_at = perf_counter()
|
||||||
|
root_response = client.get("/")
|
||||||
|
root_elapsed = perf_counter() - root_started_at
|
||||||
|
|
||||||
|
dashboard_started_at = perf_counter()
|
||||||
|
dashboard_response = client.get("/v1/actuators/dashboard/start")
|
||||||
|
dashboard_elapsed = perf_counter() - dashboard_started_at
|
||||||
|
|
||||||
|
assert root_response.status_code == 200
|
||||||
|
assert dashboard_response.status_code == 200
|
||||||
|
assert root_elapsed < 3.0
|
||||||
|
assert dashboard_elapsed < 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.get("/v1/actuators/discovery")
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
store = app.state.actuator_store
|
||||||
|
job = store.start_job(kind="training", trigger="test", summary="Langsamer Testjob")
|
||||||
|
queue = store.load_job_queue()
|
||||||
|
queue.jobs = [
|
||||||
|
item.model_copy(update={"started_at": datetime.now(timezone.utc) - timedelta(seconds=4)})
|
||||||
|
if item.job_id == job.job_id
|
||||||
|
else item
|
||||||
|
for item in queue.jobs
|
||||||
|
]
|
||||||
|
store._persist_job_queue(queue)
|
||||||
|
store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig")
|
||||||
|
|
||||||
|
dashboard_response = client.get("/v1/actuators/dashboard")
|
||||||
|
start_response = client.get("/v1/actuators/dashboard/start")
|
||||||
|
system_response = client.get("/v1/actuators/dashboard/system")
|
||||||
|
anomalies_response = client.get("/v1/actuators/anomalies")
|
||||||
|
|
||||||
|
assert dashboard_response.status_code == 200
|
||||||
|
assert start_response.status_code == 200
|
||||||
|
assert system_response.status_code == 200
|
||||||
|
system = dashboard_response.json()["system"]
|
||||||
|
start_payload = start_response.json()
|
||||||
|
assert start_payload["jobs"]["jobs"] == []
|
||||||
|
assert start_payload["discovery_groups"] == []
|
||||||
|
assert system_response.json()["actuators"] == []
|
||||||
|
assert system["performance_budget_ms"] == 3000
|
||||||
|
assert system["slow_job_count"] == 1
|
||||||
|
assert system["performance_status"] == "slow"
|
||||||
|
assert system["anomaly_count"] >= 1
|
||||||
|
assert anomalies_response.status_code == 200
|
||||||
|
assert anomalies_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.get("/v1/actuators/discovery")
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
|
||||||
|
raise AssertionError("full entity payload must not be loaded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
|
||||||
|
|
||||||
|
system_response = client.get("/v1/actuators/dashboard/system")
|
||||||
|
start_response = client.get("/v1/actuators/dashboard/start")
|
||||||
|
|
||||||
|
assert system_response.status_code == 200
|
||||||
|
assert system_response.json()["actuators"] == []
|
||||||
|
assert system_response.json()["cache"]["entity_count"] == 4
|
||||||
|
assert start_response.status_code == 200
|
||||||
|
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
||||||
|
|
||||||
|
|
||||||
|
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.get("/v1/actuators/discovery")
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
response = client.get("/v1/actuators/light.abstellkammer/detail")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["behavior"]["patterns"] == []
|
||||||
|
assert all(
|
||||||
|
snapshot["patterns"] == []
|
||||||
|
for snapshot in payload["behavior"]["model_snapshots"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
|
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -778,3 +778,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
|
||||||
|
|||||||
@@ -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,32 @@ 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 "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 +43,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"},
|
||||||
|
|||||||
Reference in New Issue
Block a user