Compare commits

..

2 Commits

Author SHA1 Message Date
98a2b2cc38 Fix dashboard summary status rendering
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:21:14 +02:00
387e027fe2 Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:09:35 +02:00
7 changed files with 96 additions and 20 deletions

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
## 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.20"
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.20",
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;
@@ -539,20 +553,20 @@ function renderConfiguredActuators() {
<div> <div>
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div> <div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div> <div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div> <div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
</div> </div>
<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.20"
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

@@ -32,5 +32,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert "record.behavior.activation_ready" in response.text assert "record.behavior.activation_ready" in response.text
assert "record.behavior.status ===" not in response.text
assert "record.behavior_status || record.behavior?.status" in response.text
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text