diff --git a/README.md b/README.md index 9092b2f..c740fbe 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md) - Version 1.3.0 Anomalie- und Performance-Überwachung: [`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md) +- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf: + [`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) ## Reifegrad diff --git a/addon/config.yaml b/addon/config.yaml index 03c01f1..b6baaa2 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.3.0" +version: "1.4.0" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 5c556cb..231ab8f 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -306,6 +306,15 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]: @router.get("/dashboard", response_model=DashboardOverview) def dashboard_overview(request: Request) -> DashboardOverview: + return _dashboard_overview(request, include_background=True) + + +@router.get("/dashboard/start", response_model=DashboardOverview) +def dashboard_start(request: Request) -> DashboardOverview: + return _dashboard_overview(request, include_background=False) + + +def _dashboard_overview(request: Request, *, include_background: bool) -> DashboardOverview: cache_payload = _load_entity_cache_payload(request) raw_entities = cache_payload.get("entities", []) if not isinstance(raw_entities, list): @@ -317,14 +326,14 @@ def dashboard_overview(request: Request) -> DashboardOverview: DashboardDiscoveryGroup.model_validate(group) for group in raw_groups if isinstance(group, dict) - ] if isinstance(raw_groups, list) else [] + ] if include_background and isinstance(raw_groups, list) else [] reconciliation = _reconciliation_state_or_default(request) ws_status = getattr(request.app.state, "ws_status", None) actuators = list_configured_summary(request) store = getattr(request.app.state, "actuator_store", None) jobs = ( store.load_job_queue() - if isinstance(store, ActuatorStore) + if include_background and isinstance(store, ActuatorStore) else JobQueueState() ) job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs) diff --git a/app/main.py b/app/main.py index 30970b9..798393d 100644 --- a/app/main.py +++ b/app/main.py @@ -105,7 +105,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.3.0", + version="1.4.0", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 6b28fc1..ff97ad8 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -283,15 +283,102 @@ let cachedEntities = null; let cachedDiscovery = null; let discoveryLoadPromise = null; let currentSensorWeightGroups = []; +let visibleActuatorLimit = 24; const ACTUATOR_RESULT_LIMIT = 50; const STATUS_TIMEOUT_MS = 2000; const DASHBOARD_TIMEOUT_MS = 3000; +const I18N = { + de: { + safety_stage: { + observe: "Nur beobachten", + suggest: "Vorschläge anzeigen", + shadow: "Prüfmodus ohne Schalten", + partial: "Teilfreigabe", + active: "Aktiv freigegeben", + }, + behavior_mode: { + shadow: "Prüfmodus", + active: "Aktiv", + paused: "Pausiert", + }, + behavior_status: { + collecting: "Sammelt Lernbeispiele", + trained: "Gelernt", + blocked: "Blockiert", + }, + lifecycle_status: { + trained: "gelernt", + pending_history: "sammelt Historie", + pending_assignment: "sucht Kontext", + review_required: "bitte prüfen", + archived: "wartet", + orphaned: "Aktor fehlt", + stale: "Training veraltet", + invalid: "ungültig", + }, + job_status: { + pending: "wartet", + running: "läuft", + completed: "abgeschlossen", + failed: "fehlgeschlagen", + }, + job_kind: { + discovery: "Geräte-Erkennung", + reconciliation: "Abgleich", + training: "Training", + evaluation: "Auswertung", + automation_refresh: "Automation-Prüfung", + }, + severity: { + info: "Hinweis", + warning: "Warnung", + critical: "Kritisch", + }, + anomaly_category: { + missing_context: "fehlender Kontext", + low_samples: "zu wenig Lernbeispiele", + unclear_sources: "unklare Quellen", + stale_training: "veraltetes Training", + low_confidence_prediction: "geringe Sicherheit", + manual_block: "manuelle Sperre", + safety_blockers: "Sicherheitsblocker", + automation_conflict: "Automation-Konflikt", + feedback_error_rate: "Feedback-Fehlerquote", + }, + performance_status: { + ok: "schnell", + slow: "zu langsam", + running: "läuft", + unknown: "noch offen", + }, + connection_status: { + connected: "verbunden", + disconnected: "getrennt", + unavailable: "nicht verfügbar", + error: "Fehler", + }, + }, +}; +const uiLang = "de"; function jumpToSection(target) { if (!target) return; document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"}); } +function translate(group, value, fallback = "") { + if (value == null || value === "") return fallback || "offen"; + return I18N[uiLang]?.[group]?.[value] || fallback || String(value); +} + +function formatDateTime(value) { + if (!value) return "noch offen"; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) + ? String(value) + : parsed.toLocaleString("de-DE"); +} + function uniqueValues(values) { return [...new Set(values.filter(Boolean))]; } @@ -324,12 +411,12 @@ function lifecycleLabel(record) { const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; if (behaviorStatus === "trained") return "Kontext erkannt"; const labels = { - trained: "lernt", - pending_history: "sammelt Historie", - pending_assignment: "sucht Kontext", - review_required: "geringe Zuordnungssicherheit", - archived: "wartet auf Kontext", - orphaned: "Aktor nicht gefunden", + trained: translate("lifecycle_status", "trained"), + pending_history: translate("lifecycle_status", "pending_history"), + pending_assignment: translate("lifecycle_status", "pending_assignment"), + review_required: translate("lifecycle_status", "review_required"), + archived: translate("lifecycle_status", "archived"), + orphaned: translate("lifecycle_status", "orphaned"), }; return labels[lifecycleStatus] || lifecycleStatus; } @@ -347,7 +434,7 @@ function behaviorLabel(record) { const mode = record.behavior_mode || record.behavior?.mode; const status = record.behavior_status || record.behavior?.status; if (mode === "active") return "aktiv freigegeben"; - if (status === "trained") return "Shadow-Vorhersage"; + if (status === "trained") return "Prüfmodus mit Vorhersage"; if (status === "blocked") return "Lernen blockiert"; return "sammelt Handlungen"; } @@ -440,7 +527,7 @@ async function loadOverview() { if (budget) budget.textContent = "Startdaten laden ..."; document.getElementById("configured-actuators").innerHTML = "

