Compare commits

...

2 Commits

Author SHA1 Message Date
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
12 changed files with 132 additions and 21 deletions

View File

@@ -25,6 +25,10 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md) [`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten: - Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md) [`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)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## Reifegrad

View File

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

View File

@@ -306,15 +306,25 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
@router.get("/dashboard", response_model=DashboardOverview) @router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview: def dashboard_overview(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=True) return _dashboard_overview(request, include_background=True, include_actuators=True)
@router.get("/dashboard/start", response_model=DashboardOverview) @router.get("/dashboard/start", response_model=DashboardOverview)
def dashboard_start(request: Request) -> DashboardOverview: def dashboard_start(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=False) return _dashboard_overview(request, include_background=False, include_actuators=True)
def _dashboard_overview(request: Request, *, include_background: bool) -> DashboardOverview: @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) cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", []) raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list): if not isinstance(raw_entities, list):
@@ -329,7 +339,7 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
] if include_background and isinstance(raw_groups, list) else [] ] if include_background and isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request) reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None) ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request) actuators = list_configured_summary(request) if include_actuators else []
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)
jobs = ( jobs = (
store.load_job_queue() store.load_job_queue()
@@ -348,7 +358,11 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
if reconciliation.last_completed_at is not None if reconciliation.last_completed_at is not None
else None else None
), ),
configured_actuators=len(actuators), configured_actuators=(
len(actuators)
if include_actuators
else reconciliation.configured_actuators
),
trained_models=reconciliation.trained_models, trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required, review_required=reconciliation.review_required,
job_p95_duration_ms=job_p95_duration_ms, job_p95_duration_ms=job_p95_duration_ms,
@@ -433,7 +447,7 @@ def get_actuator_detail(actuator_entity_id: str, request: Request) -> ActuatorRe
} }
compact_snapshots = [ compact_snapshots = [
snapshot.model_copy(update={"patterns": []}) snapshot.model_copy(update={"patterns": []})
for snapshot in record.behavior.model_snapshots[-5:] for snapshot in record.behavior.model_snapshots[-3:]
] ]
compact_behavior = record.behavior.model_copy( compact_behavior = record.behavior.model_copy(
update={ update={

View File

@@ -32,6 +32,8 @@ from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
_MAX_PATTERNS = 500 _MAX_PATTERNS = 500
_MAX_MODEL_SNAPSHOTS = 3
_MAX_SNAPSHOT_PATTERNS = 120
_MAX_EXECUTION_EVENTS = 100 _MAX_EXECUTION_EVENTS = 100
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3) _CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
@@ -937,6 +939,11 @@ class BehaviorEngine:
record: ActuatorRecord, record: ActuatorRecord,
behavior: BehaviorState, behavior: BehaviorState,
) -> ActuatorRecord: ) -> ActuatorRecord:
behavior = behavior.model_copy(
update={
"model_snapshots": _compact_model_snapshots(behavior.model_snapshots),
}
)
updated = record.model_copy( updated = record.model_copy(
update={ update={
"behavior": behavior, "behavior": behavior,
@@ -1132,10 +1139,19 @@ def _next_model_snapshots(
high_confidence_sample_count=trusted_actions, high_confidence_sample_count=trusted_actions,
average_confidence=round(average_confidence, 4), average_confidence=round(average_confidence, 4),
incorrect_feedback_count=incorrect_feedback_count, incorrect_feedback_count=incorrect_feedback_count,
patterns=patterns, patterns=patterns[-_MAX_SNAPSHOT_PATTERNS:],
reason=reason, reason=reason,
) )
return [*existing, snapshot][-10:] return _compact_model_snapshots([*existing, snapshot])
def _compact_model_snapshots(existing: list[ModelSnapshot]) -> list[ModelSnapshot]:
return [
snapshot.model_copy(
update={"patterns": snapshot.patterns[-_MAX_SNAPSHOT_PATTERNS:]}
)
for snapshot in existing[-_MAX_MODEL_SNAPSHOTS:]
]
def _average(values: list[float]) -> float: def _average(values: list[float]) -> float:

View File

@@ -21,6 +21,7 @@ class Settings:
prediction_interval_seconds: int = 60 prediction_interval_seconds: int = 60
execution_cooldown_seconds: int = 900 execution_cooldown_seconds: int = 900
timezone: str = "Europe/Berlin" timezone: str = "Europe/Berlin"
ha_timeout_seconds: int = 25
@property @property
def ha_configured(self) -> bool: def ha_configured(self) -> bool:
@@ -55,4 +56,5 @@ def load_settings() -> Settings:
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900")) 60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
), ),
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"), timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
ha_timeout_seconds=max(5, int(os.getenv("SILLYHOME_HA_TIMEOUT_SECONDS", "25"))),
) )

