Files
sillyhome-next/app/static/index.html
Otto 9ff005f089
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
Add room management settings
2026-07-26 22:44:56 +02:00

2648 lines
119 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background:#0e1218;
color:#f4f7fb;
scroll-behavior:smooth;
--accent:#ff8a1c;
--accent-strong:#ffb45b;
--complement:#1cc7ff;
--complement-soft:#123447;
--panel:#171d25;
--panel-soft:#202833;
--panel-quiet:#111720;
--border:#314050;
--text-soft:#aebdcc;
--ok:#53e0a5;
--warn:#f3c969;
--bad:#8fb8ff;
}
* { box-sizing:border-box; }
html, body { max-width:100%; overflow-x:hidden; }
body { margin:0; font-size:15px; background:var(--panel-quiet); }
h1,h2,h3 { margin:0 0 10px; letter-spacing:0; }
p { margin:6px 0; }
header { padding:18px 18px 14px; border-bottom:1px solid var(--border); background:#121821; }
.topbar { max-width:1480px; margin:0 auto; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
.brand { display:grid; gap:5px; min-width:0; }
.brand-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.brand-mark { width:34px; height:34px; border-radius:8px; display:grid; place-items:center; background:var(--accent); color:#201204; font-weight:900; }
header p { color:var(--text-soft); max-width:820px; }
.header-actions { display:grid; gap:8px; min-width:230px; }
.header-actions label { margin:0; font-size:.82rem; }
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); }
main { display:block; padding:12px; max-width:1480px; margin:0 auto; }
section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; }
.app-view { display:none; }
.app-view.active { display:block; }
section:target { outline:2px solid var(--complement); outline-offset:2px; }
.control-panel { grid-area:control; align-self:start; position:sticky; top:58px; }
.board-panel { grid-area:board; }
.detail-panel { grid-area:detail; }
.status-panel { grid-area:status; }
.guide-panel { grid-area:guide; }
.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; }
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
.manual-context > summary,
.group-panel > summary { display:flex; justify-content:space-between; gap:10px; align-items:center; }
details.collapsible > summary::-webkit-details-marker,
.manual-context > summary::-webkit-details-marker,
.group-panel > summary::-webkit-details-marker { display:none; }
details.collapsible > summary::after,
.manual-context > summary::after,
.group-panel > summary::after { content:attr(data-closed-label); color:#9fb0be; font-weight:600; font-size:.86rem; }
details[open].collapsible > summary::after,
.manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:attr(data-open-label); }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
.step { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:8px; background:var(--complement); color:#03151d; font-weight:900; margin-bottom:8px; }
.step p { margin:5px 0; }
.ok { color: #73e0a9; }
.warn { color: #f3c969; }
.bad { color: var(--bad); }
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
select,input,button { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:10px; background:#111821; color:#fff; font:inherit; min-width:0; }
input[type="checkbox"] { width:auto; min-width:0; vertical-align:middle; margin-right:8px; }
select[multiple] { min-height:150px; }
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; }
button.compact { width:auto; min-width:112px; margin-right:8px; padding:8px 10px; min-height:36px; }
table { width: 100%; border-collapse: collapse; table-layout:fixed; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; overflow-wrap:anywhere; word-break:break-word; }
ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left:4px solid var(--complement); padding-left:10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; max-width:100%; overflow-wrap:anywhere; word-break:break-word; }
.muted { color:var(--text-soft); }
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; }
.actuator-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:var(--complement); box-shadow:0 0 0 1px rgba(28,199,255,.35); }
.card-title { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; min-width:0; }
.card-title > div { min-width:0; flex:1 1 160px; overflow-wrap:anywhere; word-break:break-word; }
.card-title .chip { flex:0 1 auto; white-space:normal; text-align:center; }
.actuator-card strong { overflow-wrap:anywhere; word-break:break-word; }
.entity-id { overflow-wrap:anywhere; word-break:break-word; font-weight:800; }
.room-board { display:grid; gap:10px; margin-top:12px; }
.room-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.room-card header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.sensor-strip { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }
.sensor-pill { border:1px solid var(--border); border-radius:8px; padding:6px 8px; background:#17202b; max-width:100%; overflow-wrap:anywhere; }
.sensor-pill.active { border-color:var(--ok); }
.sensor-pill.optional { border-color:var(--complement); }
.sensor-pill.not-required { opacity:.72; border-style:dashed; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.decision-list { display:grid; gap:8px; margin:10px 0; }
.decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; }
.decision-row.slow,
.decision-row.critical { border-color:var(--warn); box-shadow:0 0 0 1px rgba(243,201,105,.25); }
.decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:12px; background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; }
textarea { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; }
.empty-state { min-height:140px; display:grid; place-items:center; text-align:center; border:1px dashed var(--border); border-radius:8px; background:#111821; color:var(--text-soft); padding:16px; }
@media (max-width: 760px) {
header { padding:16px 12px; }
header h1 { font-size:1.55rem; }
.topbar { display:grid; }
.header-actions { min-width:0; }
.status-pill { width:max-content; max-width:100%; white-space:normal; }
main { padding:8px; }
.control-panel { position:static; }
section { margin-bottom:10px; padding:10px; border-radius:8px; }
.steps { grid-template-columns:1fr; }
.grid-two { grid-template-columns:1fr; }
.metric-grid { grid-template-columns:1fr 1fr; }
.card-list { grid-template-columns:1fr; }
.actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
}
@media (max-width: 430px) {
.metric-grid { grid-template-columns:1fr; }
body { font-size:15px; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div class="brand">
<div class="brand-row">
<span class="brand-mark">SH</span>
<h1>SillyHome</h1>
</div>
<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="choose">Discovery & Einrichtung</option>
<option value="settings">Einstellungen</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
</div>
</header>
<main>
<section class="control-panel app-view" id="choose">
<div class="panel-title">
<h2>Discovery & Einrichtung</h2>
</div>
<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">
<div>
<label for="actuator-domain-filter">Typ</label>
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
<option value="">Alle steuerbaren Typen</option>
<option value="light">Lichter</option>
<option value="switch">Schalter / Helper</option>
<option value="button">Buttons</option>
<option value="input_button">Helper-Buttons</option>
<option value="input_boolean">Helper-Schalter</option>
<option value="cover">Rollläden / Cover</option>
<option value="climate">Heizungen / Klima</option>
<option value="lock">Schlösser</option>
<option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</option>
<option value="scene">Szenen</option>
<option value="number">Numerische Helper</option>
<option value="valve">Ventile</option>
</select>
</div>
<div>
<label for="actuator-search">Liste durchsuchen</label>
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
</div>
</div>
<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">Bereit.</p>
<details class="manual-context">
<summary>Vorschläge anzeigen</summary>
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
</details>
<div id="actuator-suggestions" class="card-list"></div>
</section>
<section class="board-panel app-view" id="observed">
<div class="panel-title">
<div>
<h2>Beobachtete Geräte</h2>
</div>
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
</div>
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section class="detail-panel app-view" id="detail">
<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>
<section class="status-panel app-view active" id="status-section">
<div class="panel-title">
<div>
<h2>System & Cache</h2>
</div>
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
</div>
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<div id="dashboard-stats" class="metric-grid"></div>
<div id="job-queue" class="decision-list"></div>
</section>
<section class="guide-panel app-view" id="settings">
<div class="panel-title">
<div>
<h2>Einstellungen</h2>
<p class="muted">Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.</p>
</div>
<button class="secondary compact" onclick="loadRoomManagement(true)">Räume laden</button>
</div>
<div class="grid-two">
<div>
<label for="language-select">Sprache</label>
<select id="language-select" onchange="setLanguage(this.value)">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<div>
<label for="room-management-filter">Raum suchen</label>
<input id="room-management-filter" placeholder="z. B. Abstellkammer, Wohnbereich" oninput="renderRoomManagement()">
</div>
</div>
<div id="room-management" class="room-board">
<div class="empty-state">Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.</div>
</div>
</section>
</main>
<script>
const escapeHtml = value => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedRoomManagement = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null;
let overviewLoadPromise = null;
let systemLoadPromise = null;
let currentSensorWeightGroups = [];
let latestSimulationResults = new Map();
let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 3000;
const I18N = {
de: {
ui: {
tagline: "Geräte, Lernen, Freigaben und Systemzustand.",
menu: "Menü",
nav_status: "Startseite / System",
nav_learning: "Lernen",
nav_discovery: "Discovery & Einrichtung",
nav_settings: "Einstellungen",
page_ready: "Seite bereit, Status folgt ...",
discovery_title: "Discovery & Einrichtung",
entity_id: "Entity-ID",
actuator_placeholder: "z. B. light.licht_abstellraum",
type: "Typ",
all_actuators: "Alle steuerbaren Typen",
lights: "Lichter",
switches: "Schalter / Helper",
buttons: "Buttons",
helper_buttons: "Helper-Buttons",
helper_switches: "Helper-Schalter",
covers: "Rollläden / Cover",
climate: "Heizungen / Klima",
locks: "Schlösser",
fans: "Lüftung / Ventilatoren",
humidifiers: "Befeuchter / Entfeuchter",
media: "TV / Medien",
remotes: "Fernbedienungen",
scenes: "Szenen",
numbers: "Numerische Helper",
valves: "Ventile",
search_list: "Liste durchsuchen",
search_placeholder: "Raum, Gerät oder Entity",
device_list: "Geräteliste",
device_list_lazy: "Geräteliste bei Bedarf laden",
add_device: "Gerät hinzufügen und Beobachtung starten",
load_device_list: "Geräteliste laden",
ready: "Bereit.",
show_suggestions: "Vorschläge anzeigen",
load_suggestions: "Vorschläge laden",
observed_devices: "Beobachtete Geräte",
refresh: "Aktualisieren",
details: "Details",
back: "Zurück",
detail_empty: "Öffne bei einem beobachteten Gerät die Details.",
system_cache: "System & Cache",
check_status: "Status prüfen",
checking: "Prüfung läuft ...",
settings: "Einstellungen",
settings_hint: "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.",
language: "Sprache",
load_rooms: "Räume laden",
room_search: "Raum suchen",
room_search_placeholder: "z. B. Abstellkammer, Wohnbereich",
room_management_empty: "Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.",
no_prediction: "Keine fällige Aktion",
open: "offen",
no_area: "Ohne Bereich",
loading_start: "Startdaten laden ...",
loading_devices: "Beobachtete Geräte werden geladen ...",
delayed_start: "Startdaten verzögert",
unavailable_start: "Startdaten sind gerade nicht verfügbar.",
system_loading: "Systemübersicht lädt ...",
system_delayed: "Systemübersicht verzögert",
expand: "aufklappen",
collapse: "zuklappen",
context_detected: "Kontext erkannt",
active_approved: "aktiv freigegeben",
shadow_prediction: "Prüfmodus mit Vorhersage",
learning_blocked: "Lernen blockiert",
collecting_actions: "sammelt Handlungen",
},
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",
},
},
en: {
ui: {
tagline: "Devices, learning, approvals, and system health.",
menu: "Menu",
nav_status: "Home / System",
nav_learning: "Learning",
nav_discovery: "Discovery & setup",
nav_settings: "Settings",
page_ready: "Page ready, status pending ...",
discovery_title: "Discovery & setup",
entity_id: "Entity ID",
actuator_placeholder: "e.g. light.storage_room",
type: "Type",
all_actuators: "All controllable types",
lights: "Lights",
switches: "Switches / helpers",
buttons: "Buttons",
helper_buttons: "Helper buttons",
helper_switches: "Helper switches",
covers: "Shutters / covers",
climate: "Heating / climate",
locks: "Locks",
fans: "Ventilation / fans",
humidifiers: "Humidifiers / dehumidifiers",
media: "TV / media",
remotes: "Remotes",
scenes: "Scenes",
numbers: "Numeric helpers",
valves: "Valves",
search_list: "Search list",
search_placeholder: "Room, device, or entity",
device_list: "Device list",
device_list_lazy: "Load device list when needed",
add_device: "Add device and start observing",
load_device_list: "Load device list",
ready: "Ready.",
show_suggestions: "Show suggestions",
load_suggestions: "Load suggestions",
observed_devices: "Observed devices",
refresh: "Refresh",
details: "Details",
back: "Back",
detail_empty: "Open details from an observed device.",
system_cache: "System & cache",
check_status: "Check status",
checking: "Checking ...",
settings: "Settings",
settings_hint: "Language, rooms, sensors, actuators, and predictions in one place.",
language: "Language",
load_rooms: "Load rooms",
room_search: "Search room",
room_search_placeholder: "e.g. storage room, living area",
room_management_empty: "Load room management to maintain sensors, actuators, and predictions together.",
no_prediction: "No due action",
open: "open",
no_area: "No area",
loading_start: "Loading start data ...",
loading_devices: "Loading observed devices ...",
delayed_start: "Start data delayed",
unavailable_start: "Start data is currently unavailable.",
system_loading: "Loading system overview ...",
system_delayed: "System overview delayed",
expand: "expand",
collapse: "collapse",
context_detected: "Context detected",
active_approved: "actively approved",
shadow_prediction: "Review mode with prediction",
learning_blocked: "Learning blocked",
collecting_actions: "collecting actions",
},
safety_stage: {
observe: "Observe only",
suggest: "Show suggestions",
shadow: "Review mode without switching",
partial: "Partial approval",
active: "Active approval",
},
behavior_mode: {
shadow: "Review mode",
active: "Active",
paused: "Paused",
},
behavior_status: {
collecting: "Collecting examples",
trained: "Learned",
blocked: "Blocked",
},
lifecycle_status: {
trained: "learned",
pending_history: "collecting history",
pending_assignment: "finding context",
review_required: "review required",
archived: "waiting",
orphaned: "actuator missing",
stale: "training stale",
invalid: "invalid",
},
job_status: {
pending: "waiting",
running: "running",
completed: "completed",
failed: "failed",
},
job_kind: {
discovery: "Discovery",
reconciliation: "Reconciliation",
training: "Training",
evaluation: "Evaluation",
automation_refresh: "Automation check",
},
severity: {
info: "Info",
warning: "Warning",
critical: "Critical",
},
anomaly_category: {
missing_context: "missing context",
low_samples: "not enough samples",
unclear_sources: "unclear sources",
stale_training: "stale training",
low_confidence_prediction: "low confidence",
manual_block: "manual block",
safety_blockers: "safety blockers",
automation_conflict: "automation conflict",
feedback_error_rate: "feedback error rate",
},
performance_status: {
ok: "fast",
slow: "too slow",
running: "running",
unknown: "unknown",
},
connection_status: {
connected: "connected",
disconnected: "disconnected",
unavailable: "unavailable",
error: "error",
},
},
};
const DE_TO_EN_TEXT = new Map(Object.entries({
"Aktiv / Prüfmodus": "Active / review mode",
"Aktive Gewichtung": "Active weight",
"Aktoren": "Actuators",
"Aktualisieren": "Refresh",
"Aktualisiert.": "Refreshed.",
"Aktuelle Blocker:": "Current blockers:",
"Aktuelle Situation auswerten": "Evaluate current situation",
"Aktuell ist kein gelerntes Handlungsmuster fällig.": "No learned action pattern is due right now.",
"Alle relevanten Vorschläge": "All relevant suggestions",
"Als Gruppe speichern": "Save as group",
"Anderes Gerät": "Another device",
"Annahmen": "Assumptions",
"Anomalien": "Anomalies",
"Anomalien offen": "anomalies open",
"Aktor-Simulation": "Actuator simulation",
"Aufgabenliste": "Task list",
"Automationen neu suchen": "Find automations again",
"Automatische Gewichtsanpassungen": "Automatic weight adjustments",
"Automatische Relevanz": "Automatic relevance",
"Bedienoberfläche.": "user interface.",
"Bereit.": "Ready.",
"Bestes Szenario": "Best scenario",
"Bestes Szenario berechnen": "Calculate best scenario",
"Betriebsart:": "Mode:",
"Beitragsfaktoren": "Contributing factors",
"Cache aktuell mit": "Cache current with",
"Cache wird nach Discovery aufgebaut": "Cache will be built after discovery",
"Cache-Zeitpunkt": "Cache time",
"Cooldown Sekunden": "Cooldown seconds",
"Dashboard bereit in": "Dashboard ready in",
"Dashboard bleibt bedienbar.": "The dashboard remains usable.",
"Dauer:": "Duration:",
"Davon eindeutig geregelt:": "Clearly regulated:",
"Details öffnen": "Open details",
"Diese Kontext-Auswahl speichern": "Save this context selection",
"Discovery-Gruppen": "Discovery groups",
"Entfernen": "Remove",
"Entscheidungsakte": "Decision record",
"Entity-IDs der Gruppe": "Group entity IDs",
"Entity-IDs manuell ergänzen": "Add entity IDs manually",
"Ergebnis:": "Result:",
"Erkannt:": "Detected:",
"Erkannte HA-Automationen:": "Detected HA automations:",
"Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.": "Try again: start the dashboard action again; the next run will create a new entry.",
"Freigabe": "Approval",
"Freigabebereit": "Ready for approval",
"Freigabestatus:": "Approval status:",
"Freigabestufe": "Approval stage",
"Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.": "Select context sensors before running a simulation.",
"Geladene Startdaten": "Loaded start data",
"Gelernte Handlungen": "Learned actions",
"Gelernt / Wartet": "Learned / waiting",
"Gerät auswählen": "Select device",
"Geräteliste konnte nicht geladen werden:": "Device list could not be loaded:",
"Geräteliste lädt im Hintergrund ...": "Device list loading in the background ...",
"Geräteliste wird geladen ...": "Device list is loading ...",
"Gewichtung": "Weight",
"Gewichtung korrigieren": "Adjust weight",
"Gewichtung übernehmen": "Apply weight",
"Gewichtung speichern": "Save weight",
"Gewichtungen speichern": "Save weights",
"Gruppen-Gewicht in %": "Group weight in %",
"Gruppen-Gewichtung": "Group weighting",
"Gruppenname": "Group name",
"HA-Automationen pausieren": "pause HA automations",
"HA-Automationen pausiert lassen": "leave HA automations paused",
"HA-Automationen fortsetzen": "resume HA automations",
"Handlungen": "Actions",
"Job p95": "Job p95",
"Jobs aktiv": "Active jobs",
"Kategorie": "Category",
"Kategorien": "categories",
"Keine Annahmen gespeichert.": "No assumptions stored.",
"Keine aktiven Automation-Konflikte erkannt.": "No active automation conflicts detected.",
"Keine eindeutig passende HA-Automation gefunden.": "No clearly matching HA automation found.",
"Keine gesicherten Punkte gespeichert.": "No confirmed points stored.",
"Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.": "No local safety blockers for the current prediction.",
"Keine offenen Anomalien fuer diesen Aktor.": "No open anomalies for this actuator.",
"Keine passenden Geräte gefunden": "No matching devices found",
"Keine passenden Vorschläge": "No matching suggestions",
"Keine Simulationsergebnisse.": "No simulation results.",
"Keine Unsicherheit gespeichert.": "No uncertainty stored.",
"Kein Kommentar": "No comment",
"Kein numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Keinen numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Kontext": "Context",
"Kontext selbst festlegen": "Set context manually",
"Kontext wird automatisch analysiert ...": "Context is being analyzed automatically ...",
"Kontext-Entity entfernt.": "Context entity removed.",
"Kontext-Entity ausgewählt.": "context entity selected.",
"Kontextzuordnung:": "Context assignment:",
"Kritisch": "Critical",
"Langsame Jobs": "Slow jobs",
"Langsam:": "Slow:",
"Lädt ...": "Loading ...",
"Lernbereit": "Ready to learn",
"Lernbereite Geräte": "Devices ready to learn",
"Lernen": "Learning",
"Lernfortschritt": "Learning progress",
"Lernsystem": "Learning system",
"Letzte automatische Prüfung:": "Last automatic check:",
"Letztes Training:": "Last training:",
"Manuelle Kontext-Auswahl gespeichert.": "Manual context selection saved.",
"Manuelle Sicherheitssperre aktiv": "Manual safety block active",
"Mindest-Sicherheit in %": "Minimum confidence in %",
"Noch keine Aktoren ausgewählt.": "No actuators selected yet.",
"Noch keine aktuelle Entscheidungsfaktoren berechnet.": "No current decision factors calculated yet.",
"Noch keine automatische Gewichtsanpassung.": "No automatic weight adjustment yet.",
"Noch keine Daten": "No data yet",
"Noch keine Gruppe gespeichert.": "No group saved yet.",
"Noch keine Kontext-Entity ausgewählt.": "No context entity selected yet.",
"Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.": "No sensors or states selected for weighting yet.",
"Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.": "No suitable context detected yet. SillyHome will check again when new HA data arrives.",
"Noch kein Modell-Snapshot gespeichert.": "No model snapshot saved yet.",
"Numerische Helper": "Numeric helpers",
"Optionaler Haupt-Messsensor": "Optional main measurement sensor",
"Passende Home-Assistant-Automationen": "Matching Home Assistant automations",
"Passende Home-Assistant-Automationen neu geprüft.": "Matching Home Assistant automations checked again.",
"Prüfen": "Review",
"Prüfung läuft ...": "Checking ...",
"Rollback möglich": "Rollback available",
"Sicherheit und manuelles Gegensteuern": "Safety and manual override",
"Sicherheitsprofil speichern": "Save safety profile",
"Sicherheitsprofil gespeichert.": "Safety profile saved.",
"SillyHome parallel aktivieren": "Activate SillyHome in parallel",
"SillyHome stoppen; HA-Automationen pausiert lassen": "Stop SillyHome; leave HA automations paused",
"SillyHome stoppen und pausierte HA-Automationen fortsetzen": "Stop SillyHome and resume paused HA automations",
"SillyHome übernehmen lassen": "Let SillyHome take over",
"SillyHome übernehmen lassen und passende HA-Automationen pausieren": "Let SillyHome take over and pause matching HA automations",
"Simulation läuft ...": "Simulation running ...",
"Simulation übernommen.": "Simulation applied.",
"Simulation übernommen und Dry-run gestartet.": "Simulation applied and dry-run started.",
"Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.": "Simulation result is no longer available. Please simulate again.",
"Simulierte Gewichtung in %": "Simulated weight in %",
"Simulierter Zustand": "Simulated state",
"Sensor-Gewichtung": "Sensor weighting",
"Sensor-Gewichtung gespeichert.": "Sensor weighting saved.",
"Start:": "Start:",
"Status teilweise verfügbar. Das Dashboard bleibt bedienbar.": "Status partially available. The dashboard remains usable.",
"Status wird geprüft ...": "Checking status ...",
"Statusgrund:": "Status reason:",
"System bereit": "System ready",
"Systemübersicht bereit in": "System overview ready in",
"Systemübersicht langsam:": "System overview slow:",
"Systemübersicht verzögert:": "System overview delayed:",
"Übernehmen + Dry-run starten": "Apply + start dry-run",
"Unsicherheit": "Uncertainty",
"Verwendete Sensoren/Zustände ändern": "Change used sensors/states",
"Vorhersage": "Prediction",
"Vorhersage korrekt": "Prediction correct",
"Vorhersage falsch": "Prediction wrong",
"Was SillyHome aktuell vorhersagt": "What SillyHome currently predicts",
"Wissen": "Knowledge",
"Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.": "Select a device from the loaded list or enter an entity ID.",
"Würde nach Sicherheitsprüfung schalten.": "Would switch after safety check.",
"Zeitprofile": "Time profiles",
"Zuordnung": "Assignment",
"Zuordnungssicherheit:": "Assignment confidence:",
"Zurück zur Übersicht": "Back to overview",
"Zustände": "States",
"Zustände vergleichen": "Compare states",
"aktiv": "active",
"aktiv freigegeben": "actively approved",
"automatisch erledigt": "automatic",
"bereit": "ready",
"keine": "none",
"keine Vorhersage": "no prediction",
"läuft/offen": "running/open",
"noch offen": "pending",
"offen": "open",
"pausiert": "paused",
"relevant": "relevant",
"statistisch relevanter Kandidat": "statistically relevant candidate",
"unbekannt": "unknown",
}));
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
function jumpToSection(target) {
if (!target) return;
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
}
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") {
if (cachedSystemOverview) {
renderDashboardStatus(cachedSystemOverview);
refreshSystemOverviewInBackground();
} else {
void loadSystemOverview();
}
} else if (viewId === "observed") {
if (cachedActuators) {
renderConfiguredActuators();
refreshOverviewInBackground();
} else {
void loadOverview();
}
} else if (viewId === "choose") {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") {
syncSettingsView();
void loadRoomManagement();
}
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function setLanguage(language) {
uiLang = I18N[language] ? language : "de";
localStorage.setItem("sillyhome.ui.language", uiLang);
document.documentElement.lang = uiLang;
cachedDetailHtml.clear();
applyStaticTranslations();
syncSettingsView();
renderActuatorSelect();
if (cachedActuators) renderConfiguredActuators();
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId) {
cachedDetailHtml.delete(currentActuatorId);
void showActuator(currentActuatorId);
}
}
function syncSettingsView() {
const select = document.getElementById("language-select");
if (select) select.value = uiLang;
if (cachedRoomManagement) {
renderRoomManagement();
}
}
function translate(group, value, fallback = "") {
if (value == null || value === "") return fallback || ui("open");
return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
}
function ui(key) {
return I18N[uiLang]?.ui?.[key] || I18N.de.ui[key] || key;
}
function setText(selector, key) {
const element = document.querySelector(selector);
if (element) element.textContent = ui(key);
}
function setPlaceholder(selector, key) {
const element = document.querySelector(selector);
if (element) element.placeholder = ui(key);
}
function translateText(value) {
if (uiLang !== "en") return value;
const text = String(value ?? "");
const direct = DE_TO_EN_TEXT.get(text.trim());
if (direct) {
return text.replace(text.trim(), direct);
}
let translated = text;
const replacements = [...DE_TO_EN_TEXT.entries()]
.filter(([source]) => source.length > 3)
.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of replacements) {
translated = translated.replaceAll(source, target);
}
translated = translated
.replace(/Bereit in (\d+) ms/g, "Ready in $1 ms")
.replace(/Weitere (\d+) Geräte anzeigen/g, "Show $1 more devices")
.replace(/(\d+) von (\d+); Suche oder Typ weiter eingrenzen/g, "$1 of $2; narrow search or type")
.replace(/Szenario (\d+)/g, "Scenario $1")
.replace(/Ø (\d+) %/g, "avg. $1 %")
.replace(/(\d+) Beispiele/g, "$1 examples")
.replace(/(\d+) eindeutig/g, "$1 clear")
.replace(/(\d+) % Beitrag/g, "$1 % contribution")
.replace(/(\d+) % Profilklarheit/g, "$1 % profile clarity")
.replace(/mit (\d+) % Sicherheit/g, "with $1 % confidence")
.replace(/Prüfung ([^:]+):/g, "Check $1:")
.replace(/Keine Zusammenfassung/g, "No summary")
.replace(/Dauer: läuft\/offen/g, "Duration: running/open");
return translated;
}
function localizeFragment(root = document) {
for (const summary of root.querySelectorAll?.("details.collapsible > summary, .manual-context > summary, .group-panel > summary") || []) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
if (uiLang !== "en") return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
node.nodeValue = translateText(node.nodeValue);
}
for (const input of root.querySelectorAll?.("input[placeholder], textarea[placeholder]") || []) {
input.placeholder = translateText(input.placeholder);
}
for (const optgroup of root.querySelectorAll?.("optgroup[label]") || []) {
optgroup.label = translateText(optgroup.label);
}
}
function applyStaticTranslations() {
document.body.dataset.closedLabel = ui("expand");
document.body.dataset.openLabel = ui("collapse");
for (const summary of document.querySelectorAll("details.collapsible > summary, .manual-context > summary, .group-panel > summary")) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
setText("header .brand p", "tagline");
setText("label[for='section-jump']", "menu");
const navOptions = document.querySelectorAll("#section-jump option");
[
"nav_status",
"nav_learning",
"nav_discovery",
"nav_settings",
].forEach((key, index) => {
if (navOptions[index]) navOptions[index].textContent = ui(key);
});
setText("#load-budget", "page_ready");
setText("#choose h2", "discovery_title");
setText("label[for='actuator-input']", "entity_id");
setPlaceholder("#actuator-input", "actuator_placeholder");
setText("label[for='actuator-domain-filter']", "type");
const domainOptions = document.querySelectorAll("#actuator-domain-filter option");
[
"all_actuators",
"lights",
"switches",
"buttons",
"helper_buttons",
"helper_switches",
"covers",
"climate",
"locks",
"fans",
"humidifiers",
"media",
"remotes",
"scenes",
"numbers",
"valves",
].forEach((key, index) => {
if (domainOptions[index]) domainOptions[index].textContent = ui(key);
});
setText("label[for='actuator-search']", "search_list");
setPlaceholder("#actuator-search", "search_placeholder");
setText("label[for='actuator-select']", "device_list");
const lazyOption = document.querySelector("#actuator-select option[value='']");
if (lazyOption) lazyOption.textContent = ui("device_list_lazy");
const chooseButtons = document.querySelectorAll("#choose > button");
if (chooseButtons[0]) chooseButtons[0].textContent = ui("add_device");
if (chooseButtons[1]) chooseButtons[1].textContent = ui("load_device_list");
setText("#actuator-config-result", "ready");
setText("#choose .manual-context summary", "show_suggestions");
const suggestionButton = document.querySelector("#choose .manual-context button");
if (suggestionButton) suggestionButton.textContent = ui("load_suggestions");
setText("#observed h2", "observed_devices");
const refreshButton = document.querySelector("#observed .panel-title button");
if (refreshButton) refreshButton.textContent = ui("refresh");
setText("#detail h2", "details");
const backButton = document.querySelector("#detail .panel-title button");
if (backButton) backButton.textContent = ui("back");
setText("#actuator-detail", "detail_empty");
setText("#status-section h2", "system_cache");
const statusButton = document.querySelector("#status-section .panel-title button");
if (statusButton) statusButton.textContent = ui("check_status");
setText("#settings h2", "settings");
setText("#settings .muted", "settings_hint");
setText("label[for='language-select']", "language");
const roomLoadButton = document.querySelector("#settings .panel-title button");
if (roomLoadButton) roomLoadButton.textContent = ui("load_rooms");
setText("label[for='room-management-filter']", "room_search");
setPlaceholder("#room-management-filter", "room_search_placeholder");
const roomManagement = document.getElementById("room-management");
if (roomManagement && !cachedRoomManagement) {
roomManagement.innerHTML = `<div class="empty-state">${escapeHtml(ui("room_management_empty"))}</div>`;
}
localizeFragment(document);
}
function formatDateTime(value) {
if (!value) return ui("open");
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? String(value)
: parsed.toLocaleString(uiLang === "en" ? "en-US" : "de-DE");
}
function uniqueValues(values) {
return [...new Set(values.filter(Boolean))];
}
function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedRoomManagement = null;
cachedDetailHtml.clear();
}
async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body?.detail || `${response.status} ${response.statusText}`);
return body;
}
async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await api(path, {signal: controller.signal});
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("Zeitlimit erreicht; Daten laden im Hintergrund weiter.");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function lifecycleLabel(record) {
const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return ui("context_detected");
const labels = {
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;
}
function statusClass(record) {
const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "ok";
if (lifecycleStatus === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
return "bad";
}
function behaviorLabel(record) {
const mode = record.behavior_mode || record.behavior?.mode;
const status = record.behavior_status || record.behavior?.status;
if (mode === "active") return ui("active_approved");
if (status === "trained") return ui("shadow_prediction");
if (status === "blocked") return ui("learning_blocked");
return ui("collecting_actions");
}
function predictionLabel(record) {
const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target
? `${target} (${Math.round(confidence * 100)} %)`
: ui("no_prediction");
}
function entityLabel(entity) {
const area = entity.area_name || ui("no_area");
const name = entity.friendly_name || entity.entity_id;
return `${area} - ${name} (${entity.entity_id})`;
}
function normalizedSearch(value) {
return String(value || "").toLowerCase().replaceAll("_", " ");
}
function matchesSearch(entity, query) {
if (!query) return true;
return normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
entity.device_class,
entity.domain,
].filter(Boolean).join(" ")).includes(query);
}
function categoryForEntity(entity) {
const cls = entity.device_class || "";
const text = normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
].filter(Boolean).join(" "));
if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) {
return "PV / Akku / Einspeisung";
}
if (entity.domain === "fan") return "Lüftung / Ventilatoren";
if (entity.domain === "climate") return "Heizung / Klima";
if (entity.domain === "weather") return "Wetter";
if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen";
if (entity.domain === "cover") return "Rollläden / Cover";
if (entity.domain === "light") return "Lichtzustände";
if (entity.domain === "switch") return "Schalter / Steckdosen";
if (entity.domain.startsWith("input_")) return "Helper";
if (entity.domain === "scene") return "Szenen";
if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien";
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
if (["illuminance"].includes(cls)) return "Helligkeit";
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
if (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose";
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
if (["temperature"].includes(cls)) return "Temperatur";
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal";
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
if (entity.domain === "sensor") return "Weitere Messsensoren";
return "Weitere Zustände";
}
function optionGroups(entities, selectedIds = new Set()) {
const groups = new Map();
for (const entity of entities) {
const category = categoryForEntity(entity);
if (!groups.has(category)) groups.set(category, []);
groups.get(category).push(entity);
}
return Array.from(groups.entries()).map(([label, items]) => `
<optgroup label="${escapeHtml(label)}">
${items.map(entity => `
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
${escapeHtml(entityLabel(entity))}
</option>
`).join("")}
</optgroup>
`).join("");
}
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 = ui("loading_start");
if (!cachedActuators) {
document.getElementById("configured-actuators").innerHTML = `<p class='muted'>${escapeHtml(ui("loading_devices"))}</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);
renderConfiguredActuators();
if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
}
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = ui("delayed_start");
try {
await loadSummaryData();
renderConfiguredActuators();
} catch (_) {
document.getElementById("configured-actuators").innerHTML = `<div class='empty-state'>${escapeHtml(ui("unavailable_start"))}</div>`;
}
}
scheduleDashboardExtras();
}
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 = ui("system_loading");
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) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Systemübersicht bereit in ${loadMs} ms`
: `Systemübersicht langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
}
scheduleDashboardExtras();
} catch (error) {
document.getElementById("status").innerHTML = `<p class="warn">${escapeHtml(translateText(`Systemübersicht verzögert: ${error.message}`))}</p>`;
if (budget) budget.textContent = ui("system_delayed");
}
}
function refreshOverviewInBackground() {
if (!overviewLoadPromise) {
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
}
}
function refreshSystemOverviewInBackground() {
if (!systemLoadPromise) {
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
}
}
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 = translateText(`Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`);
}
}
} catch (_) {
// Die Startansicht bleibt auch ohne Hintergrunddaten bedienbar.
}
}
async function loadStatus() {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
try {
const [health, websocket, ml, reconciliation] = await Promise.allSettled([
apiWithTimeout("health"),
apiWithTimeout("health/websocket"),
apiWithTimeout("ml/health"),
apiWithTimeout("v1/actuators/reconciliation/state"),
]);
const values = [health, websocket, ml, reconciliation].map(result =>
result.status === "fulfilled" ? result.value : null
);
const [healthValue, websocketValue, mlValue, reconciliationValue] = values;
const hasError = values.some(value => value === null);
status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(formatDateTime(reconciliationValue.last_completed_at))}</p>`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(translate("connection_status", websocketValue?.status, websocketValue?.status || "offen"))}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join("");
} catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
}
localizeFragment(status);
localizeFragment(chips);
}
function renderDashboardStatus(dashboard) {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats");
const system = dashboard.system || {};
const cache = dashboard.cache || {};
const actuators = dashboard.actuators || [];
const discoveryGroups = dashboard.discovery_groups || [];
const activeCount = actuators.filter(record => record.behavior_mode === "active").length;
const shadowCount = actuators.filter(record => record.behavior_mode !== "active").length;
const readyCount = actuators.filter(record => record.activation_ready).length;
const pendingCount = actuators.filter(record =>
["pending_history", "pending_assignment", "archived"].includes(record.lifecycle_status)
).length;
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
const anomalyTotal = Number(system.anomaly_count || 0);
const criticalAnomalyTotal = Number(system.critical_anomaly_count || 0);
const loadMs = Number(dashboard._load_elapsed_ms || 0);
const jobs = dashboard.jobs?.jobs || [];
const runningJobs = jobs.filter(job => job.status === "running").length;
const slowJobs = Number(system.slow_job_count || 0);
const p95 = system.job_p95_duration_ms == null ? "offen" : `${system.job_p95_duration_ms} ms`;
const performanceClass = (
loadMs > DASHBOARD_TIMEOUT_MS
|| slowJobs > 0
|| system.performance_status === "slow"
) ? "warn" : "ok";
const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut";
status.innerHTML = `
<p class="${performanceClass}">
Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.
</p>
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">WebSocket: ${escapeHtml(translate("connection_status", system.websocket_status, system.websocket_status || "unbekannt"))}</p>
<p class="muted" id="reconciliation-status">Letzte automatische Prüfung: ${escapeHtml(formatDateTime(system.reconciliation_last_completed_at))}</p>
`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`,
`<span class="chip">Cache: ${escapeHtml(cacheLabel)}</span>`,
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
`<span class="chip">Anomalien: ${escapeHtml(anomalyTotal)}</span>`,
`<span class="chip">Kritisch: ${escapeHtml(criticalAnomalyTotal)}</span>`,
].join("");
stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
`<div class="metric"><strong>Freigabebereit</strong>${escapeHtml(readyCount)} Geräte</div>`,
`<div class="metric"><strong>Aktiv / Prüfmodus</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
`<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`,
`<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`,
`<div class="metric"><strong>Performance-Budget</strong>${escapeHtml(system.performance_budget_ms || 3000)} ms</div>`,
`<div class="metric"><strong>Job p95</strong>${escapeHtml(p95)}</div>`,
`<div class="metric"><strong>Langsame Jobs</strong>${escapeHtml(slowJobs)}</div>`,
`<div class="metric"><strong>Anomalien</strong>${escapeHtml(anomalyTotal)} offen</div>`,
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join("");
renderJobQueue(jobs);
localizeFragment(status);
localizeFragment(chips);
localizeFragment(stats);
}
function renderJobQueue(jobs) {
const jobsBox = document.getElementById("job-queue");
if (!jobsBox) return;
jobsBox.innerHTML = jobs.length ? `
<h3>Aufgabenliste</h3>
${jobs.slice(-6).reverse().map(job => `
<div class="decision-row ${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? "slow" : ""}">
<header>
<strong>${escapeHtml(translate("job_kind", job.kind, job.kind))}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
<span class="chip">${escapeHtml(translate("job_status", job.status, job.status))}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}</span>
</header>
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
<p class="muted">Start: ${escapeHtml(formatDateTime(job.started_at))} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
${job.status === "failed" ? "<p class='warn'>Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.</p>" : ""}
</div>
`).join("")}
` : "";
localizeFragment(jobsBox);
}
async function loadRoomManagement(force = false) {
const box = document.getElementById("room-management");
if (!box) return;
if (cachedRoomManagement && !force) {
renderRoomManagement();
return;
}
box.innerHTML = `<p class='muted'>${escapeHtml(uiLang === "en" ? "Loading rooms ..." : "Räume werden geladen ...")}</p>`;
try {
cachedRoomManagement = await api("v1/actuators/settings/rooms");
renderRoomManagement();
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
function renderRoomManagement() {
const box = document.getElementById("room-management");
if (!box) return;
const overview = cachedRoomManagement;
if (!overview) {
box.innerHTML = `<div class="empty-state">${escapeHtml(ui("room_management_empty"))}</div>`;
return;
}
const query = normalizedSearch(document.getElementById("room-management-filter")?.value || "");
const rooms = (overview.rooms || []).filter(room =>
!query
|| normalizedSearch(room.room).includes(query)
|| (room.actuators || []).some(actuator =>
matchesSearch({
entity_id: actuator.actuator_entity_id,
friendly_name: actuator.friendly_name,
area_name: room.room,
}, query)
)
);
const unmanaged = overview.unmanaged_actuators || [];
box.innerHTML = `
${rooms.length ? rooms.map(room => `
<article class="room-card">
<header>
<div>
<h3>${escapeHtml(room.room)}</h3>
<p class="muted">${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}</p>
</div>
<span class="chip">${escapeHtml((room.sensors || []).filter(sensor => sensor.active).length)} ${escapeHtml(uiLang === "en" ? "active sensors" : "aktive Sensoren")}</span>
</header>
<div class="sensor-strip">
${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `<span class="muted">${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}</span>`}
</div>
<div class="decision-list">
${(room.prediction_rules || []).slice(0, 5).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
</div>
<div class="card-list">
${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")}
</div>
</article>
`).join("") : `<div class="empty-state">${escapeHtml(uiLang === "en" ? "No matching rooms." : "Keine passenden Räume.")}</div>`}
${unmanaged.length ? `
<article class="room-card">
<header>
<h3>${escapeHtml(uiLang === "en" ? "Unmanaged suggestions" : "Noch nicht verwaltete Vorschläge")}</h3>
<span class="chip">${escapeHtml(unmanaged.length)}</span>
</header>
<div class="card-list">
${unmanaged.slice(0, 6).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(item.friendly_name || item.entity_id)}</strong>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">${escapeHtml(uiLang === "en" ? "Manage" : "Verwalten")}</button>
</article>
`).join("")}
</div>
</article>
` : ""}
`;
localizeFragment(box);
}
function sensorPill(sensor) {
const classes = [
"sensor-pill",
sensor.active ? "active" : "",
sensor.optional ? "optional" : "",
sensor.not_required ? "not-required" : "",
].filter(Boolean).join(" ");
const state = sensor.state == null ? "" : ` · ${sensor.state}`;
const status = sensor.not_required
? (uiLang === "en" ? "not needed" : "nicht nötig")
: sensor.active
? (uiLang === "en" ? "active" : "aktiv")
: sensor.optional
? (uiLang === "en" ? "optional" : "optional")
: (uiLang === "en" ? "suggested" : "Vorschlag");
return `
<span class="${classes}" title="${escapeHtml(sensor.reason)}">
<strong>${escapeHtml(sensor.friendly_name || sensor.entity_id)}</strong>
<span class="muted"> · ${escapeHtml(sensor.category)} · ${escapeHtml(status)}${escapeHtml(state)}</span>
</span>
`;
}
function roomActuatorCard(actuator) {
const activeSensorIds = (actuator.sensors || [])
.filter(sensor => sensor.active && !sensor.not_required)
.map(sensor => sensor.entity_id);
const selectedContextIds = (actuator.sensors || [])
.filter(sensor => sensor.active && sensor.entity_id !== actuator.selected_numeric_entity_id)
.map(sensor => sensor.entity_id);
return `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(actuator.friendly_name || actuator.actuator_entity_id)}</strong>
<div class="entity-id">${escapeHtml(actuator.actuator_entity_id)}</div>
<div class="muted">${escapeHtml(behaviorLabel(actuator))} · ${escapeHtml(lifecycleLabel(actuator))}</div>
</div>
<span class="chip">${escapeHtml(actuator.sample_count)} ${escapeHtml(uiLang === "en" ? "actions" : "Handlungen")}</span>
</div>
<p class="muted">${escapeHtml(actuator.management_hint)}</p>
<div class="sensor-strip">
${(actuator.sensors || []).slice(0, 8).map(sensor => sensorPill(sensor)).join("")}
</div>
<div class="decision-list">
${(actuator.prediction_rules || []).slice(0, 4).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
</div>
<div class="actions">
<button class="secondary" onclick="showActuator('${escapeHtml(actuator.actuator_entity_id)}')">${escapeHtml(uiLang === "en" ? "Open details" : "Details öffnen")}</button>
<button onclick="saveRoomAssignment('${escapeHtml(actuator.actuator_entity_id)}', '${escapeHtml(actuator.selected_numeric_entity_id || "")}', '${escapeHtml(selectedContextIds.join(","))}')">${escapeHtml(uiLang === "en" ? "Save selection" : "Auswahl speichern")}</button>
</div>
<p class="muted">${escapeHtml(uiLang === "en" ? "Current selection:" : "Aktuelle Auswahl:")} ${escapeHtml(activeSensorIds.join(", ") || (uiLang === "en" ? "none" : "keine"))}</p>
</article>
`;
}
async function saveRoomAssignment(actuatorId, numericEntityId, contextCsv) {
const contextEntityIds = contextCsv.split(",").map(item => item.trim()).filter(Boolean);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId || null,
context_entity_ids: contextEntityIds,
note: "In der Raumverwaltung gespeichert",
}),
});
invalidateDashboardCache();
cachedRoomManagement = null;
await loadRoomManagement(true);
} catch (error) {
alert(error.message);
}
}
async function loadSummaryData() {
const actuators = await api("v1/actuators/summary");
cachedActuators = actuators;
cachedEntities = [];
}
async function loadDashboardData() {
await loadSummaryData();
await loadDiscoveryData();
}
async function loadDiscoveryData() {
const discovery = await api("v1/actuators/discovery");
cachedDiscovery = discovery;
}
async function loadFullDashboardData() {
const [actuators, discovery] = await Promise.all([
api("v1/actuators/summary"),
api("v1/actuators/discovery"),
]);
cachedActuators = actuators;
cachedEntities = [];
cachedDiscovery = discovery;
}
async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select");
if (select && !cachedDiscovery) {
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
}
if (!cachedActuators) {
await loadSummaryData();
}
if (!cachedDiscovery) {
await loadDiscoveryData();
}
renderActuatorDiscovery();
}
async function ensureActuatorDiscovery() {
const select = document.getElementById("actuator-select");
if (cachedDiscovery) {
renderActuatorDiscovery();
return;
}
if (!discoveryLoadPromise) {
if (select) select.innerHTML = `<option value="">Geräteliste wird geladen ...</option>`;
discoveryLoadPromise = loadActuatorDiscovery().finally(() => {
discoveryLoadPromise = null;
});
}
await discoveryLoadPromise;
}
function renderActuatorDiscovery() {
const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select");
try {
const available = cachedDiscovery || [];
const configured = cachedActuators || [];
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
).join("");
renderActuatorSelect();
} catch (error) {
options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
}
localizeFragment(document.getElementById("choose"));
}
async function loadActuatorSuggestions() {
const box = document.getElementById("actuator-suggestions");
if (!box) return;
try {
const suggestions = await api("v1/actuators/suggestions");
box.innerHTML = suggestions.length ? `
<h3>Vorschläge aus bestehenden Zusammenhängen</h3>
${suggestions.slice(0, 8).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">Vorschlag übernehmen</button>
</article>
`).join("")}
` : "";
} catch (_) {
box.innerHTML = "";
}
localizeFragment(box);
}
function actuatorGroupLabel(domain) {
const labels = {
button: "Buttons",
climate: "Heizungen / Klima",
light: "Lichter",
input_boolean: "Helper-Schalter",
input_button: "Helper-Buttons",
lock: "Schlösser",
media_player: "TV / Medien",
number: "Numerische Helper",
remote: "Fernbedienungen",
scene: "Szenen",
switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren",
humidifier: "Befeuchter / Entfeuchter",
valve: "Ventile",
};
return labels[domain] || domain;
}
function renderActuatorSelect() {
const select = document.getElementById("actuator-select");
if (!select) return;
const domain = document.getElementById("actuator-domain-filter")?.value || "";
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
const filtered = actuatorChoices
.filter(entity => !domain || entity.domain === domain)
.filter(entity => matchesSearch(entity, query));
const visible = filtered.slice(0, ACTUATOR_RESULT_LIMIT);
const domains = [...new Set(visible.map(entity => entity.domain))].sort();
const limitLabel = filtered.length > visible.length
? ` - ${visible.length} von ${filtered.length}; Suche oder Typ weiter eingrenzen`
: "";
select.innerHTML = [
`<option value="">${filtered.length ? `Gerät auswählen${limitLabel}` : "Keine passenden Geräte gefunden"}</option>`,
...domains.map(group => `
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
${visible
.filter(entity => entity.domain === group)
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
.join("")}
</optgroup>
`),
].join("");
localizeFragment(select);
}
async function loadContextOptions(actuatorId) {
try {
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
} catch (error) {
contextOptions = [];
}
}
function selectActuatorFromList() {
const value = document.getElementById("actuator-select").value;
if (value) document.getElementById("actuator-input").value = value;
}
function selectDetailActuator() {
const value = document.getElementById("detail-actuator-select")?.value;
if (value) void showActuator(value);
}
function detailActuatorOptions(selectedId) {
const rows = cachedActuators || [];
return rows.map(record => `
<option value="${escapeHtml(record.actuator_entity_id)}" ${record.actuator_entity_id === selectedId ? "selected" : ""}>
${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}
</option>
`).join("");
}
function renderManualContextSelect() {
const select = document.getElementById("manual-context-select");
if (!select) return;
const category = document.getElementById("manual-context-category")?.value || "";
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
const selectedNow = new Set([
...manualContextState.selected,
...Array.from(select.selectedOptions).map(option => option.value),
]);
const filtered = manualContextState.options
.filter(entity => !category || categoryForEntity(entity) === category)
.filter(entity => matchesSearch(entity, query))
.slice(0, 80);
select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`;
localizeFragment(select);
}
function parseEntityIds(value) {
return String(value || "")
.split(/[\s,;]+/)
.map(item => item.trim())
.filter(Boolean);
}
async function configureActuator() {
const actuatorId = (
document.getElementById("actuator-input").value.trim()
|| document.getElementById("actuator-select").value.trim()
);
const result = document.getElementById("actuator-config-result");
if (!actuatorId) {
await ensureActuatorDiscovery();
result.textContent = "Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.";
return;
}
result.textContent = "Kontext wird automatisch analysiert ...";
try {
const record = await api("v1/actuators", {
method: "POST",
body: JSON.stringify({actuator_entity_id: actuatorId}),
});
currentActuatorId = record.actuator_entity_id;
result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
await loadOverview();
await showActuator(record.actuator_entity_id);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) {
result.textContent = error.message;
}
}
async function loadConfiguredActuators() {
if (!cachedActuators) {
await loadSummaryData();
}
renderConfiguredActuators();
}
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 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
? `<button class="secondary" onclick="visibleActuatorLimit += 24; renderConfiguredActuators()">Weitere ${Math.min(24, rows.length - visibleRows.length)} Geräte anzeigen</button>`
: "";
box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => `
<details class="group-panel">
<summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list">
${items.map(({record}) => `
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
<div class="card-title">
<div>
<div><strong>${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
</div>
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</div>
<div class="metric-grid">
<div class="metric"><strong>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
<div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div>
<div class="actions">
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
${record.behavior_mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
: record.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
: ""}
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
</div>
</article>
`).join("")}
</div>
</details>
`).join("")}
${moreButton}
` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) {
box.textContent = error.message;
}
localizeFragment(box);
}
async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId;
for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === "detail");
}
document.getElementById("section-jump").value = "observed";
localStorage.setItem("sillyhome.ui.view", "detail");
const box = document.getElementById("actuator-detail");
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
localizeFragment(box);
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 = [];
const contexts = [
record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids,
].filter(Boolean);
const evidence = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join("");
const weightedCandidates = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id));
const weightGroups = record.manual_override?.sensor_weight_groups || [];
currentSensorWeightGroups = weightGroups;
const weightControls = weightedCandidates.length ? `
<div class="card-list">
${weightedCandidates.map(candidate => {
const relevance = Math.round((candidate.confidence ?? 0) * 100);
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
const manual = candidate.manual_weight == null ? effective : Math.round(candidate.manual_weight * 100);
return `
<article class="actuator-card">
<div class="card-title">
<div>
<div><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
</div>
<span class="chip">${relevance} % relevant</span>
</div>
<div class="metric-grid">
<div class="metric"><strong>Automatische Relevanz</strong>${relevance} %</div>
<div class="metric"><strong>Aktive Gewichtung</strong>${effective} %</div>
<div class="metric"><strong>Score</strong>${escapeHtml(candidate.score)}</div>
</div>
<label for="weight-${escapeHtml(candidate.entity_id)}">Gewichtung korrigieren</label>
<input id="weight-${escapeHtml(candidate.entity_id)}" data-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${manual}">
</article>
`;
}).join("")}
</div>
<div class="actions">
<button onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}')">Gewichtungen speichern</button>
</div>
` : "<p class='muted'>Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.</p>";
const weightGroupControls = `
<details class="manual-context">
<summary>Gruppen-Gewichtung</summary>
${weightGroups.length ? `<ul>${weightGroups.map(group => `
<li><strong>${escapeHtml(group.name)}</strong>: ${Math.round(group.weight * 100)} %
<span class="muted">${group.entity_ids.map(escapeHtml).join(", ")}</span></li>
`).join("")}</ul>` : "<p class='muted'>Noch keine Gruppe gespeichert.</p>"}
<div class="inline-controls">
<div>
<label for="weight-group-name">Gruppenname</label>
<input id="weight-group-name" placeholder="z. B. Flur Bewegung + Helligkeit">
</div>
<div>
<label for="weight-group-value">Gruppen-Gewicht in %</label>
<input id="weight-group-value" type="number" min="0" max="100" step="5" value="100">
</div>
</div>
<label for="weight-group-entities">Entity-IDs der Gruppe</label>
<textarea id="weight-group-entities" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs">${escapeHtml(contexts.join("\n"))}</textarea>
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
</details>
`;
const simulationControls = weightedCandidates.length ? `
<details class="manual-context" open>
<summary>Aktor-Simulation</summary>
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten. Danach kannst du die beste Gewichtung übernehmen oder direkt in den Dry-run wechseln.</p>
<div class="card-list">
${weightedCandidates.map(candidate => {
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
const currentState = candidate.state || "";
const stateOptions = candidate.domain === "binary_sensor"
? "off,on"
: currentState;
return `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
</div>
<span class="chip">Simulation</span>
</div>
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
</article>
`;
}).join("")}
</div>
<div class="actions">
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
</div>
<div id="simulation-result" class="decision-list"></div>
</details>
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
<code>${escapeHtml(entityId)}</code>
<button class="secondary compact" onclick="removeContextEntity('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(entityId)}')">Entfernen</button>
</li>
`).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction;
const safety = record.behavior.safety || {};
const blockers = record.behavior.safety_blockers || [];
const decisionFactors = record.behavior.decision_factors || [];
const knowledge = record.behavior.knowledge || [];
const assumptions = record.behavior.assumptions || [];
const uncertainties = record.behavior.uncertainties || [];
const snapshots = record.behavior.model_snapshots || [];
const activeModelVersion = record.behavior.active_model_version || "";
const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
const automationConflicts = record.behavior.automation_conflicts || [];
const timeProfiles = record.behavior.time_profiles || [];
const anomalies = (record.behavior.anomalies || []).filter(item => !item.resolved);
const safetyControls = `
<details class="manual-context" open>
<summary>Sicherheit und manuelles Gegensteuern</summary>
<div class="inline-controls">
<div>
<label for="safety-stage">Freigabestufe</label>
<select id="safety-stage">
${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${escapeHtml(translate("safety_stage", stage))}</option>
`).join("")}
</select>
</div>
<div>
<label for="safety-confidence">Mindest-Sicherheit in %</label>
<input id="safety-confidence" type="number" min="0" max="100" step="1" value="${Math.round((safety.min_confidence ?? 0.82) * 100)}">
</div>
<div>
<label for="safety-cooldown">Cooldown Sekunden</label>
<input id="safety-cooldown" type="number" min="0" step="10" value="${safety.cooldown_seconds ?? ""}" placeholder="Standard">
</div>
</div>
<label>
<input id="safety-manual-block" type="checkbox" ${safety.manual_block ? "checked" : ""}>
Manuelle Sicherheitssperre aktiv
</label>
${blockers.length ? `<p class="warn">Aktuelle Blocker: ${blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.</p>"}
<button class="secondary" onclick="saveSafetyProfile('${escapeHtml(record.actuator_entity_id)}')">Sicherheitsprofil speichern</button>
</details>
`;
const decisionArchive = `
<details class="manual-context" open>
<summary>Entscheidungsakte</summary>
<div class="grid-two">
<div>
<h3>Wissen</h3>
<ul>${knowledge.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine gesicherten Punkte gespeichert.</li>"}</ul>
</div>
<div>
<h3>Annahmen</h3>
<ul>${assumptions.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Annahmen gespeichert.</li>"}</ul>
</div>
</div>
<h3>Unsicherheit</h3>
<ul>${uncertainties.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Unsicherheit gespeichert.</li>"}</ul>
<h3>Beitragsfaktoren</h3>
<div class="decision-list">
${decisionFactors.length ? decisionFactors.map(factor => `
<div class="decision-row">
<header>
<strong>${escapeHtml(factor.label)}</strong>
<span class="chip">${Math.round((factor.contribution || 0) * 100)} % Beitrag</span>
</header>
<p class="muted">${escapeHtml(factor.entity_id || factor.factor_type)} · Zustand: ${escapeHtml(factor.state || "offen")} · Gewicht: ${Math.round((factor.weight || 0) * 100)} %</p>
<p>${(factor.evidence || []).map(escapeHtml).join(" ")}</p>
</div>
`).join("") : "<p class='muted'>Noch keine aktuelle Entscheidungsfaktoren berechnet.</p>"}
</div>
</details>
`;
const adaptivePanel = `
<details class="manual-context">
<summary>v1.2 Lernen, Rollback und Konflikte</summary>
<h3>Zeitprofile</h3>
<div class="metric-grid">
${timeProfiles.length ? timeProfiles.map(profile => `
<div class="metric">
<strong>${escapeHtml(profile.label)}</strong>
${escapeHtml(profile.sample_count)} Beispiele · ${escapeHtml(profile.dominant_state || "offen")}
<p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p>
</div>
`).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"}
</div>
<h3>Modell-Snapshots</h3>
<div class="decision-list">
${snapshots.length ? snapshots.slice(-5).reverse().map(snapshot => `
<div class="decision-row">
<header>
<strong>${escapeHtml(snapshot.version_id)}</strong>
<span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span>
</header>
<p class="muted">${escapeHtml(snapshot.sample_count)} Beispiele · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
<p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p>
${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""}
</div>
`).join("") : "<p class='muted'>Noch kein Modell-Snapshot gespeichert.</p>"}
</div>
<h3>Automatische Gewichtsanpassungen</h3>
<ul>${adaptiveUpdates.length ? adaptiveUpdates.slice(-8).reverse().map(update => `
<li><code>${escapeHtml(update.entity_id)}</code>: ${Math.round(update.previous_weight * 100)} % → ${Math.round(update.new_weight * 100)} %. ${escapeHtml(update.reason)}</li>
`).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul>
<h3>Automation-Konflikte</h3>
<ul>${automationConflicts.length ? automationConflicts.map(conflict => `
<li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(translate("severity", conflict.severity, conflict.status))}</span> ${escapeHtml(conflict.reason)}</li>
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
</details>
`;
const anomalyPanel = `
<details class="manual-context" ${anomalies.length ? "open" : ""}>
<summary>v1.3 Anomalie- und Performance-Hinweise</summary>
<div class="decision-list">
${anomalies.length ? anomalies.map(anomaly => `
<div class="decision-row ${anomaly.severity === "critical" ? "critical" : ""}">
<header>
<strong>${escapeHtml(anomaly.title)}</strong>
<span class="chip">${escapeHtml(translate("severity", anomaly.severity))} · ${escapeHtml(translate("anomaly_category", anomaly.category, anomaly.category))}</span>
</header>
<p>${escapeHtml(anomaly.detail)}</p>
<p class="muted">Erkannt: ${escapeHtml(formatDateTime(anomaly.detected_at))}</p>
</div>
`).join("") : "<p class='ok'>Keine offenen Anomalien fuer diesen Aktor.</p>"}
</div>
</details>
`;
const learnedAutomationActions = "wird bei Bedarf im Training ausgewertet";
const relatedAutomations = record.behavior.related_automations || [];
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
const manualOnlyIds = [
record.assignment.selected_numeric_entity_id,
...manualContextIds,
].filter(entityId => entityId && !suggestedIds.has(entityId));
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
const manualAssignment = `
<details class="manual-context">
<summary>Kontext selbst festlegen</summary>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select">
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
</select>
<div class="inline-controls">
<div>
<label for="manual-context-category">Kategorie</label>
<select id="manual-context-category" onchange="renderManualContextSelect()">
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
</select>
</div>
<div>
<label for="manual-context-filter">Vorschläge durchsuchen</label>
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
</div>
</div>
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
<select id="manual-context-select" multiple>
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
</select>
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<div class="actions">
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
<button class="secondary" onclick="hydrateCurrentContextOptions('${escapeHtml(record.actuator_entity_id)}')">Vorschläge neu laden</button>
</div>
</details>
`;
const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
: record.behavior.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen und passende HA-Automationen pausieren</button>
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, false, false)">SillyHome parallel aktivieren</button>`
: `<p class='warn'>${escapeHtml(record.behavior.activation_reason)}</p>`;
const automationControls = relatedAutomations.length
? `<ul>${relatedAutomations.map(automation => `
<li>
<strong>${escapeHtml(automation.friendly_name)}</strong>
<code>${escapeHtml(automation.entity_id)}</code>:
<span class="${automation.enabled ? "ok" : "warn"}">${automation.enabled ? "aktiv" : "pausiert"}</span>
<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>";
const detailHtml = `
<div class="detail-header">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
</div>
</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>
<h3>Zuordnung</h3>
<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><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div>
<div>
<h3>Lernfortschritt</h3>
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</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>
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
</div>
</div>
<h3>Was SillyHome aktuell vorhersagt</h3>
${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<div class="actions">
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
</div>
${safetyControls}
${decisionArchive}
${adaptivePanel}
${anomalyPanel}
<h3>Passende Home-Assistant-Automationen</h3>
<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>
${weightControls}
${weightGroupControls}
${simulationControls}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
`;
box.innerHTML = detailHtml;
localizeFragment(box);
cachedDetailHtml.set(actuatorId, box.innerHTML);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) {
box.textContent = error.message;
}
localizeFragment(box);
}
function renderActuatorDetailShell(actuatorId) {
document.getElementById("actuator-detail").innerHTML = `
<div class="detail-header">
<div>
<h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Lädt ...</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><strong>Status</strong>...</div>
<div class="metric"><strong>Lernen</strong>...</div>
<div class="metric"><strong>Kontext</strong>...</div>
</div>
`;
localizeFragment(document.getElementById("actuator-detail"));
}
async function hydrateContextOptions(record) {
await loadContextOptions(record.actuator_entity_id);
const manualContextSelect = document.getElementById("manual-context-select");
const numericSelect = document.getElementById("manual-numeric-select");
const categorySelect = document.getElementById("manual-context-category");
if (!manualContextSelect || !numericSelect || !categorySelect) return;
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
numericSelect.innerHTML = `
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
`;
categorySelect.innerHTML = `
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`;
renderManualContextSelect();
localizeFragment(numericSelect);
localizeFragment(categorySelect);
}
async function hydrateCurrentContextOptions(actuatorId) {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
await hydrateContextOptions(record);
}
async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
const sensorWeights = {};
for (const input of document.querySelectorAll("[data-weight-entity]")) {
const value = Number(input.value);
if (Number.isFinite(value)) {
sensorWeights[input.dataset.weightEntity] = Math.max(0, Math.min(100, value)) / 100;
}
}
const groups = [...currentSensorWeightGroups];
if (includeNewGroup) {
const name = document.getElementById("weight-group-name")?.value.trim();
const value = Number(document.getElementById("weight-group-value")?.value || 100);
const entityIds = parseEntityIds(document.getElementById("weight-group-entities")?.value || "");
if (name && entityIds.length) {
groups.push({
group_id: name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "gruppe",
name,
entity_ids: entityIds,
weight: Math.max(0, Math.min(100, Number.isFinite(value) ? value : 100)) / 100,
});
}
}
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
method: "POST",
body: JSON.stringify({
sensor_weights: sensorWeights,
sensor_weight_groups: groups,
note: "Gewichtung im Dashboard korrigiert",
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Sensor-Gewichtung gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function simulateActuator(actuatorId) {
const sensorStates = {};
const sensorWeights = {};
const stateOptions = {};
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
const value = input.value.trim();
if (value) sensorStates[input.dataset.simStateEntity] = value;
}
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
const value = Number(input.value);
if (Number.isFinite(value)) {
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
}
}
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
}
const box = document.getElementById("simulation-result");
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
try {
const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, {
method: "POST",
body: JSON.stringify({
sensor_states: sensorStates,
sensor_weights: sensorWeights,
state_options: stateOptions,
max_results: 6,
}),
});
latestSimulationResults.set(actuatorId, results);
box.innerHTML = results.length ? results.map((result, index) => {
const prediction = result.prediction;
const factors = result.decision_factors || [];
return `
<div class="decision-row">
<header>
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
</header>
<p>${escapeHtml(result.recommendation || "")}</p>
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
<div class="actions">
<button class="secondary compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', false)">Gewichtung übernehmen</button>
<button class="compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', true)">Übernehmen + Dry-run starten</button>
</div>
</div>
`;
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {
const result = (latestSimulationResults.get(actuatorId) || [])
.find(item => item.scenario_id === scenarioId);
if (!result) {
alert("Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.");
return;
}
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
method: "POST",
body: JSON.stringify({
sensor_weights: result.sensor_weights || {},
sensor_weight_groups: currentSensorWeightGroups,
note: `Aus Simulation ${scenarioId} übernommen`,
}),
});
if (startDryRun) {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/dry-run`, {
method: "POST",
body: JSON.stringify({enabled: true}),
});
}
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(
actuatorId,
startDryRun
? "Simulation übernommen und Dry-run gestartet."
: "Simulation übernommen.",
);
} catch (error) {
alert(error.message);
}
}
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from(
document.getElementById("manual-context-select").selectedOptions,
).map(option => option.value).filter(value => value.includes("."));
const freeformContextIds = parseEntityIds(
document.getElementById("manual-context-freeform").value,
);
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
.filter(entityId => entityId !== numericEntityId);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: "Manuell im Dashboard gesetzt",
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function removeContextEntity(actuatorId, entityId) {
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericEntityId = record.assignment.selected_numeric_entity_id === entityId
? null
: record.assignment.selected_numeric_entity_id;
const contextEntityIds = (record.assignment.selected_context_entity_ids || [])
.filter(id => id !== entityId);
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: `Entity ${entityId} entfernt`,
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) {
alert(error.message);
}
}
async function configureSuggestedActuator(actuatorId) {
document.getElementById("actuator-input").value = actuatorId;
await configureActuator();
}
async function evaluateActuator(actuatorId) {
try {
const record = await api(
`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`,
{method: "POST"},
);
const checkedAt = new Date(
record.behavior.last_evaluated_at || Date.now(),
).toLocaleString("de-DE");
const message = record.behavior.prediction
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, message);
} catch (error) {
alert(error.message);
}
}
async function sendFeedback(actuatorId, correct) {
const expectedState = correct ? null : prompt("Welcher Zustand wäre korrekt gewesen? Leer lassen, wenn nur abwerten.");
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/feedback`, {
method: "POST",
body: JSON.stringify({
correct,
expected_state: expectedState || null,
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) {
alert(error.message);
}
}
async function saveSafetyProfile(actuatorId) {
const confidence = Number(document.getElementById("safety-confidence")?.value || 82);
const cooldownRaw = document.getElementById("safety-cooldown")?.value || "";
const cooldown = cooldownRaw === "" ? null : Math.max(0, Number(cooldownRaw));
const profile = {
stage: document.getElementById("safety-stage")?.value || "shadow",
manual_block: Boolean(document.getElementById("safety-manual-block")?.checked),
min_confidence: Math.max(0, Math.min(100, Number.isFinite(confidence) ? confidence : 82)) / 100,
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 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."},
],
note: "Sicherheitsprofil im Dashboard gespeichert",
};
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/safety`, {
method: "POST",
body: JSON.stringify({safety: profile}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Sicherheitsprofil gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function rollbackModel(actuatorId, versionId) {
if (!confirm(`${actuatorId}: wirklich auf Modell ${versionId} zurückrollen?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/model/rollback`, {
method: "POST",
body: JSON.stringify({version_id: versionId}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, `Rollback auf ${versionId} ausgeführt.`);
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active
? pauseMatchingAutomations
? `${actuatorId}: SillyHome aktivieren und passende HA-Automationen pausieren?`
: `${actuatorId}: SillyHome parallel zu den HA-Automationen aktivieren?`
: restorePausedAutomations
? `${actuatorId}: SillyHome stoppen und pausierte HA-Automationen fortsetzen?`
: `${actuatorId}: SillyHome stoppen und HA-Automationen pausiert lassen?`;
if (!confirm(question)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
method: "POST",
body: JSON.stringify({
active,
pause_matching_automations: pauseMatchingAutomations,
restore_paused_automations: restorePausedAutomations,
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
const action = enabled ? "fortsetzen" : "pausieren";
if (!confirm(`${automationEntityId} wirklich ${action}?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/control`, {
method: "POST",
body: JSON.stringify({
automation_entity_id: automationEntityId,
enabled,
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function refreshRelatedAutomations(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Passende Home-Assistant-Automationen neu geprüft.");
} catch (error) {
alert(error.message);
}
}
async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
if (currentActuatorId === actuatorId) {
currentActuatorId = null;
document.getElementById("actuator-detail").textContent = "Öffne bei einem beobachteten Gerät die Details.";
}
await loadOverview();
} catch (error) {
alert(error.message);
}
}
async function startDashboard() {
document.documentElement.lang = uiLang;
applyStaticTranslations();
document.getElementById("status").innerHTML = `<p class='muted'>${escapeHtml(uiLang === "en" ? "Status loading ..." : "Status lädt nach ...")}</p>`;
document.getElementById("configured-actuators").innerHTML = `<div class='empty-state'>${escapeHtml(uiLang === "en" ? "Open Learning to load devices." : "Öffne „Lernen“, um Geräte zu laden.")}</div>`;
document.getElementById("actuator-detail").innerHTML = `<div class='empty-state'>${escapeHtml(uiLang === "en" ? "Select a device from the overview later." : "Wähle später ein Gerät aus der Übersicht.")}</div>`;
syncSettingsView();
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);
}
void startDashboard();
</script>
</body>
</html>