Compare commits

..

11 Commits

Author SHA1 Message Date
575211f0db Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 11:53:53 +02:00
d9dc186f9b Fix HA websocket keepalive regression
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 07:48:46 +02:00
214b384b70 Release v1.6.0 dashboard architecture cleanup
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 01:02:29 +02:00
6323b93f23 Fix ingress logging and dashboard cache navigation
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:30:12 +02:00
1d176cce45 Add SQLite dashboard cache
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:07:47 +02:00
bd087728e1 Limit rollback snapshots and relax HA timeouts
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:44:21 +02:00
10f9113547 Stabilize dashboard loading hotfix
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:19:22 +02:00
47fa8eb0ce Split dashboard views and compact detail loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 22:34:38 +02:00
bc4e33ddd8 Localize and streamline dashboard loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 21:56:24 +02:00
9419a9cd8c Add anomaly and performance monitoring
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:58:32 +02:00
2ec2c64cba Add adaptive learning and model rollback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:41:03 +02:00
29 changed files with 2768 additions and 162 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,56 @@
# Changelog
## 1.7.0 - 2026-06-18
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
Event-Latenzmessungen und Dry-run pro Aktor.
- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und
sichtbare Job-Historie.
- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und
`never_automate` speichern; `never_automate` setzt eine manuelle Sperre.
- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und
lokale Agent-Insights aus vorhandenen Daten.
- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro
Home-Assistant-State-Change.
## 1.6.1 - 2026-06-18
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
## 1.6.0 - 2026-06-18
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
materialisieren.
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
der Aktor-IDs.
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
Versions-Chip im Einrichtungsbereich.
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
anderen beobachteten Geraets.
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
## 1.5.4 - 2026-06-18
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
laden nur bei expliziter Aktualisierung neu.
## 1.2.0 - 2026-06-17
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
wertet sie vorsichtig ab.
- Modell-Snapshots mit aktivem Modellstand und Rollback-API ergaenzt.
- Dashboard zeigt Modell-Snapshots, Rollback, Zeitprofile,
adaptive Gewichtungsupdates und Automation-Konflikte.
- Automation-Refresh markiert Konflikte, wenn SillyHome aktiv ist und passende
HA-Automationen parallel aktiv bleiben.
- Zeitprofile fuer Nacht, Morgen, Tag, Abend und Wochenende werden aus
gelernten Handlungen gebildet.
## 1.1.0 - 2026-06-17
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/

View File

@@ -17,6 +17,22 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
- Version 1.1.0 Safety, Transparenz und Job-Queue:
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
- Version 1.3.0 Anomalie- und Performance-Überwachung:
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
- Version 1.5.2 Rollback-Speicher und HA-Timeouts:
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad

View File

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

View File

