Compare commits

...

2 Commits

Author SHA1 Message Date
214b384b70 Release v1.6.0 dashboard architecture cleanup
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 01:02:29 +02:00
6323b93f23 Fix ingress logging and dashboard cache navigation
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:30:12 +02:00
14 changed files with 345 additions and 95 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ __pycache__/
.env .env
.env.local .env.local
.env.* .env.*
/.actuator_store/
/MagicMock/

View File

@@ -1,5 +1,27 @@
# Changelog # Changelog
## 1.6.0 - 2026-06-18
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
materialisieren.
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
der Aktor-IDs.
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
Versions-Chip im Einrichtungsbereich.
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
anderen beobachteten Geraets.
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
## 1.5.4 - 2026-06-18
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
laden nur bei expliziter Aktualisierung neu.
## 1.2.0 - 2026-06-17 ## 1.2.0 - 2026-06-17
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback: - Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback

View File

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

@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
fi fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE" mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \ exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000
--proxy-headers --forwarded-allow-ips='*'

View File

@@ -33,6 +33,38 @@ class DashboardCache:
"entities": [json.loads(row[1]) for row in rows], "entities": [json.loads(row[1]) for row in rows],
} }
def load_status(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
entity_count = connection.execute("select count(*) from ha_entities").fetchone()[0]
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entity_count": int(entity_count or 0),
}
def load_entity_map(self, entity_ids: set[str]) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
placeholders = ",".join("?" for _ in entity_ids)
with self._lock, self._connect() as connection:
rows = connection.execute(
f"select entity_id, payload from ha_entities where entity_id in ({placeholders})",
tuple(sorted(entity_ids)),
).fetchall()
result: dict[str, HaEntitySummary] = {}
for entity_id, payload in rows:
try:
result[str(entity_id)] = HaEntitySummary.model_validate(json.loads(payload))
except (TypeError, ValueError):
continue
return result
def save_entities_payload( def save_entities_payload(
self, self,
*, *,

View File

@@ -326,13 +326,10 @@ def _dashboard_overview(
include_background: bool, include_background: bool,
include_actuators: bool, include_actuators: bool,
) -> DashboardOverview: ) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request) cache_status = _load_entity_cache_status(request)
raw_entities = cache_payload.get("entities", []) raw_updated_at = cache_status.get("updated_at")
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
raw_groups = cache_payload.get("discovery_groups", []) raw_groups = cache_status.get("discovery_groups", [])
cached_groups = [ cached_groups = [
DashboardDiscoveryGroup.model_validate(group) DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups for group in raw_groups
@@ -350,6 +347,9 @@ def _dashboard_overview(
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs) job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
anomaly_count = sum(record.anomaly_count for record in actuators) anomaly_count = sum(record.anomaly_count for record in actuators)
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators) critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
entity_count = cache_status.get("entity_count")
if not isinstance(entity_count, int):
entity_count = 0
return DashboardOverview( return DashboardOverview(
system=DashboardSystemStatus( system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"), websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -373,9 +373,9 @@ def _dashboard_overview(
critical_anomaly_count=critical_anomaly_count, critical_anomaly_count=critical_anomaly_count,
), ),
cache=EntityCacheStatus( cache=EntityCacheStatus(
available=bool(raw_entities), available=bool(entity_count),
updated_at=updated_at, updated_at=updated_at,
entity_count=len(raw_entities), entity_count=entity_count,
), ),
actuators=actuators, actuators=actuators,
discovery_groups=cached_groups, discovery_groups=cached_groups,
@@ -846,6 +846,11 @@ def _load_cached_entity_map(
) -> dict[str, HaEntitySummary]: ) -> dict[str, HaEntitySummary]:
if not entity_ids: if not entity_ids:
return {} return {}
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
cached_result = cache.load_entity_map(entity_ids)
if cached_result:
return cached_result
payload = _load_entity_cache_payload(request) payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", []) raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list): if not isinstance(raw_entities, list):
@@ -864,6 +869,29 @@ def _load_cached_entity_map(
return result return result
def _load_entity_cache_status(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
status_payload = cache.load_status()
if status_payload.get("entity_count"):
return status_payload
path = _entity_cache_path(request)
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError):
return {}
if not isinstance(payload, dict):
return {}
raw_entities = payload.get("entities", [])
return {
"updated_at": payload.get("updated_at"),
"discovery_groups": payload.get("discovery_groups", []),
"entity_count": len(raw_entities) if isinstance(raw_entities, list) else 0,
}
def _load_entity_cache_payload(request: Request) -> dict[str, object]: def _load_entity_cache_payload(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None) cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache): if isinstance(cache, DashboardCache):

View File

@@ -117,7 +117,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="1.5.3", version="1.6.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -52,6 +52,8 @@
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; } .panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; } .toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; }
.toolbar button { margin-top:0; } .toolbar button { margin-top:0; }
.detail-tools { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:8px; margin:10px 0; align-items:end; }
.detail-tools button { margin-top:0; }
details.collapsible > summary, details.collapsible > summary,
.manual-context > summary, .manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; } .group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
@@ -144,20 +146,17 @@
<div class="brand"> <div class="brand">
<div class="brand-row"> <div class="brand-row">
<span class="brand-mark">SH</span> <span class="brand-mark">SH</span>
<h1>SillyHome Next</h1> <h1>SillyHome</h1>
</div> </div>
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p> <p>Geräte, Lernen, Freigaben und Systemzustand.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<label for="section-jump">Menü</label> <label for="section-jump">Menü</label>
<select id="section-jump" onchange="showView(this.value)"> <select id="section-jump" onchange="showView(this.value)">
<option value="status-section">Startseite / System</option> <option value="status-section">Startseite / System</option>
<option value="observed">Lernen</option> <option value="observed">Lernen</option>
<option value="detail">Details</option>
<option value="choose">Discovery & Einrichtung</option> <option value="choose">Discovery & Einrichtung</option>
<option value="settings">Einstellungen</option> <option value="settings">Einstellungen</option>
<option value="guide">Ablauf</option>
</select> </select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div> <div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div> </div>
@@ -166,11 +165,9 @@
<main> <main>
<section class="control-panel app-view" id="choose"> <section class="control-panel app-view" id="choose">
<div class="panel-title"> <div class="panel-title">
<h2>Steuerung</h2> <h2>Discovery & Einrichtung</h2>
<span class="chip">v1</span>
</div> </div>
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p> <label for="actuator-input">Entity-ID</label>
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off"> <input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
<datalist id="actuator-options"></datalist> <datalist id="actuator-options"></datalist>
<div class="inline-controls"> <div class="inline-controls">
@@ -200,16 +197,15 @@
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off"> <input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
</div> </div>
</div> </div>
<label for="actuator-select">Oder aus Liste wählen</label> <label for="actuator-select">Geräteliste</label>
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()"> <select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
<option value="">Geräteliste bei Bedarf laden</option> <option value="">Geräteliste bei Bedarf laden</option>
</select> </select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button> <button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Bereit.</p>
<details class="manual-context"> <details class="manual-context">
<summary>Vorschläge anzeigen</summary> <summary>Vorschläge anzeigen</summary>
<p class="muted">Vorschläge können Home Assistant stark abfragen und werden deshalb nicht beim Start geladen.</p>
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button> <button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
</details> </details>
<div id="actuator-suggestions" class="card-list"></div> <div id="actuator-suggestions" class="card-list"></div>
@@ -219,7 +215,6 @@
<div class="panel-title"> <div class="panel-title">
<div> <div>
<h2>Beobachtete Geräte</h2> <h2>Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
</div> </div>
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button> <button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
</div> </div>
@@ -227,8 +222,10 @@
</section> </section>
<section class="detail-panel app-view" id="detail"> <section class="detail-panel app-view" id="detail">
<h2>Lernfortschritt und Freigabe</h2> <div class="panel-title">
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p> <h2>Details</h2>
<button class="secondary compact" onclick="showView('observed')">Zurück</button>
</div>
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div> <div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section> </section>
@@ -236,7 +233,6 @@
<div class="panel-title"> <div class="panel-title">
<div> <div>
<h2>System & Cache</h2> <h2>System & Cache</h2>
<p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt erst bei Bedarf.</p>
</div> </div>
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button> <button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
</div> </div>
@@ -260,40 +256,9 @@
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
<option value="en">English</option> <option value="en">English</option>
</select> </select>
<p class="muted">Die API speichert stabile technische Werte. Die Oberfläche übersetzt sie in die gewählte Sprache.</p>
</div>
<div>
<h3>Performance-Standard</h3>
<p>Startansichten dürfen maximal 3 Sekunden brauchen. Schwere Daten werden nur nach Menüwechsel oder bei Bearbeitung geladen.</p>
</div> </div>
</div> </div>
</section> </section>
<section class="guide-panel app-view" id="guide">
<details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Links im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
</div>
</details>
</section>
</main> </main>
<script> <script>
const escapeHtml = value => String(value ?? "") const escapeHtml = value => String(value ?? "")
@@ -309,7 +274,12 @@ let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null; let cachedActuators = null;
let cachedEntities = null; let cachedEntities = null;
let cachedDiscovery = null; let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null; let discoveryLoadPromise = null;
let overviewLoadPromise = null;
let systemLoadPromise = null;
let currentSensorWeightGroups = []; let currentSensorWeightGroups = [];
let visibleActuatorLimit = 24; let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
@@ -465,14 +435,29 @@ function jumpToSection(target) {
} }
function showView(viewId) { function showView(viewId) {
if (viewId === "detail" && !currentActuatorId) {
viewId = "observed";
}
for (const section of document.querySelectorAll(".app-view")) { for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === viewId); section.classList.toggle("active", section.id === viewId);
} }
localStorage.setItem("sillyhome.ui.view", viewId); localStorage.setItem("sillyhome.ui.view", viewId);
if (viewId === "status-section") { if (viewId === "status-section") {
void loadSystemOverview(); if (cachedSystemOverview) {
renderDashboardStatus(cachedSystemOverview);
refreshSystemOverviewInBackground();
} else {
void loadSystemOverview();
}
} else if (viewId === "observed") { } else if (viewId === "observed") {
void loadOverview(); if (cachedActuators) {
renderConfiguredActuators();
refreshOverviewInBackground();
} else {
void loadOverview();
}
} else if (viewId === "choose") {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") { } else if (viewId === "settings") {
syncSettingsView(); syncSettingsView();
} }
@@ -484,8 +469,10 @@ function setLanguage(language) {
localStorage.setItem("sillyhome.ui.language", uiLang); localStorage.setItem("sillyhome.ui.language", uiLang);
syncSettingsView(); syncSettingsView();
if (cachedActuators) renderConfiguredActuators(); if (cachedActuators) renderConfiguredActuators();
void loadSystemOverview(); if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId) void showActuator(currentActuatorId); if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
}
} }
function syncSettingsView() { function syncSettingsView() {
@@ -514,6 +501,9 @@ function invalidateDashboardCache() {
cachedActuators = null; cachedActuators = null;
cachedEntities = null; cachedEntities = null;
cachedDiscovery = null; cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedDetailHtml.clear();
} }
async function api(path, options = {}) { async function api(path, options = {}) {
@@ -654,13 +644,24 @@ function optionGroups(entities, selectedIds = new Set()) {
} }
async function loadOverview() { async function loadOverview() {
if (overviewLoadPromise) return overviewLoadPromise;
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
return overviewLoadPromise;
}
async function doLoadOverview() {
const startedAt = performance.now(); const startedAt = performance.now();
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Startdaten laden ..."; if (budget) budget.textContent = "Startdaten laden ...";
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>"; if (!cachedActuators) {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
}
try { try {
const dashboard = await api("v1/actuators/dashboard/start"); const dashboard = await api("v1/actuators/dashboard/start");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedDashboardOverview = dashboard;
cachedActuators = dashboard.actuators || []; cachedActuators = dashboard.actuators || [];
cachedEntities = []; cachedEntities = [];
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
@@ -685,12 +686,21 @@ async function loadOverview() {
} }
async function loadSystemOverview() { async function loadSystemOverview() {
if (systemLoadPromise) return systemLoadPromise;
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
return systemLoadPromise;
}
async function doLoadSystemOverview() {
const startedAt = performance.now(); const startedAt = performance.now();
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ..."; if (budget) budget.textContent = "Systemübersicht lädt ...";
try { try {
const dashboard = await api("v1/actuators/dashboard/system"); const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedSystemOverview = dashboard;
cachedActuators = dashboard.actuators || cachedActuators; cachedActuators = dashboard.actuators || cachedActuators;
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
if (budget) { if (budget) {
@@ -706,6 +716,22 @@ async function loadSystemOverview() {
} }
} }
function refreshOverviewInBackground() {
if (!overviewLoadPromise) {
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
}
}
function refreshSystemOverviewInBackground() {
if (!systemLoadPromise) {
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
}
}
function scheduleDashboardExtras() { function scheduleDashboardExtras() {
const run = () => { const run = () => {
void loadDashboardExtras(); void loadDashboardExtras();
@@ -1011,6 +1037,20 @@ function selectActuatorFromList() {
if (value) document.getElementById("actuator-input").value = value; if (value) document.getElementById("actuator-input").value = value;
} }
function selectDetailActuator() {
const value = document.getElementById("detail-actuator-select")?.value;
if (value) void showActuator(value);
}
function detailActuatorOptions(selectedId) {
const rows = cachedActuators || [];
return rows.map(record => `
<option value="${escapeHtml(record.actuator_entity_id)}" ${record.actuator_entity_id === selectedId ? "selected" : ""}>
${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}
</option>
`).join("");
}
function renderManualContextSelect() { function renderManualContextSelect() {
const select = document.getElementById("manual-context-select"); const select = document.getElementById("manual-context-select");
if (!select) return; if (!select) return;
@@ -1131,10 +1171,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
for (const section of document.querySelectorAll(".app-view")) { for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === "detail"); section.classList.toggle("active", section.id === "detail");
} }
document.getElementById("section-jump").value = "detail"; document.getElementById("section-jump").value = "observed";
localStorage.setItem("sillyhome.ui.view", "detail"); localStorage.setItem("sillyhome.ui.view", "detail");
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
renderActuatorDetailShell(actuatorId); if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
return;
}
if (cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
} else {
renderActuatorDetailShell(actuatorId);
}
try { try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`); const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
contextOptions = []; contextOptions = [];
@@ -1356,7 +1406,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const manualAssignment = ` const manualAssignment = `
<details class="manual-context"> <details class="manual-context">
<summary>Kontext selbst festlegen</summary> <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>
<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">
<option value="">Keinen numerischen Hauptsensor verwenden</option> <option value="">Keinen numerischen Hauptsensor verwenden</option>
@@ -1403,13 +1452,21 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button> <button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
</li>`).join("")}</ul>` </li>`).join("")}</ul>`
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>"; : "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
box.innerHTML = ` const detailHtml = `
<div class="detail-header"> <div class="detail-header">
<div> <div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3> <h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
</div> </div>
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button> </div>
<div class="detail-tools">
<button class="secondary" onclick="showView('observed')">Zurück zur Übersicht</button>
<div>
<label for="detail-actuator-select">Anderes Gerät</label>
<select id="detail-actuator-select" onchange="selectDetailActuator()">
${detailActuatorOptions(record.actuator_entity_id)}
</select>
</div>
<button class="secondary" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Aktualisieren</button>
</div> </div>
<div class="grid-two"> <div class="grid-two">
<div> <div>
@@ -1417,7 +1474,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p> <p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p> <p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p> <p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p> <p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div> </div>
<div> <div>
@@ -1427,11 +1483,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p> <p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p> <p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p> <p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p> <p><strong>Statusgrund:</strong> ${escapeHtml(record.behavior.reason)}</p>
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p> <p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
<div class="actions">${activationButton}</div> <div class="actions">${activationButton}</div>
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button> <button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""} ${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
</div> </div>
</div> </div>
@@ -1448,20 +1503,21 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${adaptivePanel} ${adaptivePanel}
${anomalyPanel} ${anomalyPanel}
<h3>Passende Home-Assistant-Automationen</h3> <h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button> <button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
${automationControls} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Sensor-Gewichtung</h3> <h3>Sensor-Gewichtung</h3>
<p class="muted">Automatische Relevanz kommt aus der Zuordnung. Die aktive Gewichtung kannst du korrigieren; Gruppen bündeln mehrere Sensoren/Zustände.</p>
${weightControls} ${weightControls}
${weightGroupControls} ${weightGroupControls}
<h3>Verwendete Sensoren/Zustände ändern</h3> <h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
@@ -1472,13 +1528,13 @@ function renderActuatorDetailShell(actuatorId) {
<div class="detail-header"> <div class="detail-header">
<div> <div>
<h3>${escapeHtml(actuatorId)}</h3> <h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Basisdaten werden geladen ...</p> <p class="muted">Lädt ...</p>
</div> </div>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div> <div class="metric"><strong>Status</strong>...</div>
<div class="metric"><strong>Phase 2</strong>Lernstand</div> <div class="metric"><strong>Lernen</strong>...</div>
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div> <div class="metric"><strong>Kontext</strong>...</div>
</div> </div>
`; `;
} }
@@ -1768,8 +1824,11 @@ async function startDashboard() {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>"; document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>"; document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
syncSettingsView(); syncSettingsView();
document.getElementById("section-jump").value = "status-section"; const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
showView("status-section"); ? "observed"
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
document.getElementById("section-jump").value = initialView;
showView(initialView);
await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => requestAnimationFrame(resolve));
setTimeout(() => void loadStatus(), 100); setTimeout(() => void loadStatus(), 100);
} }

View File

@@ -0,0 +1,35 @@
# SillyHome Next v1.5.4 Operating Guide
Diese Version korrigiert Ingress-Logging und Dashboard-Navigation.
## Ingress-/Access-Logs
- Das Add-on startet Uvicorn ohne `--proxy-headers` und ohne
`--forwarded-allow-ips='*'`.
- Vorher konnte Uvicorn LAN-Adressen aus `X-Forwarded-For` anzeigen. Diese
Adresse war dann der urspruengliche Client oder Home-Assistant-Proxy, nicht
der direkte Container-Peer.
- Nach dem Update sollten Access-Logs den direkten Docker-/Ingress-Peer zeigen.
`GET ... HTTP/1.1` bleibt normal und ist kein Hinweis auf fehlendes Streaming.
## Dashboard-Verhalten
- Die Startseite nutzt weiter `/v1/actuators/dashboard/system`.
- Die Lernuebersicht nutzt weiter `/v1/actuators/dashboard/start`.
- Bereits geladene System-, Lern- und Discovery-Daten bleiben beim Wechseln der
Ansichten im Browser erhalten und werden nur im Hintergrund aufgefrischt.
- Details sind kein eigener Menuepunkt mehr. Sie werden nur ueber ein
ausgewaehltes beobachtetes Geraet geoeffnet.
- Ein bereits geoeffneter Aktor zeigt seine Detaildaten sofort aus dem
Browser-Cache. Neue Detaildaten werden erst ueber `Details aktualisieren`
oder nach einer Speichern-/Schaltaktion geladen.
## Pruefung
1. Add-on aktualisieren und neu starten.
2. Ingress hart neu laden.
3. Zwischen Startseite, Lernen und Discovery wechseln.
4. Erwartung: Bereits geladene Inhalte bleiben sichtbar; keine volle
Neuladung bei jedem Ansichtswechsel.
5. Details eines Aktors oeffnen, wegwechseln und wieder Details oeffnen.
Erwartung: Die zuletzt geladene Detailansicht steht sofort wieder da.

View File

@@ -0,0 +1,36 @@
# SillyHome Next v1.6.0 Operating Guide
v1.6.0 trennt Startansicht, Aktoruebersicht, Discovery und Detaildaten staerker.
## API-Pfade
- `GET /v1/actuators/dashboard/system`
- nur System- und Cache-Metadaten
- keine Aktorenliste
- kein vollstaendiges Entity-Payload aus SQLite
- `GET /v1/actuators/dashboard/start`
- Aktor-Summaries
- Entity-Metadaten nur fuer konfigurierte Aktoren
- keine Discovery-Gruppen und keine Jobliste
- `GET /v1/actuators/discovery`
- steuerbare HA-Entities
- nutzt SQLite-Cache, liest HA nur bei Cache-Miss oder `refresh=true`
- `GET /v1/actuators/{id}/detail`
- genau ein ausgewaehlter Aktor
- kompakte Modell-/Kontextdaten
## Dashboard
- Frontend zeigt Daten an und loest gezielte Aktionen aus.
- Backend liefert schlanke View-Daten.
- Worker aktualisieren HA-Entity-/Discovery-Cache beim Start und danach
stündlich.
- Die Detailansicht gehoert zu einem Aktor und hat eigene Navigation:
Zurueck, anderes Geraet, Aktualisieren.
## Erwartete Wirkung
- Systemstart muss ohne Entity-Materialisierung reagieren.
- Lernen und Details laden nur ihren eigenen Datenkern.
- Discovery bleibt ein eigener Bedarfspfad.
- Texte im Dashboard sind kurz und handlungsnah.

View File

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

@@ -1,18 +1,19 @@
from __future__ import annotations from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from time import perf_counter
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.actuators.cache_db import DashboardCache from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import JobStatus, ModelSnapshot from app.actuators.models import JobStatus, ModelSnapshot
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.config import Settings
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import ( from app.ha.history import (
@@ -467,6 +468,30 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
assert anomalies_response.json() assert anomalies_response.json()
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> 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"})
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
raise AssertionError("full entity payload must not be loaded")
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
system_response = client.get("/v1/actuators/dashboard/system")
start_response = client.get("/v1/actuators/dashboard/start")
assert system_response.status_code == 200
assert system_response.json()["actuators"] == []
assert system_response.json()["cache"]["entity_count"] == 4
assert start_response.status_code == 200
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None: def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)

View File

@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml") config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main") repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone assert config_copy < repository_clone
def test_addon_does_not_trust_forwarded_lan_ips() -> None:
run_script = Path("addon/run.sh").read_text(encoding="utf-8")
assert "--proxy-headers" not in run_script
assert "--forwarded-allow-ips" not in run_script

View File

@@ -9,17 +9,18 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
assert "So gehst du vor" in response.text assert "So gehst du vor" not in response.text
assert "Steuerung" in response.text assert "Discovery & Einrichtung" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text assert "Entity-ID" in response.text
assert "Oder aus Liste wählen" in response.text assert "Geräteliste" in response.text
assert "Liste durchsuchen" in response.text assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text assert "Vorschläge können Home Assistant stark abfragen" not in response.text
assert "Wie gewohnt bedienen" in response.text assert '<option value="detail">Details</option>' not in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Wie gewohnt bedienen" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
assert "Freigabestatus" in response.text assert "Freigabestatus" in response.text
assert "SillyHome übernehmen lassen" in response.text assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text assert "Passende Home-Assistant-Automationen" in response.text
@@ -28,10 +29,12 @@ def test_dashboard_is_served_at_root() -> None:
assert "Aktuelle Situation auswerten" in response.text assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text assert "Entity-IDs manuell ergänzen" in response.text
assert "Zurück zur Übersicht" in response.text
assert "Anderes Gerät" in response.text
assert "manual-context-freeform" in response.text assert "manual-context-freeform" in response.text
assert "Diese Kontext-Auswahl speichern" in response.text assert "Diese Kontext-Auswahl speichern" in response.text
assert "manual-context-select" in response.text assert "manual-context-select" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" not in response.text
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
@@ -40,7 +43,9 @@ def test_dashboard_is_served_at_root() -> None:
assert 'api("v1/actuators")' not in response.text assert 'api("v1/actuators")' not in response.text
assert 'api("v1/actuators/summary")' in response.text assert 'api("v1/actuators/summary")' in response.text
assert 'api("v1/entities")' not in response.text assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' in response.text assert 'details class="collapsible"' not in response.text
assert 'class="group-panel"' in response.text assert 'class="group-panel"' in response.text
assert "cachedDetailHtml" in response.text
assert "refreshOverviewInBackground" 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