Beobachtete Geräte werden geladen ...

"; try { - const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS); + const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); cachedActuators = dashboard.actuators || []; cachedEntities = []; @@ -462,6 +549,38 @@ async function loadOverview() { document.getElementById("configured-actuators").innerHTML = "
Startdaten sind gerade nicht verfügbar.
"; } } + scheduleDashboardExtras(); +} + +function scheduleDashboardExtras() { + const run = () => { + void loadDashboardExtras(); + }; + if ("requestIdleCallback" in window) { + window.requestIdleCallback(run, {timeout: 1800}); + } else { + setTimeout(run, 250); + } +} + +async function loadDashboardExtras() { + try { + const [jobs, reconciliation] = await Promise.allSettled([ + apiWithTimeout("v1/actuators/job-queue/state", STATUS_TIMEOUT_MS), + apiWithTimeout("v1/actuators/reconciliation/state", STATUS_TIMEOUT_MS), + ]); + if (jobs.status === "fulfilled") { + renderJobQueue(jobs.value.jobs || []); + } + if (reconciliation.status === "fulfilled") { + const text = document.getElementById("reconciliation-status"); + if (text) { + text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`; + } + } + } catch (_) { + // Die Startansicht bleibt auch ohne Hintergrunddaten bedienbar. + } } async function loadStatus() { @@ -482,10 +601,10 @@ async function loadStatus() { const hasError = values.some(value => value === null); status.innerHTML = hasError ? "

Status teilweise verfügbar. Das Dashboard bleibt bedienbar.

" - : `

System bereit

Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}

`; + : `

System bereit

Letzte automatische Prüfung: ${escapeHtml(formatDateTime(reconciliationValue.last_completed_at))}