@@ -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
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from threading import RLock
from app.ha.models import HaEntitySummary
class DashboardCache:
def __init__(self, path: str | Path) -> None:
self._path = Path(path).resolve()
self._path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._init()
def load_entities_payload(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"select entity_id, payload from ha_entities order by entity_id"
).fetchall()
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entities": [json.loads(row[1]) for row in rows],
}
def load_status(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
entity_count = connection.execute("select count(*) from ha_entities").fetchone()[0]
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entity_count": int(entity_count or 0),
}
def load_entity_map(self, entity_ids: set[str]) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
placeholders = ",".join("?" for _ in entity_ids)
with self._lock, self._connect() as connection:
rows = connection.execute(
f"select entity_id, payload from ha_entities where entity_id in ({placeholders})",
tuple(sorted(entity_ids)),
).fetchall()
result: dict[str, HaEntitySummary] = {}
for entity_id, payload in rows:
try:
result[str(entity_id)] = HaEntitySummary.model_validate(json.loads(payload))
except (TypeError, ValueError):
continue
return result
def save_entities_payload(
self,
*,
entities: list[HaEntitySummary],
discovery_groups: list[dict[str, object]],
) -> None:
now = datetime.now(timezone.utc).isoformat()
rows = [
(entity.entity_id, entity.model_dump_json())
for entity in entities
]
with self._lock, self._connect() as connection:
connection.execute("delete from ha_entities")
connection.executemany(
"insert into ha_entities(entity_id, payload) values (?, ?)",
rows,
)
self._set_meta(connection, "ha_entities_updated_at", now)
self._set_meta(
connection,
"discovery_groups",
json.dumps(discovery_groups, ensure_ascii=True, sort_keys=True),
)
def _init(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists ha_entities (
entity_id text primary key,
payload text not null
)
"""
)
connection.execute(
"""
create table if not exists cache_meta (
key text primary key,
value text
)
"""
)
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self._path, timeout=30)
@staticmethod
def _get_meta(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"select value from cache_meta where key = ?",
(key,),
).fetchone()
return str(row[0]) if row is not None and row[0] is not None else None
@staticmethod
def _set_meta(connection: sqlite3.Connection, key: str, value: str) -> None:
connection.execute(
"""
insert into cache_meta(key, value) values (?, ?)
on conflict(key) do update set value = excluded.value
""",
(key, value),
)

View File

@@ -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,38 @@ class DecisionFactor(BaseModel):
evidence: list[str] = Field(default_factory=list)
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
trigger_state: str | None = None
target_state: str | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
executed: bool = False
blocked: bool = False
reason: str = Field(default="", max_length=700)
blockers: list[str] = Field(default_factory=list)
duration_ms: int | None = Field(default=None, ge=0)
class LatencyMeasurement(BaseModel):
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
event_to_decision_ms: int | None = Field(default=None, ge=0)
decision_to_service_ms: int | None = Field(default=None, ge=0)
event_to_done_ms: int | None = Field(default=None, ge=0)
executed: bool = False
source: str = Field(default="manual", max_length=40)
class AdaptiveWeightUpdate(BaseModel):
entity_id: str
previous_weight: float = Field(ge=0.0, le=1.0)
new_weight: float = Field(ge=0.0, le=1.0)
reason: str = Field(max_length=300)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class SafetyRule(BaseModel):
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=160)
@@ -196,6 +236,43 @@ class ExecutionEvent(BaseModel):
executed_at: datetime
class ModelSnapshot(BaseModel):
version_id: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
sample_count: int = Field(default=0, ge=0)
high_confidence_sample_count: int = Field(default=0, ge=0)
average_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
incorrect_feedback_count: int = Field(default=0, ge=0)
patterns: list[BehaviorPattern] = Field(default_factory=list)
reason: str = Field(default="", max_length=500)
class AutomationConflict(BaseModel):
automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
severity: str = Field(default="info", max_length=20)
status: str = Field(default="open", max_length=40)
reason: str = Field(max_length=500)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AnomalyEvent(BaseModel):
anomaly_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
category: str = Field(max_length=40)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=500)
detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
resolved: bool = False
class TimeProfile(BaseModel):
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=80)
sample_count: int = Field(default=0, ge=0)
dominant_state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class RelatedAutomation(BaseModel):
entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$")
config_id: str = Field(min_length=1, max_length=120)
@@ -203,6 +280,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
@@ -230,6 +333,22 @@ class BehaviorState(BaseModel):
confidence_trend: list[float] = Field(default_factory=list)
correct_feedback_count: int = Field(default=0, ge=0)
incorrect_feedback_count: int = Field(default=0, ge=0)
model_snapshots: list[ModelSnapshot] = Field(default_factory=list)
active_model_version: str | None = None
adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list)
anomalies: list[AnomalyEvent] = Field(default_factory=list)
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
feedback_log: list[FeedbackKind] = Field(default_factory=list)
dry_run_enabled: bool = False
dry_run_started_at: datetime | None = None
dry_run_sample_count: int = Field(default=0, ge=0)
dry_run_hit_count: int = Field(default=0, ge=0)
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
agent_insights: list[AgentInsight] = Field(default_factory=list)
class ActuatorRecord(BaseModel):

View File

@@ -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,
*,

View File

@@ -8,8 +8,9 @@ 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, ReconciliationState, SensorWeightGroup
from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
@@ -54,12 +55,39 @@ class WeightOverrideRequest(BaseModel):
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):
safety: SafetyProfile
class ModelRollbackRequest(BaseModel):
version_id: str = Field(min_length=1, max_length=120)
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
@@ -85,6 +113,8 @@ class ActuatorSummary(BaseModel):
activation_ready: bool
activation_reason: str
sample_count: int
anomaly_count: int = 0
critical_anomaly_count: int = 0
prediction_target_state: str | None = None
prediction_confidence: float | None = None
updated_at: str
@@ -104,6 +134,12 @@ class DashboardSystemStatus(BaseModel):
configured_actuators: int = 0
trained_models: int = 0
review_required: int = 0
performance_budget_ms: int = 3000
job_p95_duration_ms: int | None = None
slow_job_count: int = 0
performance_status: str = "unknown"
anomaly_count: int = 0
critical_anomaly_count: int = 0
class DashboardDiscoveryGroup(BaseModel):
@@ -120,6 +156,12 @@ class DashboardOverview(BaseModel):
jobs: JobQueueState = Field(default_factory=JobQueueState)
class AnomalyOverview(BaseModel):
actuator_entity_id: str
friendly_name: str | None = None
anomalies: list[AnomalyEvent] = Field(default_factory=list)
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(
request: Request,
@@ -262,6 +304,14 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
activation_ready=record.behavior.activation_ready,
activation_reason=record.behavior.activation_reason,
sample_count=record.behavior.sample_count,
anomaly_count=len([item for item in record.behavior.anomalies if not item.resolved]),
critical_anomaly_count=len(
[
item
for item in record.behavior.anomalies
if not item.resolved and item.severity == "critical"
]
),
prediction_target_state=(
record.behavior.prediction.target_state
if record.behavior.prediction is not None
@@ -280,27 +330,49 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
@router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
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, include_actuators=True)
@router.get("/dashboard/system", response_model=DashboardOverview)
def dashboard_system(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=False, include_actuators=False)
def _dashboard_overview(
request: Request,
*,
include_background: bool,
include_actuators: bool,
) -> DashboardOverview:
cache_status = _load_entity_cache_status(request)
raw_updated_at = cache_status.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
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
if isinstance(group, dict)
] if isinstance(raw_groups, list) else []
] if include_background and isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
actuators = list_configured_summary(request) if include_actuators else []
store = getattr(request.app.state, "actuator_store", None)
jobs = (
store.load_job_queue()
if isinstance(store, ActuatorStore)
if include_background and isinstance(store, ActuatorStore)
else JobQueueState()
)
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
anomaly_count = sum(record.anomaly_count for record in actuators)
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
entity_count = cache_status.get("entity_count")
if not isinstance(entity_count, int):
entity_count = 0
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -310,14 +382,23 @@ def dashboard_overview(request: Request) -> DashboardOverview:
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,
slow_job_count=slow_job_count,
performance_status=performance_status,
anomaly_count=anomaly_count,
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,
@@ -325,6 +406,71 @@ def dashboard_overview(request: Request) -> DashboardOverview:
)
@router.get("/anomalies", response_model=list[AnomalyOverview])
def list_anomalies(request: Request) -> list[AnomalyOverview]:
records = _service(request).list_configured()
entity_map = _load_cached_entity_map(
request,
{record.actuator_entity_id for record in records},
)
overview: list[AnomalyOverview] = []
for record in records:
active = [item for item in record.behavior.anomalies if not item.resolved]
if not active:
continue
entity = entity_map.get(record.actuator_entity_id)
overview.append(
AnomalyOverview(
actuator_entity_id=record.actuator_entity_id,
friendly_name=entity.friendly_name if entity is not None else None,
anomalies=active,
)
)
return overview
@router.get("/backup/export", response_model=BackupPayload)
def export_backup(request: Request) -> BackupPayload:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator Store nicht initialisiert.",
)
return BackupPayload(
records=store.list(),
reconciliation=store.load_reconciliation_state(),
jobs=store.load_job_queue(),
)
@router.post("/backup/restore", response_model=RestoreResult)
def restore_backup(payload: RestoreRequest, request: Request) -> RestoreResult:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator Store nicht initialisiert.",
)
existing_ids = {record.actuator_entity_id for record in store.list()}
restored = 0
skipped = 0
for record in payload.backup.records:
if record.actuator_entity_id in existing_ids and not payload.replace_existing:
skipped += 1
continue
store.upsert(record)
restored += 1
store.save_reconciliation_state(payload.backup.reconciliation)
store.save_job_queue(payload.backup.jobs)
return RestoreResult(restored_records=restored, skipped_existing=skipped)
@router.post("/planning/refresh", response_model=list[ActuatorRecord])
def refresh_planning_insights(request: Request) -> list[ActuatorRecord]:
return _behavior(request).refresh_planning_insights()
@router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@@ -351,6 +497,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)
@@ -391,11 +578,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,
@@ -408,6 +608,20 @@ def set_safety_profile(
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/model/rollback", response_model=ActuatorRecord)
def rollback_model(
actuator_entity_id: str,
payload: ModelRollbackRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).rollback_model(actuator_entity_id, version_id=payload.version_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation(
actuator_entity_id: str,
@@ -617,6 +831,26 @@ def _finish_job(
store.finish_job(job.job_id, status=status, summary=summary, error=error)
def _performance_status(jobs: JobQueueState) -> tuple[int | None, int, str]:
budget_ms = 3000
durations = sorted(
job.duration_ms
for job in jobs.jobs
if job.status is JobStatus.COMPLETED and job.duration_ms is not None
)
slow_count = sum(1 for duration in durations if duration >= budget_ms)
if durations:
index = min(len(durations) - 1, int(round((len(durations) - 1) * 0.95)))
p95: int | None = durations[index]
status_value = "slow" if slow_count else "ok"
else:
p95 = None
status_value = "unknown"
if any(job.status is JobStatus.RUNNING for job in jobs.jobs):
status_value = "running" if status_value == "unknown" else status_value
return p95, slow_count, status_value
def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
@@ -690,6 +924,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):
@@ -708,7 +947,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 {}
@@ -720,18 +987,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")
@@ -742,6 +1009,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],

View File

@@ -3,20 +3,33 @@ from __future__ import annotations
import logging
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,
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
DecisionFactor,
DecisionTrace,
ExecutionEvent,
FeedbackKind,
LatencyMeasurement,
ManualOverride,
ModelSnapshot,
RelatedAutomation,
SafetyProfile,
SafetyStage,
SceneSuggestion,
TimeProfile,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
@@ -26,7 +39,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)
@@ -83,6 +101,16 @@ class BehaviorEngine:
),
"last_trained_at": now,
"reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.",
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=0,
trusted_actions=0,
prediction=None,
safety_blockers=[],
),
}
),
)
@@ -123,6 +151,16 @@ class BehaviorEngine:
"patterns": [],
"last_trained_at": now,
"reason": "Noch keine historischen Aktorhandlungen gefunden.",
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=0,
trusted_actions=0,
prediction=None,
safety_blockers=[],
),
}
),
)
@@ -168,6 +206,7 @@ class BehaviorEngine:
"eindeutig zugeordnete Handlungen fehlen."
)
)
model_version_id = f"model-{now.strftime('%Y%m%d%H%M%S')}"
behavior = record.behavior.model_copy(
update={
"status": status,
@@ -182,6 +221,28 @@ class BehaviorEngine:
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, len(patterns), trusted_actions),
"time_profiles": _time_profiles(patterns),
"model_snapshots": _next_model_snapshots(
record.behavior.model_snapshots,
model_version_id,
patterns[-_MAX_PATTERNS:],
len(patterns),
trusted_actions,
_average(record.behavior.confidence_trend),
record.behavior.incorrect_feedback_count,
reason,
),
"active_model_version": model_version_id,
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=len(patterns),
trusted_actions=trusted_actions,
prediction=record.behavior.prediction,
safety_blockers=record.behavior.safety_blockers,
),
}
)
return self._save_behavior(record, behavior)
@@ -203,7 +264,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:
@@ -293,6 +358,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={
@@ -308,6 +374,16 @@ class BehaviorEngine:
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"safety_blockers": safety_blockers if prediction is not None else [],
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=record.behavior.sample_count,
trusted_actions=record.behavior.high_confidence_sample_count,
prediction=prediction,
safety_blockers=safety_blockers if prediction is not None else [],
),
"confidence_trend": (
[*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:]
if prediction is not None
@@ -322,12 +398,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",
@@ -339,7 +450,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,
@@ -373,6 +498,20 @@ 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 record_feedback(
@@ -381,6 +520,7 @@ class BehaviorEngine:
*,
correct: bool,
expected_state: str | None = None,
kind: FeedbackKind | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
@@ -424,6 +564,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:
@@ -454,6 +595,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:],
@@ -466,6 +625,93 @@ 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,
][-50:],
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=len(patterns),
trusted_actions=record.behavior.high_confidence_sample_count,
prediction=prediction,
safety_blockers=record.behavior.safety_blockers,
correct_feedback_count=correct_count,
incorrect_feedback_count=incorrect_count,
),
}
)
record_for_save = (
record.model_copy(update={"manual_override": manual_override})
if manual_override is not None
else record
)
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,
*,
version_id: str,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
snapshot = next(
(item for item in record.behavior.model_snapshots if item.version_id == version_id),
None,
)
if snapshot is None:
raise ValueError("Modell-Snapshot nicht gefunden.")
behavior = record.behavior.model_copy(
update={
"patterns": snapshot.patterns,
"sample_count": snapshot.sample_count,
"high_confidence_sample_count": snapshot.high_confidence_sample_count,
"active_model_version": snapshot.version_id,
"reason": f"Rollback auf Modell-Snapshot {snapshot.version_id}.",
}
)
return self._save_behavior(record, behavior)
@@ -499,7 +745,24 @@ class BehaviorEngine:
)
]
behavior = record.behavior.model_copy(
update={"related_automations": related}
update={
"related_automations": related,
"automation_conflicts": _automation_conflicts(record, related),
}
)
behavior = behavior.model_copy(
update={
"anomalies": _detect_anomalies(
record.model_copy(update={"behavior": behavior}),
now=datetime.now(timezone.utc),
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=behavior.sample_count,
trusted_actions=behavior.high_confidence_sample_count,
prediction=behavior.prediction,
safety_blockers=behavior.safety_blockers,
)
}
)
return self._save_behavior(record, behavior)
@@ -811,6 +1074,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,
@@ -833,11 +1101,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
@@ -846,7 +1122,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
@@ -859,6 +1135,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)
@@ -886,6 +1165,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
@@ -990,6 +1471,283 @@ def _uncertainty_lines(
return lines or ["Keine kritische Unsicherheit aus den lokalen Daten erkannt."]
def _next_model_snapshots(
existing: list[ModelSnapshot],
version_id: str,
patterns: list[BehaviorPattern],
sample_count: int,
trusted_actions: int,
average_confidence: float,
incorrect_feedback_count: int,
reason: str,
) -> list[ModelSnapshot]:
snapshot = ModelSnapshot(
version_id=version_id,
sample_count=sample_count,
high_confidence_sample_count=trusted_actions,
average_confidence=round(average_confidence, 4),
incorrect_feedback_count=incorrect_feedback_count,
patterns=patterns[-_MAX_SNAPSHOT_PATTERNS:],
reason=reason,
)
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:
return sum(values) / len(values) if values else 0.0
def _time_profiles(patterns: list[BehaviorPattern]) -> list[TimeProfile]:
buckets = {
"night": ("Nacht", range(0, 360)),
"morning": ("Morgen", range(360, 720)),
"day": ("Tag", range(720, 1080)),
"evening": ("Abend", range(1080, 1440)),
}
profiles: list[TimeProfile] = []
for profile_id, (label, minutes) in buckets.items():
selected = [pattern for pattern in patterns if pattern.minute_of_day in minutes]
if not selected:
profiles.append(TimeProfile(profile_id=profile_id, label=label))
continue
by_state: dict[str, int] = {}
for pattern in selected:
by_state[pattern.target_state] = by_state.get(pattern.target_state, 0) + 1
dominant_state, count = max(by_state.items(), key=lambda item: (item[1], item[0]))
profiles.append(
TimeProfile(
profile_id=profile_id,
label=label,
sample_count=len(selected),
dominant_state=dominant_state,
confidence=round(count / len(selected), 4),
)
)
weekend = [pattern for pattern in patterns if pattern.weekday >= 5]
profiles.append(
TimeProfile(
profile_id="weekend",
label="Wochenende",
sample_count=len(weekend),
dominant_state=(
max(
{pattern.target_state: 0 for pattern in weekend},
key=lambda state: sum(pattern.target_state == state for pattern in weekend),
)
if weekend
else None
),
confidence=round(len(weekend) / len(patterns), 4) if patterns else 0.0,
)
)
return profiles
def _adapt_sensor_weights(
record: ActuatorRecord,
current_context: dict[str, str | None],
*,
correct: bool,
) -> tuple[list[AdaptiveWeightUpdate], ManualOverride | None]:
if not current_context:
return [], record.manual_override
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
previous = record.manual_override
weights = dict(previous.sensor_weights if previous is not None else {})
updates: list[AdaptiveWeightUpdate] = []
delta = 0.03 if correct else -0.08
for entity_id in current_context:
candidate = candidates.get(entity_id)
base = weights.get(
entity_id,
candidate.effective_weight if candidate is not None else 1.0,
)
new_weight = round(min(1.0, max(0.1, base + delta)), 4)
if new_weight == base:
continue
weights[entity_id] = new_weight
updates.append(
AdaptiveWeightUpdate(
entity_id=entity_id,
previous_weight=round(base, 4),
new_weight=new_weight,
reason=(
"Feedback korrekt: Kontextsignal leicht höher gewichtet."
if correct
else "Feedback falsch: Kontextsignal vorsichtig abgewertet."
),
)
)
if not updates:
return [], previous
return updates, ManualOverride(
numeric_entity_id=(
previous.numeric_entity_id
if previous is not None
else record.assignment.selected_numeric_entity_id
),
context_entity_ids=(
previous.context_entity_ids
if previous is not None
else record.assignment.selected_context_entity_ids
),
sensor_weights=weights,
sensor_weight_groups=previous.sensor_weight_groups if previous is not None else [],
note="Sensor-Gewichtungen automatisch aus Feedback angepasst.",
)
def _automation_conflicts(
record: ActuatorRecord,
related: list[RelatedAutomation],
) -> list[AutomationConflict]:
conflicts: list[AutomationConflict] = []
for automation in related:
if record.behavior.mode is BehaviorMode.ACTIVE and automation.enabled:
conflicts.append(
AutomationConflict(
automation_entity_id=automation.entity_id,
severity="warning",
status="open",
reason=(
"SillyHome ist aktiv, aber diese passende HA-Automation "
"ist ebenfalls aktiv. Das kann zu konkurrierenden Schaltungen führen."
),
)
)
elif automation.entity_id in record.behavior.paused_automation_entity_ids:
conflicts.append(
AutomationConflict(
automation_entity_id=automation.entity_id,
severity="info",
status="controlled",
reason="Automation ist durch SillyHome pausiert.",
)
)
return conflicts
def _detect_anomalies(
record: ActuatorRecord,
*,
now: datetime,
min_behavior_actions: int,
stale_hours: int,
sample_count: int,
trusted_actions: int,
prediction: BehaviorPrediction | None,
safety_blockers: list[str],
correct_feedback_count: int | None = None,
incorrect_feedback_count: int | None = None,
) -> list[AnomalyEvent]:
anomalies: list[AnomalyEvent] = []
def add(category: str, severity: str, title: str, detail: str) -> None:
anomalies.append(
AnomalyEvent(
anomaly_id=f"{record.actuator_entity_id}.{category}",
category=category,
severity=severity,
title=title,
detail=detail,
detected_at=now,
)
)
if not record.assignment.selected_context_entity_ids and not record.assignment.selected_numeric_entity_id:
add(
"missing_context",
"warning",
"Kein Kontext verbunden",
"Der Aktor hat keine Sensor-/Kontextbasis. Entscheidungen bleiben unsicher.",
)
if sample_count < min_behavior_actions:
add(
"low_samples",
"info",
"Zu wenig Lernbeispiele",
f"{sample_count} von {min_behavior_actions} benoetigten Handlungen gelernt.",
)
if trusted_actions < sample_count:
add(
"unclear_sources",
"info",
"Unklare Aktorhandlungen",
"Ein Teil der gelernten Handlungen stammt nicht eindeutig von Nutzer oder Automation.",
)
if record.behavior.last_trained_at is not None:
age = now - record.behavior.last_trained_at
if age > timedelta(hours=stale_hours):
add(
"stale_training",
"warning",
"Training ist veraltet",
f"Letztes Training liegt mehr als {stale_hours} Stunden zurueck.",
)
if prediction is not None and prediction.matching_patterns and prediction.confidence < record.behavior.safety.min_confidence:
add(
"low_confidence_prediction",
"warning",
"Vorhersage unter Sicherheitsgrenze",
(
f"Confidence {prediction.confidence:.0%} liegt unter "
f"{record.behavior.safety.min_confidence:.0%}."
),
)
if record.behavior.safety.manual_block:
add(
"manual_block",
"info",
"Manuelle Sicherheitssperre aktiv",
"Der Aktor ist bewusst gegen automatisches Schalten gesperrt.",
)
if safety_blockers:
add(
"safety_blockers",
"info",
"Safety blockiert aktuelle Aktion",
" ".join(safety_blockers)[:500],
)
if any(conflict.severity == "warning" for conflict in record.behavior.automation_conflicts):
add(
"automation_conflict",
"critical",
"Parallele Automation erkannt",
"SillyHome und mindestens eine passende HA-Automation koennen parallel schalten.",
)
correct = (
record.behavior.correct_feedback_count
if correct_feedback_count is None
else correct_feedback_count
)
incorrect = (
record.behavior.incorrect_feedback_count
if incorrect_feedback_count is None
else incorrect_feedback_count
)
total = correct + incorrect
if total >= 3 and incorrect / total >= 0.35:
add(
"feedback_error_rate",
"critical",
"Viele falsche Vorhersagen",
f"{incorrect} von {total} Feedbacks waren negativ. Modell pruefen oder Rollback nutzen.",
)
return anomalies[-30:]
def predict_behavior(
patterns: list[BehaviorPattern],
*,

View File

@@ -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"))
),
)

View File

@@ -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.1.0",
version="1.7.0",
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:
@@ -211,11 +259,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
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":
@@ -267,8 +311,6 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
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(

View File

@@ -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; }
@@ -99,6 +105,8 @@
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.decision-list { display:grid; gap:8px; margin:10px 0; }
.decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; }
.decision-row.slow,
.decision-row.critical { border-color:var(--warn); box-shadow:0 0 0 1px rgba(243,201,105,.25); }
.decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; }
@@ -116,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; }
@@ -138,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">
@@ -193,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>
@@ -239,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>
@@ -279,17 +274,225 @@ 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;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500;
const DASHBOARD_TIMEOUT_MS = 3000;
const I18N = {
de: {
safety_stage: {
observe: "Nur beobachten",
suggest: "Vorschläge anzeigen",
shadow: "Prüfmodus ohne Schalten",
partial: "Teilfreigabe",
active: "Aktiv freigegeben",
},
behavior_mode: {
shadow: "Prüfmodus",
active: "Aktiv",
paused: "Pausiert",
},
behavior_status: {
collecting: "Sammelt Lernbeispiele",
trained: "Gelernt",
blocked: "Blockiert",
},
lifecycle_status: {
trained: "gelernt",
pending_history: "sammelt Historie",
pending_assignment: "sucht Kontext",
review_required: "bitte prüfen",
archived: "wartet",
orphaned: "Aktor fehlt",
stale: "Training veraltet",
invalid: "ungültig",
},
job_status: {
pending: "wartet",
running: "läuft",
completed: "abgeschlossen",
failed: "fehlgeschlagen",
},
job_kind: {
discovery: "Geräte-Erkennung",
reconciliation: "Abgleich",
training: "Training",
evaluation: "Auswertung",
automation_refresh: "Automation-Prüfung",
},
severity: {
info: "Hinweis",
warning: "Warnung",
critical: "Kritisch",
},
anomaly_category: {
missing_context: "fehlender Kontext",
low_samples: "zu wenig Lernbeispiele",
unclear_sources: "unklare Quellen",
stale_training: "veraltetes Training",
low_confidence_prediction: "geringe Sicherheit",
manual_block: "manuelle Sperre",
safety_blockers: "Sicherheitsblocker",
automation_conflict: "Automation-Konflikt",
feedback_error_rate: "Feedback-Fehlerquote",
},
performance_status: {
ok: "schnell",
slow: "zu langsam",
running: "läuft",
unknown: "noch offen",
},
connection_status: {
connected: "verbunden",
disconnected: "getrennt",
unavailable: "nicht verfügbar",
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",
},
},
};
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);
}
function formatDateTime(value) {
if (!value) return "noch offen";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? String(value)
: parsed.toLocaleString("de-DE");
}
function uniqueValues(values) {
return [...new Set(values.filter(Boolean))];
}
@@ -298,6 +501,9 @@ function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedDetailHtml.clear();
}
async function api(path, options = {}) {
@@ -312,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);
}
@@ -322,12 +533,12 @@ function lifecycleLabel(record) {
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt";
const labels = {
trained: "lernt",
pending_history: "sammelt Historie",
pending_assignment: "sucht Kontext",
review_required: "geringe Zuordnungssicherheit",
archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden",
trained: translate("lifecycle_status", "trained"),
pending_history: translate("lifecycle_status", "pending_history"),
pending_assignment: translate("lifecycle_status", "pending_assignment"),
review_required: translate("lifecycle_status", "review_required"),
archived: translate("lifecycle_status", "archived"),
orphaned: translate("lifecycle_status", "orphaned"),
};
return labels[lifecycleStatus] || lifecycleStatus;
}
@@ -345,7 +556,7 @@ function behaviorLabel(record) {
const mode = record.behavior_mode || record.behavior?.mode;
const status = record.behavior_status || record.behavior?.status;
if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Shadow-Vorhersage";
if (status === "trained") return "Prüfmodus mit Vorhersage";
if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen";
}
@@ -433,17 +644,34 @@ 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", 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);
renderConfiguredActuators();
if (budget) budget.textContent = `Bereit in ${Math.round(performance.now() - startedAt)} ms`;
if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
}
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Startdaten verzögert";
@@ -454,6 +682,85 @@ async function loadOverview() {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>";
}
}
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();
};
if ("requestIdleCallback" in window) {
window.requestIdleCallback(run, {timeout: 1800});
} else {
setTimeout(run, 250);
}
}
async function loadDashboardExtras() {
try {
const [jobs, reconciliation] = await Promise.allSettled([
apiWithTimeout("v1/actuators/job-queue/state", STATUS_TIMEOUT_MS),
apiWithTimeout("v1/actuators/reconciliation/state", STATUS_TIMEOUT_MS),
]);
if (jobs.status === "fulfilled") {
renderJobQueue(jobs.value.jobs || []);
}
if (reconciliation.status === "fulfilled") {
const text = document.getElementById("reconciliation-status");
if (text) {
text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`;
}
}
} catch (_) {
// Die Startansicht bleibt auch ohne Hintergrunddaten bedienbar.
}
}
async function loadStatus() {
@@ -474,10 +781,10 @@ async function loadStatus() {
const hasError = values.some(value => value === null);
status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`;
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(formatDateTime(reconciliationValue.last_completed_at))}</p>`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(translate("connection_status", websocketValue?.status, websocketValue?.status || "offen"))}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join("");
@@ -491,7 +798,6 @@ function renderDashboardStatus(dashboard) {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats");
const jobsBox = document.getElementById("job-queue");
const system = dashboard.system || {};
const cache = dashboard.cache || {};
const actuators = dashboard.actuators || [];
@@ -504,16 +810,27 @@ function renderDashboardStatus(dashboard) {
).length;
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
const anomalyTotal = Number(system.anomaly_count || 0);
const criticalAnomalyTotal = Number(system.critical_anomaly_count || 0);
const loadMs = Number(dashboard._load_elapsed_ms || 0);
const jobs = dashboard.jobs?.jobs || [];
const runningJobs = jobs.filter(job => job.status === "running").length;
const slowJobs = Number(system.slow_job_count || 0);
const p95 = system.job_p95_duration_ms == null ? "offen" : `${system.job_p95_duration_ms} ms`;
const performanceClass = (
loadMs > DASHBOARD_TIMEOUT_MS
|| slowJobs > 0
|| system.performance_status === "slow"
) ? "warn" : "ok";
const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut";
status.innerHTML = `
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">
Dashboard bereit. WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}
<p class="${performanceClass}">
Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.
</p>
<p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</p>
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">WebSocket: ${escapeHtml(translate("connection_status", system.websocket_status, system.websocket_status || "unbekannt"))}</p>
<p class="muted" id="reconciliation-status">Letzte automatische Prüfung: ${escapeHtml(formatDateTime(system.reconciliation_last_completed_at))}</p>
`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`,
@@ -522,28 +839,40 @@ function renderDashboardStatus(dashboard) {
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
`<span class="chip">Anomalien: ${escapeHtml(anomalyTotal)}</span>`,
`<span class="chip">Kritisch: ${escapeHtml(criticalAnomalyTotal)}</span>`,
].join("");
stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
`<div class="metric"><strong>Freigabebereit</strong>${escapeHtml(readyCount)} Geräte</div>`,
`<div class="metric"><strong>Aktiv / Shadow</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
`<div class="metric"><strong>Aktiv / Prüfmodus</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
`<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`,
`<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`,
`<div class="metric"><strong>Performance-Budget</strong>${escapeHtml(system.performance_budget_ms || 3000)} ms</div>`,
`<div class="metric"><strong>Job p95</strong>${escapeHtml(p95)}</div>`,
`<div class="metric"><strong>Langsame Jobs</strong>${escapeHtml(slowJobs)}</div>`,
`<div class="metric"><strong>Anomalien</strong>${escapeHtml(anomalyTotal)} offen</div>`,
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join("");
renderJobQueue(jobs);
}
function renderJobQueue(jobs) {
const jobsBox = document.getElementById("job-queue");
if (!jobsBox) return;
jobsBox.innerHTML = jobs.length ? `
<h3>Job-Queue</h3>
<h3>Aufgabenliste</h3>
${jobs.slice(-6).reverse().map(job => `
<div class="decision-row">
<div class="decision-row ${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? "slow" : ""}">
<header>
<strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
<span class="chip">${escapeHtml(job.status)}</span>
<strong>${escapeHtml(translate("job_kind", job.kind, job.kind))}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
<span class="chip">${escapeHtml(translate("job_status", job.status, job.status))}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}</span>
</header>
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
<p class="muted">Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
<p class="muted">Start: ${escapeHtml(formatDateTime(job.started_at))} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
${job.status === "failed" ? "<p class='warn'>Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.</p>" : ""}
${job.status === "failed" ? "<p class='warn'>Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.</p>" : ""}
</div>
`).join("")}
` : "";
@@ -708,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;
@@ -771,13 +1114,17 @@ function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators");
try {
const rows = cachedActuators || [];
const visibleRows = rows.slice(0, visibleActuatorLimit);
const groups = new Map();
for (const record of rows) {
for (const record of visibleRows) {
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({record});
}
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
const moreButton = rows.length > visibleRows.length
? `<button class="secondary" onclick="visibleActuatorLimit += 24; renderConfiguredActuators()">Weitere ${Math.min(24, rows.length - visibleRows.length)} Geräte anzeigen</button>`
: "";
box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => `
<details class="group-panel">
@@ -811,7 +1158,9 @@ function renderConfiguredActuators() {
`).join("")}
</div>
</details>
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
`).join("")}
${moreButton}
` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) {
box.textContent = error.message;
}
@@ -819,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,
@@ -903,6 +1267,12 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const knowledge = record.behavior.knowledge || [];
const assumptions = record.behavior.assumptions || [];
const uncertainties = record.behavior.uncertainties || [];
const snapshots = record.behavior.model_snapshots || [];
const activeModelVersion = record.behavior.active_model_version || "";
const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
const automationConflicts = record.behavior.automation_conflicts || [];
const timeProfiles = record.behavior.time_profiles || [];
const anomalies = (record.behavior.anomalies || []).filter(item => !item.resolved);
const safetyControls = `
<details class="manual-context" open>
<summary>Sicherheit und manuelles Gegensteuern</summary>
@@ -911,7 +1281,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<label for="safety-stage">Freigabestufe</label>
<select id="safety-stage">
${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${stage}</option>
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${escapeHtml(translate("safety_stage", stage))}</option>
`).join("")}
</select>
</div>
@@ -962,9 +1332,61 @@ async function showActuator(actuatorId, evaluationMessage = "") {
</div>
</details>
`;
const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation",
).length;
const adaptivePanel = `
<details class="manual-context">
<summary>v1.2 Lernen, Rollback und Konflikte</summary>
<h3>Zeitprofile</h3>
<div class="metric-grid">
${timeProfiles.length ? timeProfiles.map(profile => `
<div class="metric">
<strong>${escapeHtml(profile.label)}</strong>
${escapeHtml(profile.sample_count)} Beispiele · ${escapeHtml(profile.dominant_state || "offen")}
<p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p>
</div>
`).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"}
</div>
<h3>Modell-Snapshots</h3>
<div class="decision-list">
${snapshots.length ? snapshots.slice(-5).reverse().map(snapshot => `
<div class="decision-row">
<header>
<strong>${escapeHtml(snapshot.version_id)}</strong>
<span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span>
</header>
<p class="muted">${escapeHtml(snapshot.sample_count)} Beispiele · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
<p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p>
${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""}
</div>
`).join("") : "<p class='muted'>Noch kein Modell-Snapshot gespeichert.</p>"}
</div>
<h3>Automatische Gewichtsanpassungen</h3>
<ul>${adaptiveUpdates.length ? adaptiveUpdates.slice(-8).reverse().map(update => `
<li><code>${escapeHtml(update.entity_id)}</code>: ${Math.round(update.previous_weight * 100)} % → ${Math.round(update.new_weight * 100)} %. ${escapeHtml(update.reason)}</li>
`).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul>
<h3>Automation-Konflikte</h3>
<ul>${automationConflicts.length ? automationConflicts.map(conflict => `
<li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(translate("severity", conflict.severity, conflict.status))}</span> ${escapeHtml(conflict.reason)}</li>
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
</details>
`;
const anomalyPanel = `
<details class="manual-context" ${anomalies.length ? "open" : ""}>
<summary>v1.3 Anomalie- und Performance-Hinweise</summary>
<div class="decision-list">
${anomalies.length ? anomalies.map(anomaly => `
<div class="decision-row ${anomaly.severity === "critical" ? "critical" : ""}">
<header>
<strong>${escapeHtml(anomaly.title)}</strong>
<span class="chip">${escapeHtml(translate("severity", anomaly.severity))} · ${escapeHtml(translate("anomaly_category", anomaly.category, anomaly.category))}</span>
</header>
<p>${escapeHtml(anomaly.detail)}</p>
<p class="muted">Erkannt: ${escapeHtml(formatDateTime(anomaly.detected_at))}</p>
</div>
`).join("") : "<p class='ok'>Keine offenen Anomalien fuer diesen Aktor.</p>"}
</div>
</details>
`;
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");
@@ -984,7 +1406,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>
@@ -1031,13 +1452,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>
@@ -1045,7 +1474,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>
@@ -1053,13 +1481,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>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</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>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>
@@ -1073,22 +1500,24 @@ async function showActuator(actuatorId, evaluationMessage = "") {
</div>
${safetyControls}
${decisionArchive}
${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}
<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;
}
@@ -1099,13 +1528,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>
`;
}
@@ -1286,7 +1715,7 @@ async function saveSafetyProfile(actuatorId) {
cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null,
rules: [
{rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."},
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus."},
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Prüfmodus."},
{rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."},
{rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."},
],
@@ -1305,6 +1734,21 @@ async function saveSafetyProfile(actuatorId) {
}
}
async function rollbackModel(actuatorId, versionId) {
if (!confirm(`${actuatorId}: wirklich auf Modell ${versionId} zurückrollen?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/model/rollback`, {
method: "POST",
body: JSON.stringify({version_id: versionId}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, `Rollback auf ${versionId} ausgeführt.`);
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active
? pauseMatchingAutomations
@@ -1377,11 +1821,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 nach dem Status 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 loadStatus();
await loadOverview();
setTimeout(() => void loadStatus(), 100);
}
void startDashboard();

View File

@@ -0,0 +1,62 @@
# SillyHome Next v1.2.0 Operating Guide
## Ziel
v1.2.0 erweitert die sichere v1.1-Grundlage um adaptive Lernfunktionen. Diese
Funktionen laufen bei Feedback, Training oder Automation-Refresh und blockieren
nicht den direkten Schaltpfad.
## Adaptive Gewichtung
Feedback passt die Gewichtung aktuell beteiligter Kontextsignale vorsichtig an:
- korrektes Feedback: +3 Prozentpunkte bis maximal 100 %
- falsches Feedback: -8 Prozentpunkte bis minimal 10 %
Die Aenderungen werden als `adaptive_weight_updates` gespeichert und im
Dashboard angezeigt. Manuelle Gewichtungen bleiben weiter direkt korrigierbar.
## Modell-Snapshots und Rollback
Bei jedem Training wird ein Snapshot gespeichert:
- Version-ID
- Sample Count
- eindeutig zugeordnete Handlungen
- durchschnittliche Confidence
- negative Feedbacks
- Musterliste
- Begruendung
Ueber das Dashboard kann auf einen frueheren Snapshot zurueckgerollt werden.
## Automation-Konflikte
Beim Automation-Refresh markiert SillyHome Konflikte, wenn:
- SillyHome fuer einen Aktor aktiv ist
- eine passende Home-Assistant-Automation ebenfalls aktiv bleibt
Pausierte Automationen werden als kontrolliert markiert.
## Zeitprofile
SillyHome bildet Profile fuer:
- Nacht
- Morgen
- Tag
- Abend
- Wochenende
Diese Profile zeigen Sample Count, dominanten Zielzustand und Profilklarheit.
## Performance-Grenze
v1.2-Funktionen duerfen den Schaltmoment nicht verlangsamen. Der direkte
Schaltpfad bleibt:
1. vorhandene aktuelle States nutzen
2. lokale Safety-Pruefung
3. direkter Home-Assistant-Serviceaufruf
4. Persistenz der Entscheidung

View File

@@ -0,0 +1,68 @@
# SillyHome Next v1.3.0 Operating Guide
v1.3.0 ergänzt die v1.2-Lernfunktionen um Anomalie-Erkennung und
Performance-Überwachung. Das Dashboard bleibt Visualisierung und Einrichtung;
der direkte Schaltpfad bleibt kurz und führt vor dem Home-Assistant-Service-Call
keine Discovery, kein Training und keine Modellanalyse aus.
## Performance-Budget
- Dashboard-Start und `/v1/actuators/dashboard` haben ein Budget von 3000 ms.
- Das Dashboard zeigt die eigene Ladezeit, das aktive Budget, Job-p95 und die
Anzahl langsamer Jobs.
- Jobs ab 3000 ms werden in der Job-Queue als langsam markiert.
- Der automatisierte API-Test prüft den Root- und Dashboard-Startpfad gegen das
3-Sekunden-Budget.
## Anomalie-Erkennung
Anomalien werden pro Aktor gespeichert und im Aktor-Detail angezeigt. Erkannt
werden aktuell:
- fehlender Sensor-/Kontextbezug
- zu wenige Lernbeispiele
- unklare Quellen historischer Schaltungen
- veraltetes Training
- Vorhersagen unter der Sicherheitsgrenze
- aktive manuelle Sicherheitssperren
- Safety-Blocker
- parallele HA-Automationen bei aktivem SillyHome
- hohe negative Feedbackquote
Die Anomalien sind Hinweise für Setup und manuelles Gegensteuern. Sie lösen
keine automatische Eskalation und keine langsamere Schaltung aus.
## API
- `GET /v1/actuators/dashboard` liefert jetzt zusätzlich:
- `performance_budget_ms`
- `job_p95_duration_ms`
- `slow_job_count`
- `performance_status`
- `anomaly_count`
- `critical_anomaly_count`
- `GET /v1/actuators/anomalies` liefert offene Anomalien gruppiert nach Aktor.
## Betrieb
Bei Ladezeiten ab 3 Sekunden gilt die Seite als nicht performant. Dann zuerst
prüfen:
1. Dashboard-Statistik: Ladezeit, Job-p95, langsame Jobs.
2. Job-Queue: welche Aktion langsam war.
3. Aktor-Detail: Anomalien, Safety-Blocker und Automation-Konflikte.
4. Falls Discovery oder Training langsam war: nicht in den Startpfad ziehen,
sondern geplant, manuell oder über Queue laufen lassen.
## Qualität
Vor Release/Installation ausführen:
```bash
pytest -q
ruff check .
mypy app backend tests
git diff --check
```
Zusätzlich das eingebettete Dashboard-JavaScript mit `node --check` prüfen.

View File

@@ -0,0 +1,42 @@
# SillyHome Next v1.4.0 Operating Guide
v1.4.0 überarbeitet das Dashboard für mobile Nutzung, deutsche Verständlichkeit
und stabileren Datenabruf.
## Schneller Startpfad
- Die Startseite lädt zuerst nur die Bedienoberfläche und den kompakten
Dashboard-Startdatensatz.
- Neuer Start-Endpunkt: `GET /v1/actuators/dashboard/start`.
- Der Start-Endpunkt liefert keine Discovery-Gruppen und keine Aufgabenliste.
- Status, Aufgabenliste, Reconciliation-Zeitpunkt und Detail-Kontext werden
danach im Hintergrund geladen.
- Auf der Startansicht werden zunächst nur die ersten 24 Aktoren gerendert.
Weitere Geräte werden auf Knopfdruck nachgerendert.
## Deutsche Oberfläche
Interne Protokollwerte bleiben stabil, werden in der Oberfläche aber übersetzt:
- `observe` -> `Nur beobachten`
- `suggest` -> `Vorschläge anzeigen`
- `shadow` -> `Prüfmodus ohne Schalten`
- `partial` -> `Teilfreigabe`
- `active` -> `Aktiv freigegeben`
- Job-Status wie `running`, `completed`, `failed` erscheinen als `läuft`,
`abgeschlossen`, `fehlgeschlagen`.
- Anomalie-Schweregrade erscheinen als `Hinweis`, `Warnung`, `Kritisch`.
## Stabilität
- Startdaten und Statusdaten sind getrennt. Ein langsamer Statuscheck blockiert
nicht mehr die Geräteübersicht.
- Die Aufgabenliste wird separat geladen und kann ausfallen, ohne die
Bedienoberfläche zu blockieren.
- Detaildaten bleiben gestuft: zuerst Shell und gespeicherte Werte, danach
Kontextvorschläge.
## Performance-Regel
3 Sekunden bleiben die harte Grenze für den Startpfad. Alles, was schwerer ist
als Startdaten, muss nachgelagert oder auf Nutzeraktion geladen werden.

View File

@@ -0,0 +1,47 @@
# SillyHome Next v1.5.0 Operating Guide
v1.5.0 trennt Dashboard-Ansichten, Datenabruf und Detaildaten weiter auf. Ziel
ist, dass die Seite auf mobiler Datenverbindung schneller nutzbar wird und keine
schweren Lern-, Discovery- oder Detaildaten beim Start lädt.
## Menüstruktur
- Startseite / System: Systemübersicht, Cache, Performance, Status.
- Lernen: konfigurierte Aktoren und Lernstand.
- Details: genau ein ausgewählter Aktor.
- Discovery & Einrichtung: Geräteliste, Vorschläge und neue Aktoren.
- Einstellungen: Sprache und Standardverhalten.
- Ablauf: Bedienhinweise.
Beim Öffnen der Seite wird immer nur die Startseite geladen. Andere Ansichten
laden erst beim Öffnen.
## Kompakte Detaildaten
Neuer Endpunkt:
```text
GET /v1/actuators/{actuator_entity_id}/detail
```
Dieser Endpunkt entfernt große Musterlisten und Snapshot-Muster aus dem ersten
Detailabruf. Geladen werden nur die Werte, die für die erste Detailansicht
benötigt werden. Kontextvorschläge bleiben ein separater Abruf und laufen erst
auf Nutzeraktion.
## Sprache
Die Sprache kann unter `Einstellungen` gewählt werden. Deutsch ist Standard.
Technische API-Werte bleiben stabil, werden aber im Dashboard über die
Sprachschicht angezeigt.
## Performance-Regeln
- Kein Discovery beim Start.
- Keine Aufgabenliste beim Start.
- Keine Kontextvorschläge beim Öffnen eines Aktors.
- Keine Musterlisten im ersten Detailabruf.
- Geräteübersicht rendert begrenzt und lädt weitere Karten per Button nach.
Die Angabe „bereit in X ms“ beschreibt nur den jeweiligen API-/Ansichtsabruf.
Sie ist nicht gleichzusetzen mit der kompletten HA/Ingress-Navigationszeit.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.1 Operating Guide
v1.5.1 ist ein Stabilisierungshotfix für die nach v1.2.0 entstandenen
Dashboard-Änderungen. Fachlich gehört diese Arbeit zur v1.2.x-Patchlinie; die
höhere technische Versionsnummer ist nur nötig, weil Home Assistant bereits
v1.5.0 installiert hat und Add-on-Updates monoton nach oben laufen.
## Korrekturen
- Die System-Startseite nutzt `GET /v1/actuators/dashboard/system` und lädt
keine Aktorenliste.
- Sichtbare 3-Sekunden-Abbrüche mit Browsertexten wie `signal is aborted
without reason` wurden entfernt.
- Startdaten und Detaildaten werden ohne künstlichen Frontend-Abbruch geladen.
- Timeout-Meldungen werden deutsch und verständlich angezeigt, wenn sie bei
Nebenprüfungen auftreten.
- `summary`-Zeilen wie `anzeigenaufklappen` haben jetzt Abstand und Layout.
## Ladeverhalten
- Statische Seite wird sofort gerendert.
- Systemdaten laden im Hintergrund.
- Lernen/Geräte laden nur im Menü `Lernen`.
- Discovery lädt nur im Menü `Discovery & Einrichtung`.
- Aktorwerte laden erst beim Öffnen der Detailansicht.
- Kontextvorschläge laden erst auf Nutzeraktion.
## Hinweis zur Performance-Anzeige
Die App zeigt keine echte HA/Ingress-Navigationszeit an. Gemessen werden nur
einzelne interne Abrufe nach Start der Seite. Aussagen zur gesamten Ladezeit
müssen über Browser/Ingress oder HA-Messung geprüft werden.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.2 Operating Guide
v1.5.2 begrenzt den Rollback-Speicher und entschärft Home-Assistant-Timeouts,
die in den Add-on-Logs sichtbar wurden.
## Rollback-Speicher
- Pro Aktor bleiben maximal 3 Modell-Snapshots erhalten.
- Pro Snapshot bleiben maximal 120 Muster erhalten.
- Beim Speichern eines Aktors werden ältere oder zu große Snapshots automatisch
gekappt.
- Der kompakte Detail-Endpunkt liefert ebenfalls maximal 3 Rollback-Snapshots
und keine Musterlisten.
Damit bleibt Rollback nutzbar, ohne dass die JSON-Dateien mit alten Modellen
stark wachsen.
## Home-Assistant-Zugriffe
- REST-Zugriffe auf Home Assistant haben jetzt standardmäßig 25 Sekunden
Timeout statt 10 Sekunden.
- Der Wert ist über `SILLYHOME_HA_TIMEOUT_SECONDS` konfigurierbar.
- WebSocket-Keepalive wurde auf 30 Sekunden Ping-Intervall und 30 Sekunden
Ping-Timeout entschärft.
## Log-Einordnung
- `GET ... HTTP/1.1` ist bei Uvicorn/HA-Ingress normal und kein Fehler.
- `Zeitüberschreitung beim Zugriff auf Home Assistant` bedeutet, dass HA selbst
zu langsam geantwortet hat oder der Ingress/Netzpfad verzögert war.
- `keepalive ping timeout` bedeutet, dass die HA-WebSocket-Verbindung nicht
rechtzeitig geantwortet hat. SillyHome reconnectet automatisch.

View File

@@ -0,0 +1,37 @@
# SillyHome Next v1.5.3 Operating Guide
v1.5.3 führt eine SQLite-Cache-Schicht für Ingress-Dashboarddaten ein.
## Ziel
Die Ingress-Seite soll nicht bei jedem Aufruf live Home Assistant abfragen.
Home-Assistant-Daten werden geplant aktualisiert und lokal gelesen.
## SQLite-Cache
- Cache-Datei: `<actuator_store>/dashboard_cache.sqlite3`
- Tabelle `ha_entities`: aktuelle HA-Entity-Summaries als JSON
- Tabelle `cache_meta`: Aktualisierungszeitpunkt und Discovery-Gruppen
Dashboard-APIs lesen bevorzugt aus SQLite. Der alte JSON-Cache bleibt als
Fallback erhalten.
## Aktualisierung
- Beim App-Start läuft ein Hintergrund-Refresh nach kurzer Verzögerung.
- Danach läuft der Refresh stündlich.
- Konfiguration: `SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS`
- Mindestwert: 300 Sekunden.
- Explizite Discovery aktualisiert SQLite und JSON-Fallback.
## Schaltpfad
Das direkte Schalten bleibt unverändert: Safety prüft lokale Daten, danach geht
der Home-Assistant-Service-Call direkt raus. Der Dashboard-Cache liegt nicht im
Schaltpfad.
## Noch offen
Diese Version verschiebt Entity-/Discovery-Daten in SQLite. Die vollständige
Migration aller Aktor-Konfigurationen und Workflows aus JSON in relationale
Tabellen ist ein größerer Folgeschritt und muss mit Migrationsplan erfolgen.

View File

@@ -0,0 +1,35 @@
# SillyHome Next v1.5.4 Operating Guide
Diese Version korrigiert Ingress-Logging und Dashboard-Navigation.
## Ingress-/Access-Logs
- Das Add-on startet Uvicorn ohne `--proxy-headers` und ohne
`--forwarded-allow-ips='*'`.
- Vorher konnte Uvicorn LAN-Adressen aus `X-Forwarded-For` anzeigen. Diese
Adresse war dann der urspruengliche Client oder Home-Assistant-Proxy, nicht
der direkte Container-Peer.
- Nach dem Update sollten Access-Logs den direkten Docker-/Ingress-Peer zeigen.
`GET ... HTTP/1.1` bleibt normal und ist kein Hinweis auf fehlendes Streaming.
## Dashboard-Verhalten
- Die Startseite nutzt weiter `/v1/actuators/dashboard/system`.
- Die Lernuebersicht nutzt weiter `/v1/actuators/dashboard/start`.
- Bereits geladene System-, Lern- und Discovery-Daten bleiben beim Wechseln der
Ansichten im Browser erhalten und werden nur im Hintergrund aufgefrischt.
- Details sind kein eigener Menuepunkt mehr. Sie werden nur ueber ein
ausgewaehltes beobachtetes Geraet geoeffnet.
- Ein bereits geoeffneter Aktor zeigt seine Detaildaten sofort aus dem
Browser-Cache. Neue Detaildaten werden erst ueber `Details aktualisieren`
oder nach einer Speichern-/Schaltaktion geladen.
## Pruefung
1. Add-on aktualisieren und neu starten.
2. Ingress hart neu laden.
3. Zwischen Startseite, Lernen und Discovery wechseln.
4. Erwartung: Bereits geladene Inhalte bleiben sichtbar; keine volle
Neuladung bei jedem Ansichtswechsel.
5. Details eines Aktors oeffnen, wegwechseln und wieder Details oeffnen.
Erwartung: Die zuletzt geladene Detailansicht steht sofort wieder da.

View File

@@ -0,0 +1,36 @@
# SillyHome Next v1.6.0 Operating Guide
v1.6.0 trennt Startansicht, Aktoruebersicht, Discovery und Detaildaten staerker.
## API-Pfade
- `GET /v1/actuators/dashboard/system`
- nur System- und Cache-Metadaten
- keine Aktorenliste
- kein vollstaendiges Entity-Payload aus SQLite
- `GET /v1/actuators/dashboard/start`
- Aktor-Summaries
- Entity-Metadaten nur fuer konfigurierte Aktoren
- keine Discovery-Gruppen und keine Jobliste
- `GET /v1/actuators/discovery`
- steuerbare HA-Entities
- nutzt SQLite-Cache, liest HA nur bei Cache-Miss oder `refresh=true`
- `GET /v1/actuators/{id}/detail`
- genau ein ausgewaehlter Aktor
- kompakte Modell-/Kontextdaten
## Dashboard
- Frontend zeigt Daten an und loest gezielte Aktionen aus.
- Backend liefert schlanke View-Daten.
- Worker aktualisieren HA-Entity-/Discovery-Cache beim Start und danach
stündlich.
- Die Detailansicht gehoert zu einem Aktor und hat eigene Navigation:
Zurueck, anderes Geraet, Aktualisieren.
## Erwartete Wirkung
- Systemstart muss ohne Entity-Materialisierung reagieren.
- Lernen und Details laden nur ihren eigenen Datenkern.
- Discovery bleibt ein eigener Bedarfspfad.
- Texte im Dashboard sind kurz und handlungsnah.

View File

@@ -0,0 +1,45 @@
# SillyHome Next v1.7.0 Operating Guide
v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und
Planungshilfen.
## Diagnose
- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor.
- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`.
- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde
vorhergesagt, wurde geschaltet oder blockiert, und warum.
## Backup und Restore
- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status
und Job-Historie als JSON.
- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein.
- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben.
## Dry-run
- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus.
- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht
an Home Assistant gesendet.
## Feedback
Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen:
- `correct`
- `wrong`
- `too_early`
- `too_late`
- `never_automate`
`never_automate` setzt eine manuelle Sicherheitssperre am Aktor.
## Planung
`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise:
- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten
- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten
- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback

View File

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

View File

@@ -1,16 +1,19 @@
from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from time import perf_counter
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.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 (
@@ -113,6 +116,7 @@ def _install_service(tmp_path: Path) -> None:
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
state="12",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
@@ -120,6 +124,7 @@ def _install_service(tmp_path: Path) -> None:
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
state="off",
),
HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes",
@@ -143,6 +148,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]},
@@ -300,6 +306,99 @@ def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
record = app.state.actuator_store.get("light.abstellkammer")
version_id = "model-test"
snapshot = ModelSnapshot(
version_id=version_id,
sample_count=1,
high_confidence_sample_count=1,
average_confidence=0.9,
patterns=[],
reason="Test-Snapshot",
)
app.state.actuator_store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"model_snapshots": [snapshot],
"active_model_version": "model-current",
"sample_count": 2,
}
)
}
)
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "expected_state": "off"},
)
rollback = client.post(
"/v1/actuators/light.abstellkammer/model/rollback",
json={"version_id": version_id},
)
assert feedback.status_code == 200
feedback_payload = feedback.json()
assert feedback_payload["behavior"]["adaptive_weight_updates"]
assert feedback_payload["manual_override"]["sensor_weights"]
assert rollback.status_code == 200
assert rollback.json()["behavior"]["active_model_version"] == version_id
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "kind": "never_automate"},
)
assert feedback.status_code == 200
payload = feedback.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
backup = client.get("/v1/actuators/backup/export")
dry_run = client.post(
"/v1/actuators/light.abstellkammer/dry-run",
json={"enabled": True},
)
planning = client.post("/v1/actuators/planning/refresh")
restore = client.post(
"/v1/actuators/backup/restore",
json={"backup": backup.json(), "replace_existing": True},
)
assert backup.status_code == 200
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
assert dry_run.status_code == 200
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
assert planning.status_code == 200
assert "agent_insights" in planning.json()[0]["behavior"]
assert restore.status_code == 200
assert restore.json()["restored_records"] == 1
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
@@ -356,7 +455,7 @@ def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None:
assert payload["jobs"][-1]["status"] == "completed"
def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) -> None:
def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
@@ -367,13 +466,92 @@ def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) ->
root_elapsed = perf_counter() - root_started_at
dashboard_started_at = perf_counter()
dashboard_response = client.get("/v1/actuators/dashboard")
dashboard_response = client.get("/v1/actuators/dashboard/start")
dashboard_elapsed = perf_counter() - dashboard_started_at
assert root_response.status_code == 200
assert dashboard_response.status_code == 200
assert root_elapsed < 5.0
assert dashboard_elapsed < 5.0
assert root_elapsed < 3.0
assert dashboard_elapsed < 3.0
def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
store = app.state.actuator_store
job = store.start_job(kind="training", trigger="test", summary="Langsamer Testjob")
queue = store.load_job_queue()
queue.jobs = [
item.model_copy(update={"started_at": datetime.now(timezone.utc) - timedelta(seconds=4)})
if item.job_id == job.job_id
else item
for item in queue.jobs
]
store._persist_job_queue(queue)
store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig")
dashboard_response = client.get("/v1/actuators/dashboard")
start_response = client.get("/v1/actuators/dashboard/start")
system_response = client.get("/v1/actuators/dashboard/system")
anomalies_response = client.get("/v1/actuators/anomalies")
assert dashboard_response.status_code == 200
assert start_response.status_code == 200
assert system_response.status_code == 200
system = dashboard_response.json()["system"]
start_payload = start_response.json()
assert start_payload["jobs"]["jobs"] == []
assert start_payload["discovery_groups"] == []
assert system_response.json()["actuators"] == []
assert system["performance_budget_ms"] == 3000
assert system["slow_job_count"] == 1
assert system["performance_status"] == "slow"
assert system["anomaly_count"] >= 1
assert anomalies_response.status_code == 200
assert anomalies_response.json()
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
raise AssertionError("full entity payload must not be loaded")
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
system_response = client.get("/v1/actuators/dashboard/system")
start_response = client.get("/v1/actuators/dashboard/start")
assert system_response.status_code == 200
assert system_response.json()["actuators"] == []
assert system_response.json()["cache"]["entity_count"] == 4
assert start_response.status_code == 200
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/light.abstellkammer/detail")
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["patterns"] == []
assert all(
snapshot["patterns"] == []
for snapshot in payload["behavior"]["model_snapshots"]
)
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:

View File

@@ -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

View File

@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
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

View File

@@ -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

View File

@@ -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"},