View File

@@ -60,6 +60,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=HaClientSettings( settings=HaClientSettings(
url=cast(str, settings.ha_url), url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token), token=cast(str, settings.ha_token),
timeout_seconds=settings.ha_timeout_seconds,
) )
) )
app.state.ha_reader = HaReader(client=client) app.state.ha_reader = HaReader(client=client)
@@ -105,7 +106,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.5.0", version="1.5.2",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -213,8 +214,8 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
try: try:
async with websockets.connect( async with websockets.connect(
ws_url, ws_url,
ping_interval=20, ping_interval=30,
ping_timeout=10, ping_timeout=30,
) as websocket: ) as websocket:
auth_required_msg = await websocket.recv() auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg) auth_required_data = json.loads(auth_required_msg)

View File

@@ -56,6 +56,8 @@
.manual-context > summary, .manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; } .group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; } 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, details.collapsible > summary::-webkit-details-marker,
.manual-context > summary::-webkit-details-marker, .manual-context > summary::-webkit-details-marker,
.group-panel > summary::-webkit-details-marker { display:none; } .group-panel > summary::-webkit-details-marker { display:none; }
@@ -526,6 +528,11 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
return await api(path, {signal: controller.signal}); 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 { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
@@ -652,7 +659,7 @@ async function loadOverview() {
if (budget) budget.textContent = "Startdaten laden ..."; if (budget) budget.textContent = "Startdaten laden ...";
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>"; document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
try { try {
const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS); const dashboard = await api("v1/actuators/dashboard/start");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedActuators = dashboard.actuators || []; cachedActuators = dashboard.actuators || [];
cachedEntities = []; cachedEntities = [];
@@ -682,7 +689,7 @@ async function loadSystemOverview() {
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ..."; if (budget) budget.textContent = "Systemübersicht lädt ...";
try { try {
const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS); const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedActuators = dashboard.actuators || cachedActuators; cachedActuators = dashboard.actuators || cachedActuators;
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
@@ -1764,7 +1771,7 @@ async function startDashboard() {
document.getElementById("section-jump").value = "status-section"; document.getElementById("section-jump").value = "status-section";
showView("status-section"); showView("status-section");
await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => requestAnimationFrame(resolve));
void loadStatus(); setTimeout(() => void loadStatus(), 100);
} }
void startDashboard(); void startDashboard();

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

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

View File

@@ -446,14 +446,17 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
dashboard_response = client.get("/v1/actuators/dashboard") dashboard_response = client.get("/v1/actuators/dashboard")
start_response = client.get("/v1/actuators/dashboard/start") start_response = client.get("/v1/actuators/dashboard/start")
system_response = client.get("/v1/actuators/dashboard/system")
anomalies_response = client.get("/v1/actuators/anomalies") anomalies_response = client.get("/v1/actuators/anomalies")
assert dashboard_response.status_code == 200 assert dashboard_response.status_code == 200
assert start_response.status_code == 200 assert start_response.status_code == 200
assert system_response.status_code == 200
system = dashboard_response.json()["system"] system = dashboard_response.json()["system"]
start_payload = start_response.json() start_payload = start_response.json()
assert start_payload["jobs"]["jobs"] == [] assert start_payload["jobs"]["jobs"] == []
assert start_payload["discovery_groups"] == [] assert start_payload["discovery_groups"] == []
assert system_response.json()["actuators"] == []
assert system["performance_budget_ms"] == 3000 assert system["performance_budget_ms"] == 3000
assert system["slow_job_count"] == 1 assert system["slow_job_count"] == 1
assert system["performance_status"] == "slow" assert system["performance_status"] == "slow"

View File

@@ -92,11 +92,11 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
connect.assert_called_once_with( connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket", "ws://homeassistant:8123/api/websocket",
ping_interval=20, ping_interval=30,
ping_timeout=10, ping_timeout=30,
) )
assert fake_ws.sent == [ assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"}, {"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"}, {"id": 1, "type": "subscribe_events", "event_type": "state_changed"},