Compare commits

...

3 Commits

Author SHA1 Message Date
d9dc186f9b Fix HA websocket keepalive regression
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 07:48:46 +02:00
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
15 changed files with 352 additions and 102 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,32 @@
# Changelog
## 1.6.1 - 2026-06-18
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
## 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
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "1.5.3"
version: "1.6.1"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
fi
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 \
--proxy-headers --forwarded-allow-ips='*'
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000

View File

@@ -33,6 +33,38 @@ class DashboardCache:
"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(
self,
*,

View File

@@ -326,13 +326,10 @@ def _dashboard_overview(
include_background: bool,
include_actuators: bool,
) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
cache_status = _load_entity_cache_status(request)
raw_updated_at = cache_status.get("updated_at")
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 = [
DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups
@@ -350,6 +347,9 @@ def _dashboard_overview(
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
anomaly_count = sum(record.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(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -373,9 +373,9 @@ def _dashboard_overview(
critical_anomaly_count=critical_anomaly_count,
),
cache=EntityCacheStatus(
available=bool(raw_entities),
available=bool(entity_count),
updated_at=updated_at,
entity_count=len(raw_entities),
entity_count=entity_count,
),
actuators=actuators,
discovery_groups=cached_groups,
@@ -846,6 +846,11 @@ def _load_cached_entity_map(
) -> dict[str, HaEntitySummary]:
if not entity_ids:
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)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
@@ -864,6 +869,29 @@ def _load_cached_entity_map(
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]:
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):

View File

@@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.5.3",
version="1.6.1",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -259,11 +259,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(
ws_url,
ping_interval=30,
ping_timeout=30,
) as websocket:
async with websockets.connect(ws_url, ping_interval=None) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required":

View File

@@ -52,6 +52,8 @@
.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 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,
.manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
@@ -144,20 +146,17 @@
<div class="brand">
<div class="brand-row">
<span class="brand-mark">SH</span>
<h1>SillyHome Next</h1>
<h1>SillyHome</h1>
</div>
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
<p>Geräte, Lernen, Freigaben und Systemzustand.</p>
</div>
<div class="header-actions">
<label for="section-jump">Menü</label>
<select id="section-jump" onchange="showView(this.value)">
<option value="status-section">Startseite / System</option>
<option value="observed">Lernen</option>
<option value="detail">Details</option>
<option value="choose">Discovery & Einrichtung</option>
<option value="settings">Einstellungen</option>
<option value="guide">Ablauf</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
@@ -166,11 +165,9 @@
<main>
<section class="control-panel app-view" id="choose">
<div class="panel-title">
<h2>Steuerung</h2>
<span class="chip">v1</span>
<h2>Discovery & Einrichtung</h2>
</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">Entitätsname oder Gerät aus Home Assistant</label>
<label for="actuator-input">Entity-ID</label>
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
<datalist id="actuator-options"></datalist>
<div class="inline-controls">
@@ -200,16 +197,15 @@
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
</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()">
<option value="">Geräteliste bei Bedarf laden</option>
</select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</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">
<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>
</details>
<div id="actuator-suggestions" class="card-list"></div>
@@ -219,7 +215,6 @@
<div class="panel-title">
<div>
<h2>Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
</div>
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
</div>
@@ -227,8 +222,10 @@
</section>
<section class="detail-panel app-view" id="detail">
<h2>Lernfortschritt und Freigabe</h2>
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
<div class="panel-title">
<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>
</section>
@@ -236,7 +233,6 @@
<div class="panel-title">
<div>
<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>
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
</div>
@@ -260,40 +256,9 @@
<option value="de">Deutsch</option>
<option value="en">English</option>
</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>
</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>
<script>
const escapeHtml = value => String(value ?? "")
@@ -309,7 +274,12 @@ let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null;
let overviewLoadPromise = null;
let systemLoadPromise = null;
let currentSensorWeightGroups = [];
let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50;
@@ -465,14 +435,29 @@ function jumpToSection(target) {
}
function showView(viewId) {
if (viewId === "detail" && !currentActuatorId) {
viewId = "observed";
}
for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === viewId);
}
localStorage.setItem("sillyhome.ui.view", viewId);
if (viewId === "status-section") {
void loadSystemOverview();
if (cachedSystemOverview) {
renderDashboardStatus(cachedSystemOverview);
refreshSystemOverviewInBackground();
} else {
void loadSystemOverview();
}
} else if (viewId === "observed") {
void loadOverview();
if (cachedActuators) {
renderConfiguredActuators();
refreshOverviewInBackground();
} else {
void loadOverview();
}
} else if (viewId === "choose") {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") {
syncSettingsView();
}
@@ -484,8 +469,10 @@ function setLanguage(language) {
localStorage.setItem("sillyhome.ui.language", uiLang);
syncSettingsView();
if (cachedActuators) renderConfiguredActuators();
void loadSystemOverview();
if (currentActuatorId) void showActuator(currentActuatorId);
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
}
}
function syncSettingsView() {
@@ -514,6 +501,9 @@ function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedDetailHtml.clear();
}
async function api(path, options = {}) {
@@ -654,13 +644,24 @@ function optionGroups(entities, selectedIds = new Set()) {
}
async function loadOverview() {
if (overviewLoadPromise) return overviewLoadPromise;
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
return overviewLoadPromise;
}
async function doLoadOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
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 {
const dashboard = await api("v1/actuators/dashboard/start");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedDashboardOverview = dashboard;
cachedActuators = dashboard.actuators || [];
cachedEntities = [];
renderDashboardStatus(dashboard);
@@ -685,12 +686,21 @@ async function loadOverview() {
}
async function loadSystemOverview() {
if (systemLoadPromise) return systemLoadPromise;
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
return systemLoadPromise;
}
async function doLoadSystemOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ...";
try {
const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedSystemOverview = dashboard;
cachedActuators = dashboard.actuators || cachedActuators;
renderDashboardStatus(dashboard);
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() {
const run = () => {
void loadDashboardExtras();
@@ -1011,6 +1037,20 @@ function selectActuatorFromList() {
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() {
const select = document.getElementById("manual-context-select");
if (!select) return;
@@ -1131,10 +1171,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
for (const section of document.querySelectorAll(".app-view")) {
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");
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 {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
contextOptions = [];
@@ -1356,7 +1406,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const manualAssignment = `
<details class="manual-context">
<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>
<select id="manual-numeric-select">
<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>
</li>`).join("")}</ul>`
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
box.innerHTML = `
const detailHtml = `
<div class="detail-header">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
</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 class="grid-two">
<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>Kontextzuordnung:</strong> automatisch erledigt</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>
</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>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</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>
<div class="actions">${activationButton}</div>
<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>` : ""}
</div>
</div>
@@ -1448,20 +1503,21 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${adaptivePanel}
${anomalyPanel}
<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>
${automationControls}
<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>"}
<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}
${weightGroupControls}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
`;
box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) {
box.textContent = error.message;
}
@@ -1472,13 +1528,13 @@ function renderActuatorDetailShell(actuatorId) {
<div class="detail-header">
<div>
<h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Basisdaten werden geladen ...</p>
<p class="muted">Lädt ...</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
<div class="metric"><strong>Status</strong>...</div>
<div class="metric"><strong>Lernen</strong>...</div>
<div class="metric"><strong>Kontext</strong>...</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("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
syncSettingsView();
document.getElementById("section-jump").value = "status-section";
showView("status-section");
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
? "observed"
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
document.getElementById("section-jump").value = initialView;
showView(initialView);
await new Promise(resolve => requestAnimationFrame(resolve));
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]
name = "sillyhome-next"
version = "1.5.3"
version = "1.6.1"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
dependencies = [

View File

@@ -1,18 +1,19 @@
from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from time import perf_counter
import pytest
from fastapi.testclient import TestClient
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
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 discover_entities
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()
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:
with TestClient(app) as client:
_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")
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
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 "SillyHome Next" in response.text
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
assert "So gehst du vor" in response.text
assert "Steuerung" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Oder aus Liste wählen" in response.text
assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
assert "So gehst du vor" not in response.text
assert "Discovery & Einrichtung" in response.text
assert "Entity-ID" in response.text
assert "Geräteliste" in response.text
assert "Liste durchsuchen" 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 "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
assert "Vorschläge können Home Assistant stark abfragen" not in response.text
assert '<option value="detail">Details</option>' not in response.text
assert "Wie gewohnt bedienen" not 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 "SillyHome übernehmen lassen" 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 "Kontext selbst festlegen" 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 "Diese Kontext-Auswahl speichern" 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 "Vorhersage jetzt prüfen" not 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/summary")' 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 "cachedDetailHtml" in response.text
assert "refreshOverviewInBackground" in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text

View File

@@ -94,8 +94,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=30,
ping_timeout=30,
ping_interval=None,
)
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},