`; chips.innerHTML = [ `API: ${escapeHtml(healthValue?.status || "offen")}`, - `WebSocket: ${escapeHtml(websocketValue?.status || "offen")}`, + `WebSocket: ${escapeHtml(translate("connection_status", websocketValue?.status, websocketValue?.status || "offen"))}`, `Lernsystem: ${escapeHtml(mlValue?.status || "offen")}`, `Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}`, ].join(""); @@ -499,7 +618,6 @@ function renderDashboardStatus(dashboard) { const status = document.getElementById("status"); const chips = document.getElementById("status-chips"); const stats = document.getElementById("dashboard-stats"); - const jobsBox = document.getElementById("job-queue"); const system = dashboard.system || {}; const cache = dashboard.cache || {}; const actuators = dashboard.actuators || []; @@ -531,8 +649,8 @@ function renderDashboardStatus(dashboard) {

Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.

-

WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}

-

Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}

+

WebSocket: ${escapeHtml(translate("connection_status", system.websocket_status, system.websocket_status || "unbekannt"))}

+

Letzte automatische Prüfung: ${escapeHtml(formatDateTime(system.reconciliation_last_completed_at))}

`; chips.innerHTML = [ `API: ${escapeHtml(system.api_status || "ok")}`, @@ -547,7 +665,7 @@ function renderDashboardStatus(dashboard) { stats.innerHTML = [ `
Geladene Startdaten${escapeHtml(actuators.length)} Geräte
`, `
Freigabebereit${escapeHtml(readyCount)} Geräte
`, - `
Aktiv / Shadow${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}
`, + `
Aktiv / Prüfmodus${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}
`, `
Gelernt / Wartet${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}
`, `
Gelernte Handlungen${escapeHtml(sampleTotal)}
`, `
Performance-Budget${escapeHtml(system.performance_budget_ms || 3000)} ms
`, @@ -557,18 +675,24 @@ function renderDashboardStatus(dashboard) { `
Discovery-Gruppen${escapeHtml(discoveryGroups.length)} Kategorien
`, `
Cache-Zeitpunkt${escapeHtml(cache.updated_at || "noch offen")}
`, ].join(""); + renderJobQueue(jobs); +} + +function renderJobQueue(jobs) { + const jobsBox = document.getElementById("job-queue"); + if (!jobsBox) return; jobsBox.innerHTML = jobs.length ? ` -

Job-Queue

+

Aufgabenliste

${jobs.slice(-6).reverse().map(job => `
- ${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""} - ${escapeHtml(job.status)}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""} + ${escapeHtml(translate("job_kind", job.kind, job.kind))}${job.target ? `: ${escapeHtml(job.target)}` : ""} + ${escapeHtml(translate("job_status", job.status, job.status))}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}

${escapeHtml(job.summary || "Keine Zusammenfassung")}

-

Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}

+

Start: ${escapeHtml(formatDateTime(job.started_at))} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}

${job.error ? `

${escapeHtml(job.error)}

` : ""} - ${job.status === "failed" ? "

Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.

" : ""} + ${job.status === "failed" ? "

Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.

" : ""}
`).join("")} ` : ""; @@ -796,13 +920,17 @@ function renderConfiguredActuators() { const box = document.getElementById("configured-actuators"); try { const rows = cachedActuators || []; + const visibleRows = rows.slice(0, visibleActuatorLimit); const groups = new Map(); - for (const record of rows) { + for (const record of visibleRows) { const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]); if (!groups.has(group)) groups.set(group, []); groups.get(group).push({record}); } const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); + const moreButton = rows.length > visibleRows.length + ? `` + : ""; box.innerHTML = rows.length ? ` ${groupedRows.map(([group, items]) => `
@@ -836,7 +964,9 @@ function renderConfiguredActuators() { `).join("")}
- `).join("")}` : "

Noch keine Aktoren ausgewählt.

"; + `).join("")} + ${moreButton} + ` : "

Noch keine Aktoren ausgewählt.

"; } catch (error) { box.textContent = error.message; } @@ -942,7 +1072,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { @@ -1001,7 +1131,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${timeProfiles.length ? timeProfiles.map(profile => `
${escapeHtml(profile.label)} - ${escapeHtml(profile.sample_count)} Samples · ${escapeHtml(profile.dominant_state || "offen")} + ${escapeHtml(profile.sample_count)} Beispiele · ${escapeHtml(profile.dominant_state || "offen")}

${Math.round((profile.confidence || 0) * 100)} % Profilklarheit

