Rework dashboard loading and cache entity metadata
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:41:50 +02:00
parent 98a2b2cc38
commit 4db4276b95
8 changed files with 224 additions and 56 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## 0.7.21 - 2026-06-17
- Dashboard-Ladepfad getrennt: beobachtete Geräte laden sofort über
`/v1/actuators/summary`; Status, Discovery und Vorschläge laufen unabhängig
nachgelagert und blockieren die Übersicht nicht mehr.
- Systemstatus nutzt Timeouts und bleibt auch bei langsamem ML-/HA-Status
bedienbar.
- HA-Entity-Metadaten werden als JSON-Cache gespeichert und für Friendly Name,
Raum und Gerät in schlanken Summaries wiederverwendet.
- Anleitung, Gerätegruppen und manuelle Kontextauswahl sind aufklappbar und
kompakter für Smartphone- und Desktopansichten.
## 0.7.20 - 2026-06-17 ## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift - Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu. nicht mehr auf `record.behavior.status` aus dem Vollformat zu.

View File

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

@@ -1,5 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -7,6 +12,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ReconciliationState from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import DiscoveredEntity, EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
@@ -58,6 +64,9 @@ class ActuatorSuggestion(BaseModel):
class ActuatorSummary(BaseModel): class ActuatorSummary(BaseModel):
actuator_entity_id: str actuator_entity_id: str
domain: str domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
enabled: bool enabled: bool
behavior_mode: str behavior_mode: str
behavior_status: str behavior_status: str
@@ -71,8 +80,18 @@ class ActuatorSummary(BaseModel):
@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(
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} request: Request,
refresh: bool = Query(default=False),
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[HaEntitySummary]:
cached_entities = [] if refresh else _load_cached_entities(request)
if cached_entities:
entities = {entity.entity_id: entity for entity in cached_entities}
else:
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
entities = {entity.entity_id: entity for entity in fresh_entities}
discovered = ha_reader.discover() discovered = ha_reader.discover()
actuator_ids = _deduplicate_actuator_ids( actuator_ids = _deduplicate_actuator_ids(
[ [
@@ -160,10 +179,26 @@ def context_options(
@router.get("/summary", response_model=list[ActuatorSummary]) @router.get("/summary", response_model=list[ActuatorSummary])
def list_configured_summary(request: Request) -> list[ActuatorSummary]: def list_configured_summary(request: Request) -> list[ActuatorSummary]:
entity_map = {entity.entity_id: entity for entity in _load_cached_entities(request)}
return [ return [
ActuatorSummary( ActuatorSummary(
actuator_entity_id=record.actuator_entity_id, actuator_entity_id=record.actuator_entity_id,
domain=record.actuator_entity_id.split(".", 1)[0], domain=record.actuator_entity_id.split(".", 1)[0],
friendly_name=(
entity_map[record.actuator_entity_id].friendly_name
if record.actuator_entity_id in entity_map
else None
),
area_name=(
entity_map[record.actuator_entity_id].area_name
if record.actuator_entity_id in entity_map
else None
),
device_name=(
entity_map[record.actuator_entity_id].device_name
if record.actuator_entity_id in entity_map
else None
),
enabled=record.enabled, enabled=record.enabled,
behavior_mode=record.behavior.mode.value, behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value, behavior_status=record.behavior.status.value,
@@ -377,6 +412,45 @@ def _behavior(request: Request) -> BehaviorEngine:
return engine return engine
def _entity_cache_path(request: Request) -> Path:
store = getattr(request.app.state, "actuator_store", None)
settings = getattr(request.app.state, "settings", None)
if isinstance(store, ActuatorStore):
base_dir = store._root
elif isinstance(settings, Settings):
base_dir = Path(settings.actuator_store).resolve().parent
else:
base_dir = Path(".").resolve()
return Path(os.getenv("SILLYHOME_ENTITY_CACHE", base_dir / "ha_entity_cache.json"))
def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
path = _entity_cache_path(request)
if not path.exists():
return []
try:
payload = json.loads(path.read_text(encoding="utf-8"))
raw_entities = payload.get("entities", [])
return [HaEntitySummary.model_validate(entity) for entity in raw_entities]
except (OSError, TypeError, ValueError):
return []
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
path = _entity_cache_path(request)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"entities": [entity.model_dump(mode="json") for entity in entities],
}
temporary = path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def _deduplicate_actuator_ids( def _deduplicate_actuator_ids(
discovered: list[tuple[str, str]], discovered: list[tuple[str, str]],
entities: dict[str, HaEntitySummary], entities: dict[str, HaEntitySummary],

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

View File

@@ -6,27 +6,40 @@
<title>SillyHome Next</title> <title>SillyHome Next</title>
<style> <style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; } :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
body { margin: 0; font-size:16px; } body { margin: 0; font-size:15px; }
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); } header { padding: 18px 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
h1,h2,h3 { margin: 0 0 12px; } h1,h2,h3 { margin: 0 0 12px; }
header p { margin: 5px 0; color: #c3d1dc; } header p { margin: 5px 0; color: #c3d1dc; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; } main { display: grid; grid-template-columns: minmax(280px,.85fr) minmax(0,1.35fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; } section { background: #18212b; border: 1px solid #2d3a47; border-radius: 8px; padding: 12px; min-width:0; }
section:target { outline:2px solid #66dfa9; outline-offset:2px; } section:target { outline:2px solid #66dfa9; outline-offset:2px; }
.wide { grid-column: 1 / -1; } .wide { grid-column: 1 / -1; }
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); } .quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; } .quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
.quick-nav a.primary { background:#23715b; } .quick-nav a.primary { background:#23715b; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; } details.collapsible > summary,
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; } .manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
details.collapsible > summary::-webkit-details-marker,
.manual-context > summary::-webkit-details-marker,
.group-panel > summary::-webkit-details-marker { display:none; }
details.collapsible > summary::after,
.manual-context > summary::after,
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
details[open].collapsible > summary::after,
.manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:"zuklappen"; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
.step { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; } .step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
.step p { margin:5px 0; } .step p { margin:5px 0; }
.ok { color: #66dfa9; } .ok { color: #66dfa9; }
.warn { color: #f3c969; } .warn { color: #f3c969; }
.bad { color: #ff8f8f; } .bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; } label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; } select,input,button { box-sizing: border-box; width: 100%; border-radius: 8px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; font:inherit; min-width:0; }
select[multiple] { min-height:190px; } select[multiple] { min-height:150px; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; } button.secondary { background: #37495c; }
button.danger { background: #7b3434; } button.danger { background: #7b3434; }
@@ -39,18 +52,18 @@
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; } .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; } .chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
.muted { color:#9fb0be; } .muted { color:#9fb0be; }
.card-list { display:grid; gap:12px; } .card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } .actuator-card { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); } .actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; } .card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
.entity-id { overflow-wrap:anywhere; font-weight:800; } .entity-id { overflow-wrap:anywhere; font-weight:800; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; } .metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; } .metric { background:#18212b; border:1px solid #2d3a47; border-radius:8px; padding:8px; min-width:0; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; } .metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; } .actions button { flex:1 1 180px; margin-top:0; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; } .detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } .manual-context { margin-top:12px; background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; } .inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; } .manual-entry { min-height:80px; resize:vertical; }
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; } textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
@@ -59,11 +72,12 @@
@media (max-width: 760px) { @media (max-width: 760px) {
header { padding:18px 14px; } header { padding:18px 14px; }
header h1 { font-size:1.55rem; } header h1 { font-size:1.55rem; }
main { display:block; padding:10px; } main { display:block; padding:8px; }
section { margin-bottom:12px; padding:14px; border-radius:14px; } section { margin-bottom:10px; padding:10px; border-radius:8px; }
.steps { grid-template-columns:1fr; } .steps { grid-template-columns:1fr; }
.grid-two { grid-template-columns:1fr; } .grid-two { grid-template-columns:1fr; }
.metric-grid { grid-template-columns:1fr 1fr; } .metric-grid { grid-template-columns:1fr 1fr; }
.card-list { grid-template-columns:1fr; }
.actions { display:grid; grid-template-columns:1fr; } .actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; } .actions button, button.compact { width:100%; min-width:0; margin-right:0; }
.quick-nav { padding:8px 10px; } .quick-nav { padding:8px 10px; }
@@ -90,7 +104,8 @@
</nav> </nav>
<main> <main>
<section class="wide" id="guide"> <section class="wide" id="guide">
<h2>So gehst du vor</h2> <details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps"> <div class="steps">
<div class="step"> <div class="step">
<span class="step-number">1</span> <span class="step-number">1</span>
@@ -111,6 +126,7 @@
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p> <p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div> </div>
</div> </div>
</details>
</section> </section>
<section id="status-section"> <section id="status-section">
@@ -190,6 +206,7 @@ let cachedActuators = null;
let cachedEntities = null; let cachedEntities = null;
let cachedDiscovery = null; let cachedDiscovery = null;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2500;
function uniqueValues(values) { function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
@@ -208,6 +225,16 @@ async function api(path, options = {}) {
return body; return body;
} }
async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await api(path, {signal: controller.signal});
} finally {
clearTimeout(timeout);
}
}
function lifecycleLabel(record) { function lifecycleLabel(record) {
const behaviorStatus = record.behavior_status || record.behavior?.status; const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
@@ -324,48 +351,83 @@ function optionGroups(entities, selectedIds = new Set()) {
} }
async function loadOverview() { async function loadOverview() {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
await loadSummaryData();
renderConfiguredActuators();
void loadStatus();
void loadActuatorDiscovery();
void loadActuatorSuggestions();
}
async function loadStatus() {
const status = document.getElementById("status"); const status = document.getElementById("status");
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
try { try {
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([ const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([
api("health"), apiWithTimeout("health"),
api("health/websocket"), apiWithTimeout("health/websocket"),
api("ml/health"), apiWithTimeout("ml/health"),
api("v1/actuators/reconciliation/state"), apiWithTimeout("v1/actuators/reconciliation/state"),
api("v1/actuators"), apiWithTimeout("v1/actuators/summary"),
]); ]);
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`; const values = [health, websocket, ml, reconciliation, actuators].map(result =>
result.status === "fulfilled" ? result.value : null
);
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values;
const hasError = values.some(value => value === null);
status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`;
chips.innerHTML = [ chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(health.status)}</span>`, `<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`, `<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Aktoren: ${actuators.length}</span>`, `<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`, `<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await loadDashboardData(); }
renderActuatorDiscovery();
renderConfiguredActuators(); async function loadSummaryData() {
void loadActuatorSuggestions(); const actuators = await api("v1/actuators/summary");
cachedActuators = actuators;
cachedEntities = [];
} }
async function loadDashboardData() { async function loadDashboardData() {
const [actuators, entities, discovery] = await Promise.all([ await loadSummaryData();
await loadDiscoveryData();
}
async function loadDiscoveryData() {
const discovery = await api("v1/actuators/discovery");
cachedDiscovery = discovery;
}
async function loadFullDashboardData() {
const [actuators, discovery] = await Promise.all([
api("v1/actuators/summary"), api("v1/actuators/summary"),
Promise.resolve([]),
api("v1/actuators/discovery"), api("v1/actuators/discovery"),
]); ]);
cachedActuators = actuators; cachedActuators = actuators;
cachedEntities = entities; cachedEntities = [];
cachedDiscovery = discovery; cachedDiscovery = discovery;
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
if (!cachedActuators || !cachedDiscovery) { const select = document.getElementById("actuator-select");
await loadDashboardData(); if (select && !cachedDiscovery) {
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
}
if (!cachedActuators) {
await loadSummaryData();
}
if (!cachedDiscovery) {
await loadDiscoveryData();
} }
renderActuatorDiscovery(); renderActuatorDiscovery();
} }
@@ -384,7 +446,7 @@ function renderActuatorDiscovery() {
renderActuatorSelect(); renderActuatorSelect();
} catch (error) { } catch (error) {
options.innerHTML = ""; options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`; select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
} }
} }
@@ -523,8 +585,8 @@ async function configureActuator() {
} }
async function loadConfiguredActuators() { async function loadConfiguredActuators() {
if (!cachedActuators || !cachedEntities) { if (!cachedActuators) {
await loadDashboardData(); await loadSummaryData();
} }
renderConfiguredActuators(); renderConfiguredActuators();
} }
@@ -533,25 +595,23 @@ function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators"); const box = document.getElementById("configured-actuators");
try { try {
const rows = cachedActuators || []; const rows = cachedActuators || [];
const entities = cachedEntities || [];
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
const groups = new Map(); const groups = new Map();
for (const record of rows) { for (const record of rows) {
const entity = entityMap.get(record.actuator_entity_id) || {}; const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
if (!groups.has(group)) groups.set(group, []); if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({record, entity}); groups.get(group).push({record});
} }
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => ` ${groupedRows.map(([group, items]) => `
<h3>${escapeHtml(group)}</h3> <details class="group-panel" open>
<summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list"> <div class="card-list">
${items.map(({record, entity}) => ` ${items.map(({record}) => `
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}"> <article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
<div class="card-title"> <div class="card-title">
<div> <div>
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div> <div><strong>${escapeHtml(record.friendly_name || record.device_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 || 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>
@@ -574,6 +634,7 @@ function renderConfiguredActuators() {
</article> </article>
`).join("")} `).join("")}
</div> </div>
</details>
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>"; `).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
@@ -628,8 +689,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
selected: manualContextIds, selected: manualContextIds,
}; };
const manualAssignment = ` const manualAssignment = `
<div class="manual-context"> <details class="manual-context" open>
<h3>Kontext selbst festlegen</h3> <summary>Kontext selbst festlegen</summary>
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p> <p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label> <label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select"> <select id="manual-numeric-select">
@@ -659,7 +720,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button> <button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button> <button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
</div> </div>
</div> </details>
`; `;
const activationButton = record.behavior.mode === "active" const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button> ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>

View File

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

@@ -213,6 +213,23 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
] ]
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/summary")
assert response.status_code == 200
payload = response.json()
assert payload[0]["actuator_entity_id"] == "light.abstellkammer"
assert payload[0]["friendly_name"] == "Abstellkammer Licht"
assert payload[0]["area_name"] == "Abstellkammer"
assert "behavior" not in payload[0]
assert "numeric_candidates" not in payload[0]
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None: def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)

View File

@@ -34,5 +34,10 @@ def test_dashboard_is_served_at_root() -> None:
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 ===" not in response.text
assert "record.behavior_status || record.behavior?.status" in response.text assert "record.behavior_status || record.behavior?.status" in response.text
assert 'api("v1/actuators")' not in response.text
assert 'api("v1/actuators/summary")' in response.text
assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' in response.text
assert 'class="group-panel"' 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