Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 214b384b70 | |||
| 6323b93f23 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ __pycache__/
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
/.actuator_store/
|
||||
/MagicMock/
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
||||
# 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
|
||||
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
||||
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "1.5.3"
|
||||
version: "1.6.0"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
35
docs/V1_5_4_OPERATING_GUIDE.md
Normal file
35
docs/V1_5_4_OPERATING_GUIDE.md
Normal 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.
|
||||
36
docs/V1_6_0_OPERATING_GUIDE.md
Normal file
36
docs/V1_6_0_OPERATING_GUIDE.md
Normal 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.
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "1.5.3"
|
||||
version = "1.6.0"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user