Compare commits

..

7 Commits

Author SHA1 Message Date
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
24 changed files with 1390 additions and 91 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,14 @@
# Changelog
## 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

View File

@@ -19,6 +19,18 @@ nach einer ausdrücklichen Freigabe ausführen.
[`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)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "1.2.0"
version: "1.5.4"
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

98
app/actuators/cache_db.py Normal file
View File

@@ -0,0 +1,98 @@
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 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

@@ -223,6 +223,16 @@ class AutomationConflict(BaseModel):
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)
@@ -270,6 +280,7 @@ class BehaviorState(BaseModel):
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)
class ActuatorRecord(BaseModel):

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, ReconciliationState, SensorWeightGroup
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
@@ -89,6 +90,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
@@ -108,6 +111,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):
@@ -124,6 +133,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,
@@ -266,6 +281,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
@@ -284,6 +307,25 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
@router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=True, 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_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
@@ -295,16 +337,19 @@ def dashboard_overview(request: Request) -> DashboardOverview:
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)
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -314,9 +359,18 @@ 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),
@@ -329,6 +383,29 @@ 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("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@@ -355,6 +432,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)
@@ -635,6 +753,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):
@@ -727,6 +865,11 @@ def _load_cached_entity_map(
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 {}
@@ -738,18 +881,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")
@@ -760,6 +903,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

@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
from app.actuators.models import (
ActuatorRecord,
AdaptiveWeightUpdate,
AnomalyEvent,
AutomationConflict,
BehaviorMode,
BehaviorPattern,
@@ -31,6 +32,8 @@ 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
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
@@ -88,6 +91,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=[],
),
}
),
)
@@ -128,6 +141,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=[],
),
}
),
)
@@ -200,6 +223,16 @@ class BehaviorEngine:
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)
@@ -326,6 +359,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
@@ -493,6 +536,18 @@ class BehaviorEngine:
*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 = (
@@ -560,6 +615,20 @@ class BehaviorEngine:
"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)
def set_automation_enabled(
@@ -870,6 +939,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,
@@ -1065,10 +1139,19 @@ def _next_model_snapshots(
high_confidence_sample_count=trusted_actions,
average_confidence=round(average_confidence, 4),
incorrect_feedback_count=incorrect_feedback_count,
patterns=patterns,
patterns=patterns[-_MAX_SNAPSHOT_PATTERNS:],
reason=reason,
)
return [*existing, snapshot][-10:]
return _compact_model_snapshots([*existing, snapshot])
def _compact_model_snapshots(existing: list[ModelSnapshot]) -> list[ModelSnapshot]:
return [
snapshot.model_copy(
update={"patterns": snapshot.patterns[-_MAX_SNAPSHOT_PATTERNS:]}
)
for snapshot in existing[-_MAX_MODEL_SNAPSHOTS:]
]
def _average(values: list[float]) -> float:
@@ -1208,6 +1291,115 @@ def _automation_conflicts(
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.2.0",
version="1.5.4",
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:
@@ -213,8 +261,8 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
try:
async with websockets.connect(
ws_url,
ping_interval=20,
ping_timeout=10,
ping_interval=30,
ping_timeout=30,
) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)

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; }
@@ -54,6 +56,8 @@
.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 +103,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 +122,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; }
@@ -145,19 +151,19 @@
</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>
<option value="guide">Ablauf</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>
@@ -208,7 +214,7 @@
<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>
@@ -219,13 +225,13 @@
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section class="detail-panel" id="detail">
<section class="detail-panel app-view" 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>
<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>
@@ -239,7 +245,30 @@
<div id="job-queue" class="decision-list"></div>
</section>
<section class="guide-panel" id="guide">
<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>
<p class="muted">Die API speichert stabile technische Werte. Die Oberfläche übersetzt sie in die gewählte Sprache.</p>
</div>
<div>
<h3>Performance-Standard</h3>
<p>Startansichten dürfen maximal 3 Sekunden brauchen. Schwere Daten werden nur nach Menüwechsel oder bei Bearbeitung geladen.</p>
</div>
</div>
</section>
<section class="guide-panel app-view" id="guide">
<details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps">
@@ -279,17 +308,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 +535,9 @@ function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedDetailHtml.clear();
}
async function api(path, options = {}) {
@@ -312,6 +552,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 +567,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 +590,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 +678,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 +716,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 +815,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 +832,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 +844,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 +873,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("")}
` : "";
@@ -771,13 +1134,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 +1178,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 +1188,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,
@@ -908,6 +1292,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
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>
@@ -916,7 +1301,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>
@@ -975,7 +1360,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${timeProfiles.length ? timeProfiles.map(profile => `
<div class="metric">
<strong>${escapeHtml(profile.label)}</strong>
${escapeHtml(profile.sample_count)} Samples · ${escapeHtml(profile.dominant_state || "offen")}
${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>"}
@@ -988,7 +1373,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<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)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
<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>
@@ -1000,13 +1385,28 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`).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(conflict.status)}</span> ${escapeHtml(conflict.reason)}</li>
<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 learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation",
).length;
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");
@@ -1073,13 +1473,13 @@ 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>
<button class="secondary compact" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Details aktualisieren</button>
</div>
<div class="grid-two">
<div>
@@ -1095,8 +1495,8 @@ 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>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
<div class="actions">${activationButton}</div>
@@ -1116,6 +1516,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${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>
@@ -1130,8 +1531,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${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;
}
@@ -1329,7 +1732,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."},
],
@@ -1435,11 +1838,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,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

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

View File

@@ -1,13 +1,14 @@
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 fastapi.testclient import TestClient
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ModelSnapshot
from app.actuators.models import JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings
@@ -146,6 +147,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]},
@@ -407,7 +409,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")
@@ -418,13 +420,68 @@ 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_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

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

@@ -17,6 +17,7 @@ def test_dashboard_is_served_at_root() -> None:
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 '<option value="detail">Details</option>' not 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
@@ -24,7 +25,7 @@ def test_dashboard_is_served_at_root() -> None:
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
@@ -42,5 +43,7 @@ def test_dashboard_is_served_at_root() -> None:
assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' 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,11 @@ 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=30,
ping_timeout=30,
)
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},