Use lightweight actuator dashboard summaries
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user