Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ca0c53f6a | |||
| 8070a85b52 | |||
| 575211f0db | |||
| d9dc186f9b | |||
| 214b384b70 | |||
| 6323b93f23 | |||
| 1d176cce45 | |||
| bd087728e1 | |||
| 10f9113547 | |||
| 47fa8eb0ce |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ __pycache__/
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
/.actuator_store/
|
||||
/MagicMock/
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
||||
# 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
|
||||
|
||||
10
README.md
10
README.md
@@ -23,6 +23,16 @@ nach einer ausdrücklichen Freigabe ausführen.
|
||||
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
|
||||
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
|
||||
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
|
||||
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
|
||||
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
|
||||
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
|
||||
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
|
||||
- Version 1.5.2 Rollback-Speicher und HA-Timeouts:
|
||||
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
|
||||
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
|
||||
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
|
||||
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
|
||||
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
|
||||
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
||||
|
||||
## Reifegrad
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "1.4.0"
|
||||
version: "1.7.2"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
|
||||
fi
|
||||
|
||||
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
|
||||
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000
|
||||
|
||||
130
app/actuators/cache_db.py
Normal file
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),
|
||||
)
|
||||
@@ -52,6 +52,14 @@ class JobStatus(StrEnum):
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class FeedbackKind(StrEnum):
|
||||
CORRECT = "correct"
|
||||
WRONG = "wrong"
|
||||
TOO_EARLY = "too_early"
|
||||
TOO_LATE = "too_late"
|
||||
NEVER_AUTOMATE = "never_automate"
|
||||
|
||||
|
||||
class AssignmentCandidate(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
@@ -146,6 +154,43 @@ class DecisionFactor(BaseModel):
|
||||
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)
|
||||
@@ -248,6 +293,32 @@ class RelatedAutomation(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ActuatorGroup(BaseModel):
|
||||
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
area_name: str | None = Field(default=None, max_length=120)
|
||||
member_entity_ids: list[str] = Field(default_factory=list)
|
||||
reason: str = Field(default="", max_length=300)
|
||||
|
||||
|
||||
class SceneSuggestion(BaseModel):
|
||||
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||
label: str = Field(min_length=1, max_length=120)
|
||||
member_entity_ids: list[str] = Field(default_factory=list)
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
reason: str = Field(default="", max_length=500)
|
||||
last_seen_at: datetime | None = None
|
||||
|
||||
|
||||
class AgentInsight(BaseModel):
|
||||
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||
severity: str = Field(default="info", max_length=20)
|
||||
title: str = Field(min_length=1, max_length=160)
|
||||
detail: str = Field(min_length=1, max_length=700)
|
||||
action: str | None = Field(default=None, max_length=300)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class BehaviorState(BaseModel):
|
||||
mode: BehaviorMode = BehaviorMode.SHADOW
|
||||
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
||||
@@ -281,6 +352,16 @@ class BehaviorState(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -100,6 +100,11 @@ class ActuatorStore:
|
||||
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,
|
||||
*,
|
||||
|
||||
@@ -8,8 +8,16 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.actuators.cache_db import DashboardCache
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup
|
||||
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.behavior.engine import BehaviorEngine
|
||||
@@ -51,9 +59,40 @@ class WeightOverrideRequest(BaseModel):
|
||||
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):
|
||||
correct: bool
|
||||
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):
|
||||
@@ -306,22 +345,29 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardOverview)
|
||||
def dashboard_overview(request: Request) -> DashboardOverview:
|
||||
return _dashboard_overview(request, include_background=True)
|
||||
return _dashboard_overview(request, include_background=True, include_actuators=True)
|
||||
|
||||
|
||||
@router.get("/dashboard/start", response_model=DashboardOverview)
|
||||
def dashboard_start(request: Request) -> DashboardOverview:
|
||||
return _dashboard_overview(request, include_background=False)
|
||||
return _dashboard_overview(request, include_background=False, include_actuators=True)
|
||||
|
||||
|
||||
def _dashboard_overview(request: Request, *, include_background: bool) -> DashboardOverview:
|
||||
cache_payload = _load_entity_cache_payload(request)
|
||||
raw_entities = cache_payload.get("entities", [])
|
||||
if not isinstance(raw_entities, list):
|
||||
raw_entities = []
|
||||
raw_updated_at = cache_payload.get("updated_at")
|
||||
@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
|
||||
raw_groups = cache_payload.get("discovery_groups", [])
|
||||
raw_groups = cache_status.get("discovery_groups", [])
|
||||
cached_groups = [
|
||||
DashboardDiscoveryGroup.model_validate(group)
|
||||
for group in raw_groups
|
||||
@@ -329,7 +375,7 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
|
||||
] if include_background and isinstance(raw_groups, list) else []
|
||||
reconciliation = _reconciliation_state_or_default(request)
|
||||
ws_status = getattr(request.app.state, "ws_status", None)
|
||||
actuators = list_configured_summary(request)
|
||||
actuators = list_configured_summary(request) if include_actuators else []
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
jobs = (
|
||||
store.load_job_queue()
|
||||
@@ -339,6 +385,9 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
|
||||
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(
|
||||
system=DashboardSystemStatus(
|
||||
websocket_status=getattr(ws_status, "status", "unavailable"),
|
||||
@@ -348,7 +397,11 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
|
||||
if reconciliation.last_completed_at is not None
|
||||
else None
|
||||
),
|
||||
configured_actuators=len(actuators),
|
||||
configured_actuators=(
|
||||
len(actuators)
|
||||
if include_actuators
|
||||
else reconciliation.configured_actuators
|
||||
),
|
||||
trained_models=reconciliation.trained_models,
|
||||
review_required=reconciliation.review_required,
|
||||
job_p95_duration_ms=job_p95_duration_ms,
|
||||
@@ -358,9 +411,9 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
|
||||
critical_anomaly_count=critical_anomaly_count,
|
||||
),
|
||||
cache=EntityCacheStatus(
|
||||
available=bool(raw_entities),
|
||||
available=bool(entity_count),
|
||||
updated_at=updated_at,
|
||||
entity_count=len(raw_entities),
|
||||
entity_count=entity_count,
|
||||
),
|
||||
actuators=actuators,
|
||||
discovery_groups=cached_groups,
|
||||
@@ -391,6 +444,48 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
|
||||
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])
|
||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||
return _service(request).list_configured()
|
||||
@@ -417,6 +512,47 @@ def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord:
|
||||
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)
|
||||
def delete_actuator(actuator_entity_id: str, request: Request) -> None:
|
||||
_service(request).delete_actuator(actuator_entity_id)
|
||||
@@ -446,6 +582,28 @@ def evaluate_actuator(
|
||||
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)
|
||||
def record_feedback(
|
||||
actuator_entity_id: str,
|
||||
@@ -457,11 +615,24 @@ def record_feedback(
|
||||
actuator_entity_id,
|
||||
correct=payload.correct,
|
||||
expected_state=payload.expected_state,
|
||||
kind=payload.kind,
|
||||
)
|
||||
except KeyError as 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,
|
||||
@@ -751,6 +922,22 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
|
||||
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:
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
if not isinstance(store, ActuatorStore):
|
||||
@@ -790,6 +977,11 @@ def _load_cached_entity_map(
|
||||
) -> dict[str, HaEntitySummary]:
|
||||
if not entity_ids:
|
||||
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)
|
||||
raw_entities = payload.get("entities", [])
|
||||
if not isinstance(raw_entities, list):
|
||||
@@ -808,7 +1000,35 @@ def _load_cached_entity_map(
|
||||
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]:
|
||||
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)
|
||||
if not path.exists():
|
||||
return {}
|
||||
@@ -820,18 +1040,18 @@ def _load_entity_cache_payload(request: Request) -> dict[str, object]:
|
||||
|
||||
|
||||
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.parent.mkdir(parents=True, exist_ok=True)
|
||||
group_counts: dict[tuple[str, str], int] = {}
|
||||
for entity in discover_entities(entities):
|
||||
key = (entity.category, entity.role.value)
|
||||
group_counts[key] = group_counts.get(key, 0) + 1
|
||||
payload = {
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"discovery_groups": [
|
||||
{"category": category, "role": role, "count": count}
|
||||
for (category, role), count in sorted(group_counts.items())
|
||||
],
|
||||
"discovery_groups": group_payload,
|
||||
"entities": [entity.model_dump(mode="json") for entity in entities],
|
||||
}
|
||||
temporary = path.with_suffix(".json.tmp")
|
||||
@@ -842,6 +1062,17 @@ def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) ->
|
||||
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(
|
||||
discovered: list[tuple[str, str]],
|
||||
entities: dict[str, HaEntitySummary],
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from itertools import product
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from time import perf_counter
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.actuators.models import (
|
||||
ActuatorRecord,
|
||||
AdaptiveWeightUpdate,
|
||||
AgentInsight,
|
||||
AnomalyEvent,
|
||||
ActuatorGroup,
|
||||
AutomationConflict,
|
||||
BehaviorMode,
|
||||
BehaviorPattern,
|
||||
@@ -16,12 +20,17 @@ from app.actuators.models import (
|
||||
BehaviorState,
|
||||
BehaviorStatus,
|
||||
DecisionFactor,
|
||||
DecisionTrace,
|
||||
ExecutionEvent,
|
||||
FeedbackKind,
|
||||
LatencyMeasurement,
|
||||
ManualOverride,
|
||||
ModelSnapshot,
|
||||
RelatedAutomation,
|
||||
SafetyProfile,
|
||||
SafetyStage,
|
||||
SceneSuggestion,
|
||||
SimulationOutcome,
|
||||
TimeProfile,
|
||||
)
|
||||
from app.actuators.store import ActuatorStore
|
||||
@@ -32,7 +41,12 @@ from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
_MAX_PATTERNS = 500
|
||||
_MAX_MODEL_SNAPSHOTS = 3
|
||||
_MAX_SNAPSHOT_PATTERNS = 120
|
||||
_MAX_EXECUTION_EVENTS = 100
|
||||
_MAX_DECISION_TRACES = 30
|
||||
_MAX_LATENCY_MEASUREMENTS = 50
|
||||
_MAX_FEEDBACK_LOG = 50
|
||||
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
||||
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
||||
@@ -252,7 +266,11 @@ class BehaviorEngine:
|
||||
context_state_overrides: dict[str, str | None] | None = None,
|
||||
context_changed_at_overrides: dict[str, datetime | None] | None = None,
|
||||
current_entities: Sequence[HaEntitySummary] | None = None,
|
||||
trigger_entity_id: str | None = None,
|
||||
trigger_state: str | None = None,
|
||||
event_received_at: datetime | None = None,
|
||||
) -> ActuatorRecord:
|
||||
started_perf = perf_counter()
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if current_entities is None:
|
||||
@@ -317,6 +335,7 @@ class BehaviorEngine:
|
||||
record.behavior.patterns,
|
||||
current_context=current_context,
|
||||
current_context_changed_at=current_context_changed_at,
|
||||
context_weights=_context_weights_for(record),
|
||||
now=now,
|
||||
min_support=self._settings.min_behavior_actions,
|
||||
window_minutes=self._settings.prediction_window_minutes,
|
||||
@@ -342,6 +361,7 @@ class BehaviorEngine:
|
||||
else:
|
||||
safety_allowed = False
|
||||
safety_blockers = ["Keine fällige Vorhersage."]
|
||||
decision_to_service_ms: int | None = None
|
||||
decision_factors = _decision_factors_for(record, current_context, prediction)
|
||||
behavior = record.behavior.model_copy(
|
||||
update={
|
||||
@@ -381,12 +401,47 @@ class BehaviorEngine:
|
||||
domain = actuator_entity_id.split(".", 1)[0]
|
||||
service = service_for_state(domain, prediction.target_state)
|
||||
if service is not None:
|
||||
if record.behavior.dry_run_enabled:
|
||||
behavior = behavior.model_copy(
|
||||
update={
|
||||
"prediction": prediction.model_copy(
|
||||
update={
|
||||
"executed": False,
|
||||
"execution_reason": (
|
||||
"Dry-run: Aktion wäre ausgeführt worden."
|
||||
),
|
||||
}
|
||||
),
|
||||
"dry_run_sample_count": record.behavior.dry_run_sample_count + 1,
|
||||
"reason": (
|
||||
f"Dry-run hätte {prediction.target_state!r} mit "
|
||||
f"{prediction.confidence:.0%} Sicherheit ausgeführt."
|
||||
),
|
||||
}
|
||||
)
|
||||
return self._save_behavior(
|
||||
record,
|
||||
_append_decision_trace(
|
||||
behavior,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
trigger_state=trigger_state,
|
||||
prediction=prediction,
|
||||
safety_blockers=safety_blockers,
|
||||
duration_ms=_elapsed_ms(started_perf),
|
||||
event_received_at=event_received_at,
|
||||
decision_to_service_ms=None,
|
||||
executed=False,
|
||||
source="event" if event_received_at is not None else "manual",
|
||||
),
|
||||
)
|
||||
try:
|
||||
service_started_perf = perf_counter()
|
||||
self._ha_reader.call_service(
|
||||
domain,
|
||||
service,
|
||||
{"entity_id": actuator_entity_id},
|
||||
)
|
||||
decision_to_service_ms = _elapsed_ms(service_started_perf)
|
||||
except (HaClientError, ValueError) as exc:
|
||||
logger.error(
|
||||
"Predicted action failed for %s: %s",
|
||||
@@ -398,7 +453,21 @@ class BehaviorEngine:
|
||||
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
|
||||
}
|
||||
)
|
||||
return self._save_behavior(record, behavior)
|
||||
return self._save_behavior(
|
||||
record,
|
||||
_append_decision_trace(
|
||||
behavior,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
trigger_state=trigger_state,
|
||||
prediction=prediction,
|
||||
safety_blockers=[str(exc)],
|
||||
duration_ms=_elapsed_ms(started_perf),
|
||||
event_received_at=event_received_at,
|
||||
decision_to_service_ms=None,
|
||||
executed=False,
|
||||
source="event" if event_received_at is not None else "manual",
|
||||
),
|
||||
)
|
||||
event = ExecutionEvent(
|
||||
target_state=prediction.target_state,
|
||||
executed_at=now,
|
||||
@@ -432,14 +501,139 @@ class BehaviorEngine:
|
||||
)
|
||||
}
|
||||
)
|
||||
behavior = _append_decision_trace(
|
||||
behavior,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
trigger_state=trigger_state,
|
||||
prediction=prediction,
|
||||
safety_blockers=safety_blockers,
|
||||
duration_ms=_elapsed_ms(started_perf),
|
||||
event_received_at=event_received_at,
|
||||
decision_to_service_ms=(
|
||||
decision_to_service_ms
|
||||
),
|
||||
executed=bool(prediction is not None and behavior.prediction is not None and behavior.prediction.executed),
|
||||
source="event" if event_received_at is not None else "manual",
|
||||
)
|
||||
return self._save_behavior(record, behavior)
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
*,
|
||||
sensor_states: dict[str, str],
|
||||
sensor_weights: dict[str, float],
|
||||
state_options: dict[str, list[str]],
|
||||
max_results: int,
|
||||
include_current: bool = True,
|
||||
) -> list[SimulationOutcome]:
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
current_entities = self._ha_reader.read_entities()
|
||||
entities = {entity.entity_id: entity for entity in current_entities}
|
||||
actuator = entities.get(actuator_entity_id)
|
||||
if actuator is None:
|
||||
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
|
||||
selected_context_ids = [
|
||||
entity_id
|
||||
for entity_id in [
|
||||
record.assignment.selected_numeric_entity_id,
|
||||
*record.assignment.selected_context_entity_ids,
|
||||
]
|
||||
if entity_id
|
||||
]
|
||||
if not selected_context_ids:
|
||||
return []
|
||||
base_context = {
|
||||
entity_id: entities[entity_id].state
|
||||
for entity_id in selected_context_ids
|
||||
if entity_id in entities and entities[entity_id].state is not None
|
||||
}
|
||||
base_changed_at = {
|
||||
entity_id: entities[entity_id].last_changed
|
||||
for entity_id in base_context
|
||||
}
|
||||
context_weights = _context_weights_for(record)
|
||||
for entity_id, weight in sensor_weights.items():
|
||||
if entity_id in selected_context_ids:
|
||||
context_weights[entity_id] = max(0.0, min(1.0, weight))
|
||||
scenarios = _simulation_contexts(
|
||||
base_context,
|
||||
sensor_states=sensor_states,
|
||||
state_options=state_options,
|
||||
selected_context_ids=selected_context_ids,
|
||||
include_current=include_current,
|
||||
)
|
||||
outcomes: list[SimulationOutcome] = []
|
||||
for index, context in enumerate(scenarios[:64], start=1):
|
||||
prediction_context: dict[str, str | None] = dict(context)
|
||||
changed_at = dict(base_changed_at)
|
||||
for entity_id, state in context.items():
|
||||
if base_context.get(entity_id) != state:
|
||||
changed_at[entity_id] = now
|
||||
prediction = predict_behavior(
|
||||
record.behavior.patterns,
|
||||
current_context=prediction_context,
|
||||
current_context_changed_at=changed_at,
|
||||
context_weights=context_weights,
|
||||
now=now,
|
||||
min_support=self._settings.min_behavior_actions,
|
||||
window_minutes=self._settings.prediction_window_minutes,
|
||||
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
|
||||
timezone_name=self._settings.timezone,
|
||||
)
|
||||
if prediction is not None:
|
||||
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
|
||||
recommendation = (
|
||||
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
|
||||
if would_execute
|
||||
else (
|
||||
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
|
||||
"aber blockiert: " + " ".join(blockers)
|
||||
)
|
||||
)
|
||||
else:
|
||||
would_execute = False
|
||||
blockers = ["Keine fällige Vorhersage."]
|
||||
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
|
||||
outcomes.append(
|
||||
SimulationOutcome(
|
||||
scenario_id=f"scenario-{index}",
|
||||
actuator_entity_id=actuator_entity_id,
|
||||
sensor_states=context,
|
||||
sensor_weights={
|
||||
entity_id: round(context_weights.get(entity_id, 1.0), 4)
|
||||
for entity_id in context
|
||||
},
|
||||
prediction=prediction,
|
||||
decision_factors=_decision_factors_for(
|
||||
record,
|
||||
prediction_context,
|
||||
prediction,
|
||||
context_weights=context_weights,
|
||||
),
|
||||
would_execute=would_execute,
|
||||
blockers=blockers,
|
||||
score=round(prediction.confidence if prediction is not None else 0.0, 4),
|
||||
recommendation=recommendation,
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
outcomes,
|
||||
key=lambda item: (
|
||||
item.prediction is None,
|
||||
-item.score,
|
||||
item.scenario_id,
|
||||
),
|
||||
)[:max_results]
|
||||
|
||||
def record_feedback(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
*,
|
||||
correct: bool,
|
||||
expected_state: str | None = None,
|
||||
kind: FeedbackKind | None = None,
|
||||
) -> ActuatorRecord:
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -483,6 +677,7 @@ class BehaviorEngine:
|
||||
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
|
||||
correct_count = record.behavior.correct_feedback_count + 1
|
||||
incorrect_count = record.behavior.incorrect_feedback_count
|
||||
feedback_kind = kind or FeedbackKind.CORRECT
|
||||
else:
|
||||
target = prediction.target_state if prediction is not None else None
|
||||
if target:
|
||||
@@ -513,11 +708,24 @@ class BehaviorEngine:
|
||||
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
||||
correct_count = record.behavior.correct_feedback_count
|
||||
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
||||
feedback_kind = kind or FeedbackKind.WRONG
|
||||
if feedback_kind is FeedbackKind.NEVER_AUTOMATE:
|
||||
safety = record.behavior.safety.model_copy(
|
||||
update={
|
||||
"manual_block": True,
|
||||
"updated_at": now,
|
||||
"note": "Durch Nutzerfeedback dauerhaft blockiert.",
|
||||
}
|
||||
)
|
||||
else:
|
||||
safety = record.behavior.safety
|
||||
adaptive_updates, manual_override = _adapt_sensor_weights(
|
||||
record,
|
||||
current_context,
|
||||
correct=correct,
|
||||
)
|
||||
if correct and prediction is not None:
|
||||
safety = record.behavior.safety
|
||||
behavior = record.behavior.model_copy(
|
||||
update={
|
||||
"patterns": patterns[-_MAX_PATTERNS:],
|
||||
@@ -530,6 +738,11 @@ class BehaviorEngine:
|
||||
"last_trained_at": now,
|
||||
"correct_feedback_count": correct_count,
|
||||
"incorrect_feedback_count": incorrect_count,
|
||||
"feedback_log": [
|
||||
*record.behavior.feedback_log,
|
||||
feedback_kind,
|
||||
][-_MAX_FEEDBACK_LOG:],
|
||||
"safety": safety,
|
||||
"adaptive_weight_updates": [
|
||||
*record.behavior.adaptive_weight_updates,
|
||||
*adaptive_updates,
|
||||
@@ -555,6 +768,43 @@ class BehaviorEngine:
|
||||
)
|
||||
return self._save_behavior(record_for_save, behavior)
|
||||
|
||||
def set_dry_run(self, actuator_entity_id: str, *, enabled: bool) -> ActuatorRecord:
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
behavior = record.behavior.model_copy(
|
||||
update={
|
||||
"dry_run_enabled": enabled,
|
||||
"dry_run_started_at": now if enabled else record.behavior.dry_run_started_at,
|
||||
"reason": (
|
||||
"Dry-run aktiv; freigegebene Aktionen werden protokolliert, aber nicht geschaltet."
|
||||
if enabled
|
||||
else "Dry-run beendet."
|
||||
),
|
||||
}
|
||||
)
|
||||
return self._save_behavior(record, behavior)
|
||||
|
||||
def refresh_planning_insights(self) -> list[ActuatorRecord]:
|
||||
records = self._store.list()
|
||||
groups = _derive_actuator_groups(records)
|
||||
scenes = _derive_scene_suggestions(records)
|
||||
insights_by_actuator = _derive_agent_insights(records)
|
||||
updated: list[ActuatorRecord] = []
|
||||
for record in records:
|
||||
behavior = record.behavior.model_copy(
|
||||
update={
|
||||
"actuator_groups": [
|
||||
group for group in groups if record.actuator_entity_id in group.member_entity_ids
|
||||
],
|
||||
"scene_suggestions": [
|
||||
scene for scene in scenes if record.actuator_entity_id in scene.member_entity_ids
|
||||
],
|
||||
"agent_insights": insights_by_actuator.get(record.actuator_entity_id, []),
|
||||
}
|
||||
)
|
||||
updated.append(self._save_behavior(record, behavior))
|
||||
return updated
|
||||
|
||||
def rollback_model(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
@@ -937,6 +1187,11 @@ class BehaviorEngine:
|
||||
record: ActuatorRecord,
|
||||
behavior: BehaviorState,
|
||||
) -> ActuatorRecord:
|
||||
behavior = behavior.model_copy(
|
||||
update={
|
||||
"model_snapshots": _compact_model_snapshots(behavior.model_snapshots),
|
||||
}
|
||||
)
|
||||
updated = record.model_copy(
|
||||
update={
|
||||
"behavior": behavior,
|
||||
@@ -959,11 +1214,19 @@ class BehaviorEngine:
|
||||
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
||||
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
||||
"""
|
||||
event_received_at = datetime.now(timezone.utc)
|
||||
records = self._store.list()
|
||||
# Aktor direkt evaluieren
|
||||
for record in self._store.list():
|
||||
for record in records:
|
||||
if record.actuator_entity_id == entity_id:
|
||||
try:
|
||||
self.evaluate(record.actuator_entity_id, current_entities=current_entities)
|
||||
self.evaluate(
|
||||
record.actuator_entity_id,
|
||||
current_entities=current_entities,
|
||||
trigger_entity_id=entity_id,
|
||||
trigger_state=_event_state(new_state),
|
||||
event_received_at=event_received_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
|
||||
return
|
||||
@@ -972,7 +1235,7 @@ class BehaviorEngine:
|
||||
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
|
||||
affected_actuators = [
|
||||
record.actuator_entity_id
|
||||
for record in self._store.list()
|
||||
for record in records
|
||||
if (
|
||||
record.assignment.selected_numeric_entity_id == entity_id
|
||||
or entity_id in record.assignment.selected_context_entity_ids
|
||||
@@ -985,6 +1248,9 @@ class BehaviorEngine:
|
||||
context_state_overrides={entity_id: event_state},
|
||||
context_changed_at_overrides={entity_id: event_changed_at},
|
||||
current_entities=current_entities,
|
||||
trigger_entity_id=entity_id,
|
||||
trigger_state=event_state,
|
||||
event_received_at=event_received_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
|
||||
@@ -1012,6 +1278,208 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def _elapsed_ms(started_perf: float) -> int:
|
||||
return max(0, int((perf_counter() - started_perf) * 1000))
|
||||
|
||||
|
||||
def _append_decision_trace(
|
||||
behavior: BehaviorState,
|
||||
*,
|
||||
trigger_entity_id: str | None,
|
||||
trigger_state: str | None,
|
||||
prediction: BehaviorPrediction | None,
|
||||
safety_blockers: list[str],
|
||||
duration_ms: int,
|
||||
event_received_at: datetime | None,
|
||||
decision_to_service_ms: int | None,
|
||||
executed: bool,
|
||||
source: str,
|
||||
) -> BehaviorState:
|
||||
now = datetime.now(timezone.utc)
|
||||
blocked = prediction is None or bool(safety_blockers)
|
||||
trace = DecisionTrace(
|
||||
trace_id=f"{now.strftime('%Y%m%d%H%M%S%f')}.{trigger_entity_id or 'manual'}",
|
||||
created_at=now,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
trigger_state=trigger_state,
|
||||
target_state=prediction.target_state if prediction is not None else None,
|
||||
confidence=prediction.confidence if prediction is not None else None,
|
||||
executed=executed,
|
||||
blocked=blocked,
|
||||
reason=(
|
||||
prediction.execution_reason
|
||||
if prediction is not None
|
||||
else behavior.reason
|
||||
),
|
||||
blockers=safety_blockers if prediction is not None else ["Keine fällige Vorhersage."],
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
updated = behavior.model_copy(
|
||||
update={
|
||||
"decision_timeline": [
|
||||
*behavior.decision_timeline,
|
||||
trace,
|
||||
][-_MAX_DECISION_TRACES:],
|
||||
}
|
||||
)
|
||||
if event_received_at is None:
|
||||
return updated
|
||||
return _append_latency_measurement(
|
||||
updated,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
event_received_at=event_received_at,
|
||||
event_to_decision_ms=duration_ms,
|
||||
decision_to_service_ms=decision_to_service_ms,
|
||||
executed=executed,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _append_latency_measurement(
|
||||
behavior: BehaviorState,
|
||||
*,
|
||||
trigger_entity_id: str | None,
|
||||
event_received_at: datetime | None,
|
||||
event_to_decision_ms: int | None,
|
||||
decision_to_service_ms: int | None,
|
||||
executed: bool,
|
||||
source: str,
|
||||
) -> BehaviorState:
|
||||
if event_received_at is None:
|
||||
return behavior
|
||||
now = datetime.now(timezone.utc)
|
||||
event_to_done_ms = max(0, int((now - event_received_at).total_seconds() * 1000))
|
||||
measurement = LatencyMeasurement(
|
||||
measured_at=now,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
event_to_decision_ms=event_to_decision_ms,
|
||||
decision_to_service_ms=decision_to_service_ms,
|
||||
event_to_done_ms=event_to_done_ms,
|
||||
executed=executed,
|
||||
source=source,
|
||||
)
|
||||
return behavior.model_copy(
|
||||
update={
|
||||
"latency_measurements": [
|
||||
*behavior.latency_measurements,
|
||||
measurement,
|
||||
][-_MAX_LATENCY_MEASUREMENTS:],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _derive_actuator_groups(records: list[ActuatorRecord]) -> list[ActuatorGroup]:
|
||||
by_area: dict[str, list[str]] = {}
|
||||
for record in records:
|
||||
area = _area_hint(record)
|
||||
if area:
|
||||
by_area.setdefault(area, []).append(record.actuator_entity_id)
|
||||
return [
|
||||
ActuatorGroup(
|
||||
group_id=_slug(f"area_{area}"),
|
||||
name=f"Raum {area}",
|
||||
area_name=area,
|
||||
member_entity_ids=sorted(entity_ids),
|
||||
reason="Aktor-Gruppe aus gemeinsamer Raum-/Kontextzuordnung abgeleitet.",
|
||||
)
|
||||
for area, entity_ids in sorted(by_area.items())
|
||||
if len(entity_ids) >= 2
|
||||
]
|
||||
|
||||
|
||||
def _derive_scene_suggestions(records: list[ActuatorRecord]) -> list[SceneSuggestion]:
|
||||
scenes: list[SceneSuggestion] = []
|
||||
by_context: dict[tuple[str, str], list[str]] = {}
|
||||
for record in records:
|
||||
for pattern in record.behavior.patterns:
|
||||
for entity_id, state in pattern.context_states.items():
|
||||
by_context.setdefault((entity_id, state), []).append(record.actuator_entity_id)
|
||||
for (entity_id, state), members in sorted(by_context.items()):
|
||||
unique_members = sorted(set(members))
|
||||
if len(unique_members) < 2:
|
||||
continue
|
||||
scenes.append(
|
||||
SceneSuggestion(
|
||||
scene_id=_slug(f"{entity_id}_{state}"),
|
||||
label=f"{entity_id} ist {state}",
|
||||
member_entity_ids=unique_members,
|
||||
confidence=min(1.0, len(members) / max(3, len(unique_members) * 2)),
|
||||
reason="Mehrere Aktoren reagieren historisch auf denselben Kontext.",
|
||||
last_seen_at=max(
|
||||
(
|
||||
pattern.observed_at
|
||||
for record in records
|
||||
for pattern in record.behavior.patterns
|
||||
if pattern.context_states.get(entity_id) == state
|
||||
),
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
return scenes[-20:]
|
||||
|
||||
|
||||
def _derive_agent_insights(records: list[ActuatorRecord]) -> dict[str, list[AgentInsight]]:
|
||||
result: dict[str, list[AgentInsight]] = {}
|
||||
for record in records:
|
||||
insights: list[AgentInsight] = []
|
||||
if record.behavior.automation_conflicts:
|
||||
insights.append(
|
||||
AgentInsight(
|
||||
insight_id=f"{record.actuator_entity_id}.automation_conflict",
|
||||
severity="warning",
|
||||
title="Automation-Konflikt prüfen",
|
||||
detail="Eine passende HA-Automation kann parallel zu SillyHome schalten.",
|
||||
action="Automation pausieren oder SillyHome im Shadow-Modus lassen.",
|
||||
)
|
||||
)
|
||||
if record.behavior.latency_measurements:
|
||||
durations = [
|
||||
item.event_to_done_ms
|
||||
for item in record.behavior.latency_measurements
|
||||
if item.event_to_done_ms is not None
|
||||
]
|
||||
if durations and max(durations) > 1500:
|
||||
insights.append(
|
||||
AgentInsight(
|
||||
insight_id=f"{record.actuator_entity_id}.latency",
|
||||
severity="warning",
|
||||
title="Schalt-Latenz beobachten",
|
||||
detail=f"Letzte maximale Event-Latenz: {max(durations)} ms.",
|
||||
action="WebSocket-Status, HA-Servicezeit und Sensor-Routing pruefen.",
|
||||
)
|
||||
)
|
||||
if record.behavior.incorrect_feedback_count > record.behavior.correct_feedback_count:
|
||||
insights.append(
|
||||
AgentInsight(
|
||||
insight_id=f"{record.actuator_entity_id}.feedback",
|
||||
severity="warning",
|
||||
title="Viele negative Feedbacks",
|
||||
detail="Das Modell trifft aktuell mehr falsche als richtige Entscheidungen.",
|
||||
action="Kontextzuordnung, Gewichtung oder Modell-Rollback pruefen.",
|
||||
)
|
||||
)
|
||||
result[record.actuator_entity_id] = insights[:5]
|
||||
return result
|
||||
|
||||
|
||||
def _area_hint(record: ActuatorRecord) -> str | None:
|
||||
for candidate in [*record.context_candidates, *record.numeric_candidates]:
|
||||
if candidate.area_name:
|
||||
return candidate.area_name
|
||||
return None
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
result = []
|
||||
for char in value.lower():
|
||||
if char.isalnum():
|
||||
result.append(char)
|
||||
elif char in {".", "_", "-", " "}:
|
||||
result.append("_")
|
||||
return "".join(result).strip("_")[:64] or "item"
|
||||
|
||||
|
||||
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
|
||||
if target_state == "on" and profile.min_confidence_on is not None:
|
||||
return profile.min_confidence_on
|
||||
@@ -1024,15 +1492,21 @@ def _decision_factors_for(
|
||||
record: ActuatorRecord,
|
||||
current_context: dict[str, str | None],
|
||||
prediction: BehaviorPrediction | None,
|
||||
*,
|
||||
context_weights: dict[str, float] | None = None,
|
||||
) -> list[DecisionFactor]:
|
||||
factors: list[DecisionFactor] = []
|
||||
weights = context_weights or {}
|
||||
candidates = {
|
||||
candidate.entity_id: candidate
|
||||
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||
}
|
||||
for entity_id, state in current_context.items():
|
||||
candidate = candidates.get(entity_id)
|
||||
weight = candidate.effective_weight if candidate is not None else 1.0
|
||||
weight = weights.get(
|
||||
entity_id,
|
||||
candidate.effective_weight if candidate is not None else 1.0,
|
||||
)
|
||||
relevance = candidate.confidence if candidate is not None else 0.5
|
||||
contribution = round(min(1.0, weight * relevance), 4)
|
||||
factors.append(
|
||||
@@ -1068,6 +1542,62 @@ def _decision_factors_for(
|
||||
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
|
||||
|
||||
|
||||
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
|
||||
weights = {
|
||||
candidate.entity_id: candidate.effective_weight
|
||||
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||
}
|
||||
override = record.manual_override
|
||||
if override is not None:
|
||||
for entity_id, weight in override.sensor_weights.items():
|
||||
weights[entity_id] = max(0.0, min(1.0, weight))
|
||||
for group in override.sensor_weight_groups:
|
||||
for entity_id in group.entity_ids:
|
||||
weights[entity_id] = max(0.0, min(1.0, group.weight))
|
||||
return weights
|
||||
|
||||
|
||||
def _simulation_contexts(
|
||||
base_context: dict[str, str | None],
|
||||
*,
|
||||
sensor_states: dict[str, str],
|
||||
state_options: dict[str, list[str]],
|
||||
selected_context_ids: list[str],
|
||||
include_current: bool,
|
||||
) -> list[dict[str, str]]:
|
||||
selected = set(selected_context_ids)
|
||||
base = {
|
||||
entity_id: state
|
||||
for entity_id, state in base_context.items()
|
||||
if entity_id in selected and state is not None
|
||||
}
|
||||
for entity_id, state in sensor_states.items():
|
||||
if entity_id in selected:
|
||||
base[entity_id] = state
|
||||
option_items = [
|
||||
(
|
||||
entity_id,
|
||||
list(dict.fromkeys(state for state in states if state))[:6],
|
||||
)
|
||||
for entity_id, states in state_options.items()
|
||||
if entity_id in selected and states
|
||||
][:6]
|
||||
contexts: list[dict[str, str]] = []
|
||||
if include_current or not option_items:
|
||||
contexts.append(dict(base))
|
||||
if option_items:
|
||||
keys = [item[0] for item in option_items]
|
||||
value_lists = [item[1] for item in option_items]
|
||||
for values in product(*value_lists):
|
||||
context = dict(base)
|
||||
context.update(dict(zip(keys, values, strict=True)))
|
||||
if context not in contexts:
|
||||
contexts.append(context)
|
||||
if len(contexts) >= 64:
|
||||
break
|
||||
return contexts
|
||||
|
||||
|
||||
def _knowledge_lines(
|
||||
record: ActuatorRecord,
|
||||
sample_count: int,
|
||||
@@ -1132,10 +1662,19 @@ def _next_model_snapshots(
|
||||
high_confidence_sample_count=trusted_actions,
|
||||
average_confidence=round(average_confidence, 4),
|
||||
incorrect_feedback_count=incorrect_feedback_count,
|
||||
patterns=patterns,
|
||||
patterns=patterns[-_MAX_SNAPSHOT_PATTERNS:],
|
||||
reason=reason,
|
||||
)
|
||||
return [*existing, snapshot][-10:]
|
||||
return _compact_model_snapshots([*existing, snapshot])
|
||||
|
||||
|
||||
def _compact_model_snapshots(existing: list[ModelSnapshot]) -> list[ModelSnapshot]:
|
||||
return [
|
||||
snapshot.model_copy(
|
||||
update={"patterns": snapshot.patterns[-_MAX_SNAPSHOT_PATTERNS:]}
|
||||
)
|
||||
for snapshot in existing[-_MAX_MODEL_SNAPSHOTS:]
|
||||
]
|
||||
|
||||
|
||||
def _average(values: list[float]) -> float:
|
||||
@@ -1392,6 +1931,7 @@ def predict_behavior(
|
||||
min_support: int,
|
||||
window_minutes: int,
|
||||
current_context_changed_at: dict[str, datetime | None] | None = None,
|
||||
context_weights: dict[str, float] | None = None,
|
||||
causal_window_seconds: int = 120,
|
||||
timezone_name: str = "Europe/Berlin",
|
||||
) -> BehaviorPrediction | None:
|
||||
@@ -1422,14 +1962,10 @@ def predict_behavior(
|
||||
for entity_id, expected in pattern.context_states.items()
|
||||
if entity_id in current_context
|
||||
]
|
||||
context_score = (
|
||||
sum(
|
||||
current_context[entity_id] == expected
|
||||
for entity_id, expected in comparable
|
||||
)
|
||||
/ len(comparable)
|
||||
if comparable
|
||||
else 0.5
|
||||
context_score = _weighted_context_score(
|
||||
comparable,
|
||||
current_context,
|
||||
context_weights or {},
|
||||
)
|
||||
score = pattern.weight * (0.85 + 0.15 * context_score)
|
||||
by_state.setdefault(pattern.target_state, []).append(score)
|
||||
@@ -1453,11 +1989,10 @@ def predict_behavior(
|
||||
for entity_id, expected in pattern.context_states.items()
|
||||
if entity_id in current_context
|
||||
]
|
||||
context_score = (
|
||||
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
|
||||
/ len(comparable)
|
||||
if comparable
|
||||
else 0.5
|
||||
context_score = _weighted_context_score(
|
||||
comparable,
|
||||
current_context,
|
||||
context_weights or {},
|
||||
)
|
||||
score = pattern.weight * (
|
||||
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
||||
@@ -1490,6 +2025,25 @@ def predict_behavior(
|
||||
)
|
||||
|
||||
|
||||
def _weighted_context_score(
|
||||
comparable: list[tuple[str, str]],
|
||||
current_context: dict[str, str | None],
|
||||
context_weights: dict[str, float],
|
||||
) -> float:
|
||||
if not comparable:
|
||||
return 0.5
|
||||
total_weight = 0.0
|
||||
matched_weight = 0.0
|
||||
for entity_id, expected in comparable:
|
||||
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
|
||||
total_weight += weight
|
||||
if current_context.get(entity_id) == expected:
|
||||
matched_weight += weight
|
||||
if total_weight <= 0:
|
||||
return 0.5
|
||||
return matched_weight / total_weight
|
||||
|
||||
|
||||
def service_for_state(domain: str, target_state: str) -> str | None:
|
||||
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
|
||||
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
|
||||
|
||||
@@ -21,6 +21,8 @@ class Settings:
|
||||
prediction_interval_seconds: int = 60
|
||||
execution_cooldown_seconds: int = 900
|
||||
timezone: str = "Europe/Berlin"
|
||||
ha_timeout_seconds: int = 25
|
||||
dashboard_cache_refresh_seconds: int = 3600
|
||||
|
||||
@property
|
||||
def ha_configured(self) -> bool:
|
||||
@@ -55,4 +57,8 @@ def load_settings() -> Settings:
|
||||
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
|
||||
),
|
||||
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
|
||||
ha_timeout_seconds=max(5, int(os.getenv("SILLYHOME_HA_TIMEOUT_SECONDS", "25"))),
|
||||
dashboard_cache_refresh_seconds=max(
|
||||
300, int(os.getenv("SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS", "3600"))
|
||||
),
|
||||
)
|
||||
|
||||
100
app/main.py
100
app/main.py
@@ -12,6 +12,7 @@ from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.actuators.cache_db import DashboardCache
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.api.v1.actuators import router as actuators_router
|
||||
@@ -20,6 +21,7 @@ from app.behavior.engine import BehaviorEngine
|
||||
from app.config import load_settings
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.discovery import discover_entities
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
@@ -47,8 +49,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
reconcile_task: asyncio.Task[None] | None = None
|
||||
event_listener_task: asyncio.Task[None] | None = None
|
||||
fallback_task: asyncio.Task[None] | None = None
|
||||
cache_refresh_task: asyncio.Task[None] | None = None
|
||||
app.state.registry = ModelRegistry(settings.model_store)
|
||||
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
||||
app.state.dashboard_cache = DashboardCache(
|
||||
Path(settings.actuator_store).resolve() / "dashboard_cache.sqlite3"
|
||||
)
|
||||
if hasattr(app.state, "ha_reader"):
|
||||
del app.state.ha_reader
|
||||
if hasattr(app.state, "actuator_service"):
|
||||
@@ -60,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings=HaClientSettings(
|
||||
url=cast(str, settings.ha_url),
|
||||
token=cast(str, settings.ha_token),
|
||||
timeout_seconds=settings.ha_timeout_seconds,
|
||||
)
|
||||
)
|
||||
app.state.ha_reader = HaReader(client=client)
|
||||
@@ -79,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
||||
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
||||
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
||||
cache_refresh_task = asyncio.create_task(_periodic_dashboard_cache_refresh(app))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -98,6 +106,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
fallback_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await fallback_task
|
||||
if cache_refresh_task is not None:
|
||||
cache_refresh_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await cache_refresh_task
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
@@ -105,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Next API",
|
||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||
version="1.4.0",
|
||||
version="1.7.2",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
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.")
|
||||
|
||||
|
||||
async def _periodic_dashboard_cache_refresh(app: FastAPI) -> None:
|
||||
await asyncio.sleep(2)
|
||||
while True:
|
||||
await _refresh_dashboard_cache(app, trigger="scheduled")
|
||||
await asyncio.sleep(app.state.settings.dashboard_cache_refresh_seconds)
|
||||
|
||||
|
||||
async def _refresh_dashboard_cache(app: FastAPI, *, trigger: str) -> None:
|
||||
ha_reader = getattr(app.state, "ha_reader", None)
|
||||
cache = getattr(app.state, "dashboard_cache", None)
|
||||
if not isinstance(ha_reader, HaReader) or not isinstance(cache, DashboardCache):
|
||||
return
|
||||
try:
|
||||
entities = await asyncio.to_thread(ha_reader.read_entities)
|
||||
groups = _discovery_group_payload(list(entities))
|
||||
await asyncio.to_thread(
|
||||
cache.save_entities_payload,
|
||||
entities=list(entities),
|
||||
discovery_groups=groups,
|
||||
)
|
||||
logger.info("Dashboard-Cache aktualisiert (%s): %d Entities", trigger, len(entities))
|
||||
except Exception as exc:
|
||||
logger.warning("Dashboard-Cache konnte nicht aktualisiert werden (%s): %s", trigger, exc)
|
||||
|
||||
|
||||
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
|
||||
group_counts: dict[tuple[str, str], int] = {}
|
||||
for entity in discover_entities(entities):
|
||||
key = (entity.category, entity.role.value)
|
||||
group_counts[key] = group_counts.get(key, 0) + 1
|
||||
return [
|
||||
{"category": category, "role": role, "count": count}
|
||||
for (category, role), count in sorted(group_counts.items())
|
||||
]
|
||||
|
||||
|
||||
async def _startup_reconciliation(app: FastAPI) -> None:
|
||||
delay_seconds = 5
|
||||
while True:
|
||||
@@ -207,15 +255,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||
auth_token = cast(str, settings.ha_token)
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
reconnect_delay = 1.0
|
||||
relevant_entity_ids: set[str] = set()
|
||||
relevant_loaded_at = 0.0
|
||||
while True:
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connecting"
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
ping_interval=20,
|
||||
ping_timeout=10,
|
||||
) as websocket:
|
||||
async with websockets.connect(ws_url, ping_interval=None) as websocket:
|
||||
auth_required_msg = await websocket.recv()
|
||||
auth_required_data = json.loads(auth_required_msg)
|
||||
if auth_required_data.get("type") != "auth_required":
|
||||
@@ -239,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
|
||||
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
||||
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
||||
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||
relevant_loaded_at = asyncio.get_running_loop().time()
|
||||
reconnect_delay = 1.0
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connected"
|
||||
ws_status.error = None
|
||||
@@ -265,10 +315,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
entity_id = event_data.get("entity_id")
|
||||
if not entity_id:
|
||||
continue
|
||||
loop_time = asyncio.get_running_loop().time()
|
||||
if loop_time - relevant_loaded_at >= 10:
|
||||
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||
relevant_loaded_at = loop_time
|
||||
if entity_id not in relevant_entity_ids:
|
||||
continue
|
||||
new_state = event_data.get("new_state")
|
||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||
if not _is_relevant_state_change(store, str(entity_id)):
|
||||
continue
|
||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||
await asyncio.to_thread(
|
||||
@@ -286,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
websockets.exceptions.InvalidStatus,
|
||||
OSError,
|
||||
) as exc:
|
||||
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
|
||||
delay = reconnect_delay
|
||||
logger.warning(
|
||||
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
|
||||
exc,
|
||||
delay,
|
||||
)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "reconnecting"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||
except Exception as exc:
|
||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||
|
||||
|
||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||
@@ -311,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
||||
await asyncio.sleep(
|
||||
app.state.settings.prediction_interval_seconds
|
||||
if websocket_connected
|
||||
else min(5, app.state.settings.prediction_interval_seconds)
|
||||
else max(30, app.state.settings.prediction_interval_seconds)
|
||||
)
|
||||
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
@@ -347,15 +408,14 @@ def _update_ha_state_cache(
|
||||
)
|
||||
|
||||
|
||||
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
|
||||
def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for record in store.list():
|
||||
if record.actuator_entity_id == entity_id:
|
||||
return True
|
||||
if record.assignment.selected_numeric_entity_id == entity_id:
|
||||
return True
|
||||
if entity_id in record.assignment.selected_context_entity_ids:
|
||||
return True
|
||||
return False
|
||||
result.add(record.actuator_entity_id)
|
||||
if record.assignment.selected_numeric_entity_id:
|
||||
result.add(record.assignment.selected_numeric_entity_id)
|
||||
result.update(record.assignment.selected_context_entity_ids)
|
||||
return result
|
||||
|
||||
|
||||
def _ha_entity_from_event(
|
||||
|
||||
@@ -39,8 +39,10 @@
|
||||
.header-actions label { margin:0; font-size:.82rem; }
|
||||
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; }
|
||||
.dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); }
|
||||
main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; }
|
||||
main { display:block; padding:12px; max-width:1480px; margin:0 auto; }
|
||||
section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; }
|
||||
.app-view { display:none; }
|
||||
.app-view.active { display:block; }
|
||||
section:target { outline:2px solid var(--complement); outline-offset:2px; }
|
||||
.control-panel { grid-area:control; align-self:start; position:sticky; top:58px; }
|
||||
.board-panel { grid-area:board; }
|
||||
@@ -50,10 +52,14 @@
|
||||
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
|
||||
.toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; }
|
||||
.toolbar button { margin-top:0; }
|
||||
.detail-tools { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:8px; margin:10px 0; align-items:end; }
|
||||
.detail-tools button { margin-top:0; }
|
||||
details.collapsible > summary,
|
||||
.manual-context > summary,
|
||||
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
|
||||
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
|
||||
.manual-context > summary,
|
||||
.group-panel > summary { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
||||
details.collapsible > summary::-webkit-details-marker,
|
||||
.manual-context > summary::-webkit-details-marker,
|
||||
.group-panel > summary::-webkit-details-marker { display:none; }
|
||||
@@ -118,7 +124,7 @@
|
||||
.topbar { display:grid; }
|
||||
.header-actions { min-width:0; }
|
||||
.status-pill { width:max-content; max-width:100%; white-space:normal; }
|
||||
main { display:block; padding:8px; }
|
||||
main { padding:8px; }
|
||||
.control-panel { position:static; }
|
||||
section { margin-bottom:10px; padding:10px; border-radius:8px; }
|
||||
.steps { grid-template-columns:1fr; }
|
||||
@@ -140,32 +146,28 @@
|
||||
<div class="brand">
|
||||
<div class="brand-row">
|
||||
<span class="brand-mark">SH</span>
|
||||
<h1>SillyHome Next</h1>
|
||||
<h1>SillyHome</h1>
|
||||
</div>
|
||||
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
|
||||
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
||||
<p>Geräte, Lernen, Freigaben und Systemzustand.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<label for="section-jump">Menü</label>
|
||||
<select id="section-jump" onchange="jumpToSection(this.value)">
|
||||
<option value="#choose">Steuerung</option>
|
||||
<option value="#observed">Geräte</option>
|
||||
<option value="#detail">Freigabe</option>
|
||||
<option value="#status-section">System</option>
|
||||
<option value="#guide">Ablauf</option>
|
||||
<select id="section-jump" onchange="showView(this.value)">
|
||||
<option value="status-section">Startseite / System</option>
|
||||
<option value="observed">Lernen</option>
|
||||
<option value="choose">Discovery & Einrichtung</option>
|
||||
<option value="settings">Einstellungen</option>
|
||||
</select>
|
||||
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="control-panel" id="choose">
|
||||
<section class="control-panel app-view" id="choose">
|
||||
<div class="panel-title">
|
||||
<h2>Steuerung</h2>
|
||||
<span class="chip">v1</span>
|
||||
<h2>Discovery & Einrichtung</h2>
|
||||
</div>
|
||||
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
|
||||
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
||||
<label for="actuator-input">Entity-ID</label>
|
||||
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
||||
<datalist id="actuator-options"></datalist>
|
||||
<div class="inline-controls">
|
||||
@@ -195,43 +197,42 @@
|
||||
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<label for="actuator-select">Oder aus Liste wählen</label>
|
||||
<label for="actuator-select">Geräteliste</label>
|
||||
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
|
||||
<option value="">Geräteliste bei Bedarf laden</option>
|
||||
</select>
|
||||
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
||||
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button>
|
||||
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
||||
<p id="actuator-config-result" class="muted">Bereit.</p>
|
||||
<details class="manual-context">
|
||||
<summary>Vorschläge anzeigen</summary>
|
||||
<p class="muted">Vorschläge können Home Assistant stark abfragen und werden deshalb nicht beim Start geladen.</p>
|
||||
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
|
||||
</details>
|
||||
<div id="actuator-suggestions" class="card-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="board-panel" id="observed">
|
||||
<section class="board-panel app-view" id="observed">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<h2>Beobachtete Geräte</h2>
|
||||
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
||||
</div>
|
||||
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="configured-actuators">Noch nicht geladen.</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-panel" id="detail">
|
||||
<h2>Lernfortschritt und Freigabe</h2>
|
||||
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
|
||||
<section class="detail-panel app-view" id="detail">
|
||||
<div class="panel-title">
|
||||
<h2>Details</h2>
|
||||
<button class="secondary compact" onclick="showView('observed')">Zurück</button>
|
||||
</div>
|
||||
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
||||
</section>
|
||||
|
||||
<section class="status-panel" id="status-section">
|
||||
<section class="status-panel app-view active" id="status-section">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<h2>System & Cache</h2>
|
||||
<p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt erst bei Bedarf.</p>
|
||||
</div>
|
||||
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
|
||||
</div>
|
||||
@@ -241,30 +242,22 @@
|
||||
<div id="job-queue" class="decision-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="guide-panel" id="guide">
|
||||
<details class="collapsible">
|
||||
<summary><span>So gehst du vor</span></summary>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<span class="step-number">1</span>
|
||||
<h3>Aktor auswählen</h3>
|
||||
<p><strong>Wo?</strong> Links im Feld „Gerät auswählen“.</p>
|
||||
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-number">2</span>
|
||||
<h3>Wie gewohnt bedienen</h3>
|
||||
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
|
||||
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-number">3</span>
|
||||
<h3>Später freigeben</h3>
|
||||
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
|
||||
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
|
||||
<section class="guide-panel app-view" id="settings">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<h2>Einstellungen</h2>
|
||||
<p class="muted">Sprache und Standardwerte für die Bedienoberfläche.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-two">
|
||||
<div>
|
||||
<label for="language-select">Sprache</label>
|
||||
<select id="language-select" onchange="setLanguage(this.value)">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
@@ -281,7 +274,12 @@ let manualContextState = {options: [], selected: new Set()};
|
||||
let cachedActuators = null;
|
||||
let cachedEntities = null;
|
||||
let cachedDiscovery = null;
|
||||
let cachedSystemOverview = null;
|
||||
let cachedDashboardOverview = null;
|
||||
let cachedDetailHtml = new Map();
|
||||
let discoveryLoadPromise = null;
|
||||
let overviewLoadPromise = null;
|
||||
let systemLoadPromise = null;
|
||||
let currentSensorWeightGroups = [];
|
||||
let visibleActuatorLimit = 24;
|
||||
const ACTUATOR_RESULT_LIMIT = 50;
|
||||
@@ -358,14 +356,130 @@ const I18N = {
|
||||
error: "Fehler",
|
||||
},
|
||||
},
|
||||
en: {
|
||||
safety_stage: {
|
||||
observe: "Observe only",
|
||||
suggest: "Show suggestions",
|
||||
shadow: "Review mode without switching",
|
||||
partial: "Partial approval",
|
||||
active: "Active approval",
|
||||
},
|
||||
behavior_mode: {
|
||||
shadow: "Review mode",
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
},
|
||||
behavior_status: {
|
||||
collecting: "Collecting examples",
|
||||
trained: "Learned",
|
||||
blocked: "Blocked",
|
||||
},
|
||||
lifecycle_status: {
|
||||
trained: "learned",
|
||||
pending_history: "collecting history",
|
||||
pending_assignment: "finding context",
|
||||
review_required: "review required",
|
||||
archived: "waiting",
|
||||
orphaned: "actuator missing",
|
||||
stale: "training stale",
|
||||
invalid: "invalid",
|
||||
},
|
||||
job_status: {
|
||||
pending: "waiting",
|
||||
running: "running",
|
||||
completed: "completed",
|
||||
failed: "failed",
|
||||
},
|
||||
job_kind: {
|
||||
discovery: "Discovery",
|
||||
reconciliation: "Reconciliation",
|
||||
training: "Training",
|
||||
evaluation: "Evaluation",
|
||||
automation_refresh: "Automation check",
|
||||
},
|
||||
severity: {
|
||||
info: "Info",
|
||||
warning: "Warning",
|
||||
critical: "Critical",
|
||||
},
|
||||
anomaly_category: {
|
||||
missing_context: "missing context",
|
||||
low_samples: "not enough samples",
|
||||
unclear_sources: "unclear sources",
|
||||
stale_training: "stale training",
|
||||
low_confidence_prediction: "low confidence",
|
||||
manual_block: "manual block",
|
||||
safety_blockers: "safety blockers",
|
||||
automation_conflict: "automation conflict",
|
||||
feedback_error_rate: "feedback error rate",
|
||||
},
|
||||
performance_status: {
|
||||
ok: "fast",
|
||||
slow: "too slow",
|
||||
running: "running",
|
||||
unknown: "unknown",
|
||||
},
|
||||
connection_status: {
|
||||
connected: "connected",
|
||||
disconnected: "disconnected",
|
||||
unavailable: "unavailable",
|
||||
error: "error",
|
||||
},
|
||||
},
|
||||
};
|
||||
const uiLang = "de";
|
||||
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
|
||||
|
||||
function jumpToSection(target) {
|
||||
if (!target) return;
|
||||
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
|
||||
}
|
||||
|
||||
function showView(viewId) {
|
||||
if (viewId === "detail" && !currentActuatorId) {
|
||||
viewId = "observed";
|
||||
}
|
||||
for (const section of document.querySelectorAll(".app-view")) {
|
||||
section.classList.toggle("active", section.id === viewId);
|
||||
}
|
||||
localStorage.setItem("sillyhome.ui.view", viewId);
|
||||
if (viewId === "status-section") {
|
||||
if (cachedSystemOverview) {
|
||||
renderDashboardStatus(cachedSystemOverview);
|
||||
refreshSystemOverviewInBackground();
|
||||
} else {
|
||||
void loadSystemOverview();
|
||||
}
|
||||
} else if (viewId === "observed") {
|
||||
if (cachedActuators) {
|
||||
renderConfiguredActuators();
|
||||
refreshOverviewInBackground();
|
||||
} else {
|
||||
void loadOverview();
|
||||
}
|
||||
} else if (viewId === "choose") {
|
||||
if (cachedDiscovery) renderActuatorDiscovery();
|
||||
} else if (viewId === "settings") {
|
||||
syncSettingsView();
|
||||
}
|
||||
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
|
||||
}
|
||||
|
||||
function setLanguage(language) {
|
||||
uiLang = I18N[language] ? language : "de";
|
||||
localStorage.setItem("sillyhome.ui.language", uiLang);
|
||||
syncSettingsView();
|
||||
if (cachedActuators) renderConfiguredActuators();
|
||||
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
|
||||
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
|
||||
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsView() {
|
||||
const select = document.getElementById("language-select");
|
||||
if (select) select.value = uiLang;
|
||||
}
|
||||
|
||||
function translate(group, value, fallback = "") {
|
||||
if (value == null || value === "") return fallback || "offen";
|
||||
return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
|
||||
@@ -387,6 +501,9 @@ function invalidateDashboardCache() {
|
||||
cachedActuators = null;
|
||||
cachedEntities = null;
|
||||
cachedDiscovery = null;
|
||||
cachedDashboardOverview = null;
|
||||
cachedSystemOverview = null;
|
||||
cachedDetailHtml.clear();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -401,6 +518,11 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await api(path, {signal: controller.signal});
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error("Zeitlimit erreicht; Daten laden im Hintergrund weiter.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@@ -522,13 +644,24 @@ function optionGroups(entities, selectedIds = new Set()) {
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
if (overviewLoadPromise) return overviewLoadPromise;
|
||||
overviewLoadPromise = doLoadOverview().finally(() => {
|
||||
overviewLoadPromise = null;
|
||||
});
|
||||
return overviewLoadPromise;
|
||||
}
|
||||
|
||||
async function doLoadOverview() {
|
||||
const startedAt = performance.now();
|
||||
const budget = document.getElementById("load-budget");
|
||||
if (budget) budget.textContent = "Startdaten laden ...";
|
||||
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
||||
if (!cachedActuators) {
|
||||
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
||||
}
|
||||
try {
|
||||
const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS);
|
||||
const dashboard = await api("v1/actuators/dashboard/start");
|
||||
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
||||
cachedDashboardOverview = dashboard;
|
||||
cachedActuators = dashboard.actuators || [];
|
||||
cachedEntities = [];
|
||||
renderDashboardStatus(dashboard);
|
||||
@@ -552,6 +685,53 @@ async function loadOverview() {
|
||||
scheduleDashboardExtras();
|
||||
}
|
||||
|
||||
async function loadSystemOverview() {
|
||||
if (systemLoadPromise) return systemLoadPromise;
|
||||
systemLoadPromise = doLoadSystemOverview().finally(() => {
|
||||
systemLoadPromise = null;
|
||||
});
|
||||
return systemLoadPromise;
|
||||
}
|
||||
|
||||
async function doLoadSystemOverview() {
|
||||
const startedAt = performance.now();
|
||||
const budget = document.getElementById("load-budget");
|
||||
if (budget) budget.textContent = "Systemübersicht lädt ...";
|
||||
try {
|
||||
const dashboard = await api("v1/actuators/dashboard/system");
|
||||
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
||||
cachedSystemOverview = dashboard;
|
||||
cachedActuators = dashboard.actuators || cachedActuators;
|
||||
renderDashboardStatus(dashboard);
|
||||
if (budget) {
|
||||
const loadMs = dashboard._load_elapsed_ms;
|
||||
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
||||
? `Systemübersicht bereit in ${loadMs} ms`
|
||||
: `Systemübersicht langsam: ${loadMs} ms`;
|
||||
}
|
||||
scheduleDashboardExtras();
|
||||
} catch (error) {
|
||||
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
|
||||
if (budget) budget.textContent = "Systemübersicht verzögert";
|
||||
}
|
||||
}
|
||||
|
||||
function refreshOverviewInBackground() {
|
||||
if (!overviewLoadPromise) {
|
||||
overviewLoadPromise = doLoadOverview().finally(() => {
|
||||
overviewLoadPromise = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSystemOverviewInBackground() {
|
||||
if (!systemLoadPromise) {
|
||||
systemLoadPromise = doLoadSystemOverview().finally(() => {
|
||||
systemLoadPromise = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDashboardExtras() {
|
||||
const run = () => {
|
||||
void loadDashboardExtras();
|
||||
@@ -857,6 +1037,20 @@ function selectActuatorFromList() {
|
||||
if (value) document.getElementById("actuator-input").value = value;
|
||||
}
|
||||
|
||||
function selectDetailActuator() {
|
||||
const value = document.getElementById("detail-actuator-select")?.value;
|
||||
if (value) void showActuator(value);
|
||||
}
|
||||
|
||||
function detailActuatorOptions(selectedId) {
|
||||
const rows = cachedActuators || [];
|
||||
return rows.map(record => `
|
||||
<option value="${escapeHtml(record.actuator_entity_id)}" ${record.actuator_entity_id === selectedId ? "selected" : ""}>
|
||||
${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}
|
||||
</option>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderManualContextSelect() {
|
||||
const select = document.getElementById("manual-context-select");
|
||||
if (!select) return;
|
||||
@@ -974,10 +1168,25 @@ function renderConfiguredActuators() {
|
||||
|
||||
async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
currentActuatorId = actuatorId;
|
||||
for (const section of document.querySelectorAll(".app-view")) {
|
||||
section.classList.toggle("active", section.id === "detail");
|
||||
}
|
||||
document.getElementById("section-jump").value = "observed";
|
||||
localStorage.setItem("sillyhome.ui.view", "detail");
|
||||
const box = document.getElementById("actuator-detail");
|
||||
renderActuatorDetailShell(actuatorId);
|
||||
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
|
||||
box.innerHTML = cachedDetailHtml.get(actuatorId);
|
||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
renderConfiguredActuators();
|
||||
return;
|
||||
}
|
||||
if (cachedDetailHtml.has(actuatorId)) {
|
||||
box.innerHTML = cachedDetailHtml.get(actuatorId);
|
||||
} else {
|
||||
renderActuatorDetailShell(actuatorId);
|
||||
}
|
||||
try {
|
||||
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
||||
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
|
||||
contextOptions = [];
|
||||
const contexts = [
|
||||
record.assignment.selected_numeric_entity_id,
|
||||
@@ -1043,6 +1252,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
||||
</details>
|
||||
`;
|
||||
const simulationControls = weightedCandidates.length ? `
|
||||
<details class="manual-context" open>
|
||||
<summary>Aktor-Simulation</summary>
|
||||
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten.</p>
|
||||
<div class="card-list">
|
||||
${weightedCandidates.map(candidate => {
|
||||
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
|
||||
const currentState = candidate.state || "";
|
||||
const stateOptions = candidate.domain === "binary_sensor"
|
||||
? "off,on"
|
||||
: currentState;
|
||||
return `
|
||||
<article class="actuator-card">
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
|
||||
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
|
||||
</div>
|
||||
<span class="chip">Simulation</span>
|
||||
</div>
|
||||
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
|
||||
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
|
||||
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
|
||||
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
|
||||
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
|
||||
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
|
||||
</article>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
|
||||
</div>
|
||||
<div id="simulation-result" class="decision-list"></div>
|
||||
</details>
|
||||
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
|
||||
const currentContextControls = contexts.length
|
||||
? `<ul>${contexts.map(entityId => `
|
||||
<li>
|
||||
@@ -1177,9 +1422,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||
pattern => pattern.source === "automation",
|
||||
).length;
|
||||
const learnedAutomationActions = "wird bei Bedarf im Training ausgewertet";
|
||||
const relatedAutomations = record.behavior.related_automations || [];
|
||||
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
|
||||
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
|
||||
@@ -1199,7 +1442,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
const manualAssignment = `
|
||||
<details class="manual-context">
|
||||
<summary>Kontext selbst festlegen</summary>
|
||||
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
|
||||
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
||||
<select id="manual-numeric-select">
|
||||
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
||||
@@ -1246,13 +1488,21 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
||||
box.innerHTML = `
|
||||
const detailHtml = `
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
||||
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
|
||||
</div>
|
||||
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button>
|
||||
</div>
|
||||
<div class="detail-tools">
|
||||
<button class="secondary" onclick="showView('observed')">Zurück zur Übersicht</button>
|
||||
<div>
|
||||
<label for="detail-actuator-select">Anderes Gerät</label>
|
||||
<select id="detail-actuator-select" onchange="selectDetailActuator()">
|
||||
${detailActuatorOptions(record.actuator_entity_id)}
|
||||
</select>
|
||||
</div>
|
||||
<button class="secondary" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Aktualisieren</button>
|
||||
</div>
|
||||
<div class="grid-two">
|
||||
<div>
|
||||
@@ -1260,7 +1510,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
||||
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
||||
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
|
||||
<p class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
|
||||
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1268,13 +1517,12 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
|
||||
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
|
||||
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
|
||||
<p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p>
|
||||
<p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
|
||||
<p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p>
|
||||
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
||||
<p><strong>Statusgrund:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
||||
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
||||
<div class="actions">${activationButton}</div>
|
||||
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
||||
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
|
||||
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1291,21 +1539,22 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
${adaptivePanel}
|
||||
${anomalyPanel}
|
||||
<h3>Passende Home-Assistant-Automationen</h3>
|
||||
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
|
||||
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
||||
${automationControls}
|
||||
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
||||
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
||||
<h3>Sensor-Gewichtung</h3>
|
||||
<p class="muted">Automatische Relevanz kommt aus der Zuordnung. Die aktive Gewichtung kannst du korrigieren; Gruppen bündeln mehrere Sensoren/Zustände.</p>
|
||||
${weightControls}
|
||||
${weightGroupControls}
|
||||
${simulationControls}
|
||||
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
||||
${currentContextControls}
|
||||
${manualAssignment}
|
||||
`;
|
||||
void hydrateContextOptions(record);
|
||||
box.innerHTML = detailHtml;
|
||||
cachedDetailHtml.set(actuatorId, detailHtml);
|
||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
renderConfiguredActuators();
|
||||
} catch (error) {
|
||||
box.textContent = error.message;
|
||||
}
|
||||
@@ -1316,13 +1565,13 @@ function renderActuatorDetailShell(actuatorId) {
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(actuatorId)}</h3>
|
||||
<p class="muted">Basisdaten werden geladen ...</p>
|
||||
<p class="muted">Lädt ...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
|
||||
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
|
||||
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
|
||||
<div class="metric"><strong>Status</strong>...</div>
|
||||
<div class="metric"><strong>Lernen</strong>...</div>
|
||||
<div class="metric"><strong>Kontext</strong>...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1398,6 +1647,58 @@ async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function simulateActuator(actuatorId) {
|
||||
const sensorStates = {};
|
||||
const sensorWeights = {};
|
||||
const stateOptions = {};
|
||||
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
|
||||
const value = input.value.trim();
|
||||
if (value) sensorStates[input.dataset.simStateEntity] = value;
|
||||
}
|
||||
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
|
||||
const value = Number(input.value);
|
||||
if (Number.isFinite(value)) {
|
||||
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
|
||||
}
|
||||
}
|
||||
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
|
||||
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
|
||||
}
|
||||
const box = document.getElementById("simulation-result");
|
||||
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
|
||||
try {
|
||||
const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sensor_states: sensorStates,
|
||||
sensor_weights: sensorWeights,
|
||||
state_options: stateOptions,
|
||||
max_results: 6,
|
||||
}),
|
||||
});
|
||||
box.innerHTML = results.length ? results.map((result, index) => {
|
||||
const prediction = result.prediction;
|
||||
const factors = result.decision_factors || [];
|
||||
return `
|
||||
<div class="decision-row">
|
||||
<header>
|
||||
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
|
||||
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
|
||||
</header>
|
||||
<p>${escapeHtml(result.recommendation || "")}</p>
|
||||
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
|
||||
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
|
||||
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
|
||||
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
|
||||
} catch (error) {
|
||||
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManualAssignment(actuatorId) {
|
||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||
const selectedContextIds = Array.from(
|
||||
@@ -1609,11 +1910,16 @@ async function removeActuator(actuatorId) {
|
||||
|
||||
async function startDashboard() {
|
||||
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
|
||||
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Geräte werden geladen.</div>";
|
||||
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
|
||||
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
|
||||
syncSettingsView();
|
||||
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
|
||||
? "observed"
|
||||
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
|
||||
document.getElementById("section-jump").value = initialView;
|
||||
showView(initialView);
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
await loadOverview();
|
||||
void loadStatus();
|
||||
setTimeout(() => void loadStatus(), 100);
|
||||
}
|
||||
|
||||
void startDashboard();
|
||||
|
||||
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]
|
||||
name = "sillyhome-next"
|
||||
version = "1.4.0"
|
||||
version = "1.7.2"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import perf_counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.actuators import _deduplicate_actuator_ids
|
||||
from app.actuators.cache_db import DashboardCache
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import JobStatus, ModelSnapshot
|
||||
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
from app.config import Settings
|
||||
from app.api.v1.actuators import _deduplicate_actuator_ids
|
||||
from app.ha.discovery import DiscoveredEntity
|
||||
from app.ha.discovery import discover_entities
|
||||
from app.ha.history import (
|
||||
@@ -146,6 +149,7 @@ def _install_service(tmp_path: Path) -> None:
|
||||
)
|
||||
app.state.registry = ModelRegistry(tmp_path / "models")
|
||||
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
|
||||
app.state.dashboard_cache = DashboardCache(tmp_path / "actuators" / "dashboard_cache.sqlite3")
|
||||
app.state.ha_reader = FakeHaReader(
|
||||
entities,
|
||||
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
|
||||
@@ -269,6 +273,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No
|
||||
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
||||
|
||||
|
||||
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)
|
||||
@@ -351,6 +440,51 @@ def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -
|
||||
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
||||
|
||||
|
||||
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.post(
|
||||
"/v1/actuators",
|
||||
json={"actuator_entity_id": "light.abstellkammer"},
|
||||
)
|
||||
|
||||
feedback = client.post(
|
||||
"/v1/actuators/light.abstellkammer/feedback",
|
||||
json={"correct": False, "kind": "never_automate"},
|
||||
)
|
||||
|
||||
assert feedback.status_code == 200
|
||||
payload = feedback.json()
|
||||
assert payload["behavior"]["safety"]["manual_block"] is True
|
||||
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
|
||||
|
||||
|
||||
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
|
||||
backup = client.get("/v1/actuators/backup/export")
|
||||
dry_run = client.post(
|
||||
"/v1/actuators/light.abstellkammer/dry-run",
|
||||
json={"enabled": True},
|
||||
)
|
||||
planning = client.post("/v1/actuators/planning/refresh")
|
||||
restore = client.post(
|
||||
"/v1/actuators/backup/restore",
|
||||
json={"backup": backup.json(), "replace_existing": True},
|
||||
)
|
||||
|
||||
assert backup.status_code == 200
|
||||
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
|
||||
assert dry_run.status_code == 200
|
||||
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
|
||||
assert planning.status_code == 200
|
||||
assert "agent_insights" in planning.json()[0]["behavior"]
|
||||
assert restore.status_code == 200
|
||||
assert restore.json()["restored_records"] == 1
|
||||
|
||||
|
||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
@@ -446,14 +580,17 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
|
||||
|
||||
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"
|
||||
@@ -462,6 +599,47 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
|
||||
assert anomalies_response.json()
|
||||
|
||||
|
||||
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.get("/v1/actuators/discovery")
|
||||
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
|
||||
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
|
||||
raise AssertionError("full entity payload must not be loaded")
|
||||
|
||||
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
|
||||
|
||||
system_response = client.get("/v1/actuators/dashboard/system")
|
||||
start_response = client.get("/v1/actuators/dashboard/start")
|
||||
|
||||
assert system_response.status_code == 200
|
||||
assert system_response.json()["actuators"] == []
|
||||
assert system_response.json()["cache"]["entity_count"] == 4
|
||||
assert start_response.status_code == 200
|
||||
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
||||
|
||||
|
||||
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.get("/v1/actuators/discovery")
|
||||
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
|
||||
response = client.get("/v1/actuators/light.abstellkammer/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["behavior"]["patterns"] == []
|
||||
assert all(
|
||||
snapshot["patterns"] == []
|
||||
for snapshot in payload["behavior"]["model_snapshots"]
|
||||
)
|
||||
|
||||
|
||||
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
@@ -778,3 +778,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
|
||||
assert reader.service_calls == [
|
||||
("light", "turn_on", {"entity_id": "light.storage"})
|
||||
]
|
||||
|
||||
|
||||
def test_event_evaluation_records_decision_timeline_and_latency(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("light.storage")
|
||||
record = record.model_copy(
|
||||
update={
|
||||
"assignment": record.assignment.model_copy(
|
||||
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
|
||||
),
|
||||
"behavior": record.behavior.model_copy(
|
||||
update={
|
||||
"mode": BehaviorMode.ACTIVE,
|
||||
"status": BehaviorStatus.TRAINED,
|
||||
"activation_ready": True,
|
||||
"patterns": [
|
||||
BehaviorPattern(
|
||||
target_state="on",
|
||||
minute_of_day=60,
|
||||
weekday=0,
|
||||
context_states={"binary_sensor.storage_door": "on"},
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_from_state="off",
|
||||
trigger_to_state="on",
|
||||
source="automation",
|
||||
weight=1.0,
|
||||
observed_at=now - timedelta(days=days_ago),
|
||||
)
|
||||
for days_ago in (3, 2, 1)
|
||||
],
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
store.upsert(record)
|
||||
reader = FakeBehaviorReader(
|
||||
entities=[
|
||||
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.storage_door",
|
||||
domain="binary_sensor",
|
||||
state="on",
|
||||
last_changed=now,
|
||||
),
|
||||
],
|
||||
history=[],
|
||||
logbook=[],
|
||||
)
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
result = engine.evaluate(
|
||||
"light.storage",
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_state="on",
|
||||
event_received_at=now,
|
||||
)
|
||||
|
||||
trace = result.behavior.decision_timeline[-1]
|
||||
latency = result.behavior.latency_measurements[-1]
|
||||
assert trace.trigger_entity_id == "binary_sensor.storage_door"
|
||||
assert trace.target_state == "on"
|
||||
assert trace.executed is True
|
||||
assert latency.trigger_entity_id == "binary_sensor.storage_door"
|
||||
assert latency.executed is True
|
||||
|
||||
|
||||
def test_dry_run_records_without_calling_service(tmp_path: Path) -> None:
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("light.storage")
|
||||
record = record.model_copy(
|
||||
update={
|
||||
"assignment": record.assignment.model_copy(
|
||||
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
|
||||
),
|
||||
"behavior": record.behavior.model_copy(
|
||||
update={
|
||||
"mode": BehaviorMode.ACTIVE,
|
||||
"status": BehaviorStatus.TRAINED,
|
||||
"activation_ready": True,
|
||||
"dry_run_enabled": True,
|
||||
"patterns": [
|
||||
BehaviorPattern(
|
||||
target_state="on",
|
||||
minute_of_day=60,
|
||||
weekday=0,
|
||||
context_states={"binary_sensor.storage_door": "on"},
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_from_state="off",
|
||||
trigger_to_state="on",
|
||||
source="automation",
|
||||
weight=1.0,
|
||||
observed_at=now - timedelta(days=days_ago),
|
||||
)
|
||||
for days_ago in (3, 2, 1)
|
||||
],
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
store.upsert(record)
|
||||
reader = FakeBehaviorReader(
|
||||
entities=[
|
||||
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.storage_door",
|
||||
domain="binary_sensor",
|
||||
state="on",
|
||||
last_changed=now,
|
||||
),
|
||||
],
|
||||
history=[],
|
||||
logbook=[],
|
||||
)
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
result = engine.evaluate("light.storage")
|
||||
|
||||
assert reader.service_calls == []
|
||||
assert result.behavior.dry_run_sample_count == 1
|
||||
assert result.behavior.decision_timeline[-1].executed is False
|
||||
|
||||
@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
|
||||
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
|
||||
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
|
||||
assert config_copy < repository_clone
|
||||
|
||||
|
||||
def test_addon_does_not_trust_forwarded_lan_ips() -> None:
|
||||
run_script = Path("addon/run.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "--proxy-headers" not in run_script
|
||||
assert "--forwarded-allow-ips" not in run_script
|
||||
|
||||
@@ -9,29 +9,32 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "SillyHome Next" in response.text
|
||||
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
|
||||
assert "So gehst du vor" in response.text
|
||||
assert "Steuerung" in response.text
|
||||
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
|
||||
assert "Oder aus Liste wählen" in response.text
|
||||
assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
|
||||
assert "So gehst du vor" not in response.text
|
||||
assert "Discovery & Einrichtung" in response.text
|
||||
assert "Entity-ID" in response.text
|
||||
assert "Geräteliste" in response.text
|
||||
assert "Liste durchsuchen" in response.text
|
||||
assert "Geräteliste bei Bedarf laden" in response.text
|
||||
assert "Vorschläge können Home Assistant stark abfragen" in response.text
|
||||
assert "Wie gewohnt bedienen" in response.text
|
||||
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
|
||||
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
|
||||
assert "Vorschläge können Home Assistant stark abfragen" not in response.text
|
||||
assert '<option value="detail">Details</option>' not in response.text
|
||||
assert "Wie gewohnt bedienen" not in response.text
|
||||
assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
|
||||
assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
|
||||
assert "Freigabestatus" in response.text
|
||||
assert "SillyHome übernehmen lassen" in response.text
|
||||
assert "Passende Home-Assistant-Automationen" in response.text
|
||||
assert "Pausieren" in response.text
|
||||
assert "Davon erkannte HA-Automationen" in response.text
|
||||
assert "Erkannte HA-Automationen" in response.text
|
||||
assert "Aktuelle Situation auswerten" in response.text
|
||||
assert "Kontext selbst festlegen" in response.text
|
||||
assert "Entity-IDs manuell ergänzen" in response.text
|
||||
assert "Zurück zur Übersicht" in response.text
|
||||
assert "Anderes Gerät" in response.text
|
||||
assert "manual-context-freeform" in response.text
|
||||
assert "Diese Kontext-Auswahl speichern" in response.text
|
||||
assert "manual-context-select" in response.text
|
||||
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
|
||||
assert "Die Prüfung simuliert keinen Sensorwechsel" not in response.text
|
||||
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
|
||||
assert "Vorhersage jetzt prüfen" not in response.text
|
||||
assert "record.behavior.activation_ready" in response.text
|
||||
@@ -40,7 +43,9 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
assert 'api("v1/actuators")' not in response.text
|
||||
assert 'api("v1/actuators/summary")' in response.text
|
||||
assert 'api("v1/entities")' not in response.text
|
||||
assert 'details class="collapsible"' in response.text
|
||||
assert 'details class="collapsible"' not in response.text
|
||||
assert 'class="group-panel"' in response.text
|
||||
assert "cachedDetailHtml" in response.text
|
||||
assert "refreshOverviewInBackground" in response.text
|
||||
assert "Automation-Entwurf" not in response.text
|
||||
assert "Manuelle Overrides" not in response.text
|
||||
|
||||
@@ -92,11 +92,10 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
connect.assert_called_once_with(
|
||||
"ws://homeassistant:8123/api/websocket",
|
||||
ping_interval=20,
|
||||
ping_timeout=10,
|
||||
)
|
||||
connect.assert_called_once_with(
|
||||
"ws://homeassistant:8123/api/websocket",
|
||||
ping_interval=None,
|
||||
)
|
||||
assert fake_ws.sent == [
|
||||
{"type": "auth", "access_token": "test-token"},
|
||||
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
||||
@@ -127,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
||||
assert mock_app.state.ws_status.error is None
|
||||
|
||||
|
||||
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
|
||||
async def run_test() -> None:
|
||||
fake_ws = _FakeWebSocket(
|
||||
[
|
||||
'{"type":"auth_required"}',
|
||||
'{"type":"auth_ok"}',
|
||||
(
|
||||
'{"type":"event","event":{"event_type":"state_changed",'
|
||||
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
|
||||
),
|
||||
asyncio.CancelledError(),
|
||||
]
|
||||
)
|
||||
|
||||
with patch("websockets.connect", return_value=fake_ws):
|
||||
try:
|
||||
await _ha_event_listener(mock_app, mock_client)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.state.settings = MagicMock()
|
||||
mock_app.state.settings.ha_url = "http://homeassistant:8123"
|
||||
mock_app.state.settings.ha_token = "test-token"
|
||||
mock_app.state.ws_status = MagicMock()
|
||||
mock_engine = _RecordingBehaviorEngine(tmp_path)
|
||||
mock_app.state.behavior_engine = mock_engine
|
||||
mock_app.state.ha_reader = _FakeHaReader()
|
||||
mock_store = ActuatorStore(tmp_path / "store")
|
||||
mock_store.configure("light.test")
|
||||
mock_app.state.actuator_store = mock_store
|
||||
mock_client = MagicMock()
|
||||
|
||||
anyio.run(run_test)
|
||||
assert mock_engine.state_changes == []
|
||||
|
||||
|
||||
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
||||
app = FastAPI()
|
||||
app.state.settings = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user