`).join("") : "
ZeitprofileNoch keine Daten
"} @@ -1014,7 +1144,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${escapeHtml(snapshot.version_id)} ${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"} -

${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %

+

${escapeHtml(snapshot.sample_count)} Beispiele · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %

${escapeHtml(snapshot.reason || "Kein Kommentar")}

${snapshot.version_id !== activeModelVersion ? `` : ""} @@ -1026,7 +1156,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { `).join("") : "
  • Noch keine automatische Gewichtsanpassung.
  • "}

    Automation-Konflikte

    `; @@ -1038,10 +1168,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
    ${escapeHtml(anomaly.title)} - ${escapeHtml(anomaly.severity)} · ${escapeHtml(anomaly.category)} + ${escapeHtml(translate("severity", anomaly.severity))} · ${escapeHtml(translate("anomaly_category", anomaly.category, anomaly.category))}

    ${escapeHtml(anomaly.detail)}

    -

    Erkannt: ${escapeHtml(anomaly.detected_at || "offen")}

    +

    Erkannt: ${escapeHtml(formatDateTime(anomaly.detected_at))}

    `).join("") : "

    Keine offenen Anomalien fuer diesen Aktor.

    "} @@ -1139,7 +1269,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {

    Gelernte Handlungen: ${record.behavior.sample_count}

    Davon eindeutig geregelt: ${record.behavior.high_confidence_sample_count}

    Davon erkannte HA-Automationen: ${learnedAutomationActions}

    -

    Letztes Training: ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}

    +

    Letztes Training: ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}

    Was noch passiert: ${escapeHtml(record.behavior.reason)}

    Freigabestatus: ${escapeHtml(record.behavior.activation_reason)}

    ${activationButton}
    @@ -1373,7 +1503,7 @@ async function saveSafetyProfile(actuatorId) { cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null, rules: [ {rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."}, - {rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus."}, + {rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Prüfmodus."}, {rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."}, {rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."}, ], @@ -1479,11 +1609,11 @@ async function removeActuator(actuatorId) { async function startDashboard() { document.getElementById("status").innerHTML = "

    Status lädt nach ...

    "; - document.getElementById("configured-actuators").innerHTML = "
    Geräte werden nach dem Status geladen.
    "; + document.getElementById("configured-actuators").innerHTML = "
    Geräte werden geladen.
    "; document.getElementById("actuator-detail").innerHTML = "
    Wähle später ein Gerät aus der Übersicht.
    "; await new Promise(resolve => requestAnimationFrame(resolve)); - await loadStatus(); await loadOverview(); + void loadStatus(); } void startDashboard(); diff --git a/docs/V1_4_0_OPERATING_GUIDE.md b/docs/V1_4_0_OPERATING_GUIDE.md new file mode 100644 index 0000000..5a9f373 --- /dev/null +++ b/docs/V1_4_0_OPERATING_GUIDE.md @@ -0,0 +1,42 @@ +# SillyHome Next v1.4.0 Operating Guide + +v1.4.0 überarbeitet das Dashboard für mobile Nutzung, deutsche Verständlichkeit +und stabileren Datenabruf. + +## Schneller Startpfad + +- Die Startseite lädt zuerst nur die Bedienoberfläche und den kompakten + Dashboard-Startdatensatz. +- Neuer Start-Endpunkt: `GET /v1/actuators/dashboard/start`. +- Der Start-Endpunkt liefert keine Discovery-Gruppen und keine Aufgabenliste. +- Status, Aufgabenliste, Reconciliation-Zeitpunkt und Detail-Kontext werden + danach im Hintergrund geladen. +- Auf der Startansicht werden zunächst nur die ersten 24 Aktoren gerendert. + Weitere Geräte werden auf Knopfdruck nachgerendert. + +## Deutsche Oberfläche + +Interne Protokollwerte bleiben stabil, werden in der Oberfläche aber übersetzt: + +- `observe` -> `Nur beobachten` +- `suggest` -> `Vorschläge anzeigen` +- `shadow` -> `Prüfmodus ohne Schalten` +- `partial` -> `Teilfreigabe` +- `active` -> `Aktiv freigegeben` +- Job-Status wie `running`, `completed`, `failed` erscheinen als `läuft`, + `abgeschlossen`, `fehlgeschlagen`. +- Anomalie-Schweregrade erscheinen als `Hinweis`, `Warnung`, `Kritisch`. + +## Stabilität + +- Startdaten und Statusdaten sind getrennt. Ein langsamer Statuscheck blockiert + nicht mehr die Geräteübersicht. +- Die Aufgabenliste wird separat geladen und kann ausfallen, ohne die + Bedienoberfläche zu blockieren. +- Detaildaten bleiben gestuft: zuerst Shell und gespeicherte Werte, danach + Kontextvorschläge. + +## Performance-Regel + +3 Sekunden bleiben die harte Grenze für den Startpfad. Alles, was schwerer ist +als Startdaten, muss nachgelagert oder auf Nutzeraktion geladen werden. diff --git a/pyproject.toml b/pyproject.toml index 6aba4e4..c177843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.3.0" +version = "1.4.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 5b42a01..51c3686 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -418,7 +418,7 @@ def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) - root_elapsed = perf_counter() - root_started_at dashboard_started_at = perf_counter() - dashboard_response = client.get("/v1/actuators/dashboard") + dashboard_response = client.get("/v1/actuators/dashboard/start") dashboard_elapsed = perf_counter() - dashboard_started_at assert root_response.status_code == 200 @@ -445,10 +445,15 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig") dashboard_response = client.get("/v1/actuators/dashboard") + start_response = client.get("/v1/actuators/dashboard/start") anomalies_response = client.get("/v1/actuators/anomalies") assert dashboard_response.status_code == 200 + assert start_response.status_code == 200 system = dashboard_response.json()["system"] + start_payload = start_response.json() + assert start_payload["jobs"]["jobs"] == [] + assert start_payload["discovery_groups"] == [] assert system["performance_budget_ms"] == 3000 assert system["slow_job_count"] == 1 assert system["performance_status"] == "slow"