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
## 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
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.

View File

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

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

View File

@@ -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.20",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -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;
@@ -539,20 +553,20 @@ function renderConfiguredActuators() {
<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="${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>
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</div>
<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>Handlungen</strong>${record.behavior.sample_count}</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.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div>
<div class="actions">
<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>`
: record.behavior.activation_ready
: record.activation_ready
? `<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>
@@ -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) {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "0.7.18"
version = "0.7.20"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
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 "Vorhersage jetzt prüfen" not 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 "Manuelle Overrides" not in response.text