Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-17 00:09:35 +02:00
parent f8bee92e64
commit 387e027fe2
6 changed files with 89 additions and 19 deletions

View File

@@ -1,5 +1,11 @@
# Changelog # 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 ## 0.7.18 - 2026-06-16
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und - Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege. rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.

View File

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

@@ -55,6 +55,21 @@ class ActuatorSuggestion(BaseModel):
likely_context_count: int = 0 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]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> 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()} 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 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]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()

View File

@@ -105,7 +105,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="0.7.18", version="0.7.19",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -195,6 +195,12 @@ function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
}
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = response.status === 204 ? null : await response.json().catch(() => ({})); const body = response.status === 204 ? null : await response.json().catch(() => ({}));
@@ -203,7 +209,9 @@ async function api(path, options = {}) {
} }
function lifecycleLabel(record) { 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 = { const labels = {
trained: "lernt", trained: "lernt",
pending_history: "sammelt Historie", pending_history: "sammelt Historie",
@@ -212,26 +220,32 @@ function lifecycleLabel(record) {
archived: "wartet auf Kontext", archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden", orphaned: "Aktor nicht gefunden",
}; };
return labels[record.lifecycle.status] || record.lifecycle.status; return labels[lifecycleStatus] || lifecycleStatus;
} }
function statusClass(record) { function statusClass(record) {
if (record.behavior.status === "trained") return "ok"; const behaviorStatus = record.behavior_status || record.behavior?.status;
if (record.lifecycle.status === "trained") return "ok"; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; if (behaviorStatus === "trained") return "ok";
if (lifecycleStatus === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
return "bad"; return "bad";
} }
function behaviorLabel(record) { function behaviorLabel(record) {
if (record.behavior.mode === "active") return "aktiv freigegeben"; const mode = record.behavior_mode || record.behavior?.mode;
if (record.behavior.status === "trained") return "Shadow-Vorhersage"; const status = record.behavior_status || record.behavior?.status;
if (record.behavior.status === "blocked") return "Lernen blockiert"; if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Shadow-Vorhersage";
if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen"; return "sammelt Handlungen";
} }
function predictionLabel(record) { function predictionLabel(record) {
return record.behavior.prediction const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)` const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target
? `${target} (${Math.round(confidence * 100)} %)`
: "Keine fällige Aktion"; : "Keine fällige Aktion";
} }
@@ -340,8 +354,8 @@ async function loadOverview() {
async function loadDashboardData() { async function loadDashboardData() {
const [actuators, entities, discovery] = await Promise.all([ const [actuators, entities, discovery] = await Promise.all([
api("v1/actuators"), api("v1/actuators/summary"),
api("v1/entities"), Promise.resolve([]),
api("v1/actuators/discovery"), api("v1/actuators/discovery"),
]); ]);
cachedActuators = actuators; cachedActuators = actuators;
@@ -544,15 +558,15 @@ function renderConfiguredActuators() {
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span> <span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div> <div class="metric"><strong>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div> <div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div> <div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div> </div>
<div class="actions"> <div class="actions">
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button> <button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
${record.behavior.mode === "active" ${record.behavior_mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
: record.behavior.activation_ready : record.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>` ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
: ""} : ""}
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button> <button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
@@ -737,6 +751,7 @@ async function saveManualAssignment(actuatorId) {
note: "Manuell im Dashboard gesetzt", note: "Manuell im Dashboard gesetzt",
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert."); await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) { } catch (error) {
@@ -760,6 +775,7 @@ async function removeContextEntity(actuatorId, entityId) {
note: `Entity ${entityId} entfernt`, note: `Entity ${entityId} entfernt`,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt."); await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) { } catch (error) {
@@ -784,6 +800,7 @@ async function evaluateActuator(actuatorId) {
const message = record.behavior.prediction 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}: ${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.`; : `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, message); await showActuator(actuatorId, message);
} catch (error) { } catch (error) {
@@ -801,6 +818,7 @@ async function sendFeedback(actuatorId, correct) {
expected_state: expectedState || null, expected_state: expectedState || null,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert."); await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) { } catch (error) {
@@ -826,6 +844,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
restore_paused_automations: restorePausedAutomations, restore_paused_automations: restorePausedAutomations,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
@@ -844,6 +863,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
enabled, enabled,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.7.18" version = "0.7.19"
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 = [