From 387e027fe2e533700dbcda837181a296dbb5999f Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 17 Jun 2026 00:09:35 +0200 Subject: [PATCH] Use lightweight actuator dashboard summaries --- CHANGELOG.md | 6 +++++ addon/config.yaml | 2 +- app/api/v1/actuators.py | 44 ++++++++++++++++++++++++++++++++++ app/main.py | 2 +- app/static/index.html | 52 ++++++++++++++++++++++++++++------------- pyproject.toml | 2 +- 6 files changed, 89 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3920d98..a916bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.7.19 - 2026-06-17 +- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt + statt voller Lernmuster und kompletter HA-Entityliste. +- Nach Aktionen werden Dashboard-Caches gezielt invalidiert, damit keine + stale oder doppelt geladenen Einträge entstehen. + ## 0.7.18 - 2026-06-16 - Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege. diff --git a/addon/config.yaml b/addon/config.yaml index 855ef64..f8da4d9 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.18" +version: "0.7.19" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 2ee76e7..45cbb8d 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -55,6 +55,21 @@ class ActuatorSuggestion(BaseModel): likely_context_count: int = 0 +class ActuatorSummary(BaseModel): + actuator_entity_id: str + domain: str + enabled: bool + behavior_mode: str + behavior_status: str + lifecycle_status: str + activation_ready: bool + activation_reason: str + sample_count: int + prediction_target_state: str | None = None + prediction_confidence: float | None = None + updated_at: str + + @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} @@ -143,6 +158,35 @@ def context_options( raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.get("/summary", response_model=list[ActuatorSummary]) +def list_configured_summary(request: Request) -> list[ActuatorSummary]: + return [ + ActuatorSummary( + actuator_entity_id=record.actuator_entity_id, + domain=record.actuator_entity_id.split(".", 1)[0], + enabled=record.enabled, + behavior_mode=record.behavior.mode.value, + behavior_status=record.behavior.status.value, + lifecycle_status=record.lifecycle.status.value, + activation_ready=record.behavior.activation_ready, + activation_reason=record.behavior.activation_reason, + sample_count=record.behavior.sample_count, + prediction_target_state=( + record.behavior.prediction.target_state + if record.behavior.prediction is not None + else None + ), + prediction_confidence=( + record.behavior.prediction.confidence + if record.behavior.prediction is not None + else None + ), + updated_at=record.updated_at.isoformat(), + ) + for record in _service(request).list_configured() + ] + + @router.get("", response_model=list[ActuatorRecord]) def list_configured(request: Request) -> list[ActuatorRecord]: return _service(request).list_configured() diff --git a/app/main.py b/app/main.py index a46df4b..2cadd11 100644 --- a/app/main.py +++ b/app/main.py @@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.7.18", + version="0.7.19", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 586f9c0..ed47a9c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -195,6 +195,12 @@ function uniqueValues(values) { return [...new Set(values.filter(Boolean))]; } +function invalidateDashboardCache() { + cachedActuators = null; + cachedEntities = null; + cachedDiscovery = null; +} + async function api(path, options = {}) { const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const body = response.status === 204 ? null : await response.json().catch(() => ({})); @@ -203,7 +209,9 @@ async function api(path, options = {}) { } function lifecycleLabel(record) { - if (record.behavior.status === "trained") return "Kontext erkannt"; + const behaviorStatus = record.behavior_status || record.behavior?.status; + const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; + if (behaviorStatus === "trained") return "Kontext erkannt"; const labels = { trained: "lernt", pending_history: "sammelt Historie", @@ -212,26 +220,32 @@ function lifecycleLabel(record) { archived: "wartet auf Kontext", orphaned: "Aktor nicht gefunden", }; - return labels[record.lifecycle.status] || record.lifecycle.status; + return labels[lifecycleStatus] || lifecycleStatus; } function statusClass(record) { - if (record.behavior.status === "trained") return "ok"; - if (record.lifecycle.status === "trained") return "ok"; - if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; + const behaviorStatus = record.behavior_status || record.behavior?.status; + const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; + if (behaviorStatus === "trained") return "ok"; + if (lifecycleStatus === "trained") return "ok"; + if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn"; return "bad"; } function behaviorLabel(record) { - if (record.behavior.mode === "active") return "aktiv freigegeben"; - if (record.behavior.status === "trained") return "Shadow-Vorhersage"; - if (record.behavior.status === "blocked") return "Lernen blockiert"; + 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 === "blocked") return "Lernen blockiert"; return "sammelt Handlungen"; } function predictionLabel(record) { - return record.behavior.prediction - ? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)` + const target = record.prediction_target_state || record.behavior?.prediction?.target_state; + const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence; + return target + ? `${target} (${Math.round(confidence * 100)} %)` : "Keine fällige Aktion"; } @@ -340,8 +354,8 @@ async function loadOverview() { async function loadDashboardData() { const [actuators, entities, discovery] = await Promise.all([ - api("v1/actuators"), - api("v1/entities"), + api("v1/actuators/summary"), + Promise.resolve([]), api("v1/actuators/discovery"), ]); cachedActuators = actuators; @@ -544,15 +558,15 @@ function renderConfiguredActuators() { ${escapeHtml(lifecycleLabel(record))}
-
Freigabe${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}
-
Handlungen${record.behavior.sample_count}
+
Freigabe${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}
+
Handlungen${record.sample_count}
Vorhersage${escapeHtml(predictionLabel(record))}
- ${record.behavior.mode === "active" + ${record.behavior_mode === "active" ? `` - : record.behavior.activation_ready + : record.activation_ready ? `` : ""} @@ -737,6 +751,7 @@ async function saveManualAssignment(actuatorId) { note: "Manuell im Dashboard gesetzt", }), }); + invalidateDashboardCache(); await loadConfiguredActuators(); await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert."); } catch (error) { @@ -760,6 +775,7 @@ async function removeContextEntity(actuatorId, entityId) { note: `Entity ${entityId} entfernt`, }), }); + invalidateDashboardCache(); await loadConfiguredActuators(); await showActuator(actuatorId, "Kontext-Entity entfernt."); } catch (error) { @@ -784,6 +800,7 @@ async function evaluateActuator(actuatorId) { const message = record.behavior.prediction ? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.` : `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`; + invalidateDashboardCache(); await loadConfiguredActuators(); await showActuator(actuatorId, message); } catch (error) { @@ -801,6 +818,7 @@ async function sendFeedback(actuatorId, correct) { expected_state: expectedState || null, }), }); + invalidateDashboardCache(); await loadConfiguredActuators(); await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert."); } catch (error) { @@ -826,6 +844,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto restore_paused_automations: restorePausedAutomations, }), }); + invalidateDashboardCache(); await loadConfiguredActuators(); await showActuator(actuatorId); } catch (error) { @@ -844,6 +863,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) { enabled, }), }); + invalidateDashboardCache(); await loadConfiguredActuators(); await showActuator(actuatorId); } catch (error) { diff --git a/pyproject.toml b/pyproject.toml index bc8e079..30d75cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.18" +version = "0.7.19" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [