1090 lines
51 KiB
HTML
1090 lines
51 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; }
|
|
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; }
|
|
.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); }
|
|
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 18px; background:rgba(14,18,24,.96); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); }
|
|
.quick-nav a { flex:0 0 auto; min-height:36px; display:grid; place-items:center; padding:8px 12px; border-radius:8px; background:#151c25; border:1px solid var(--border); color:#f3f7fb; text-decoration:none; font-weight:750; font-size:.92rem; }
|
|
.quick-nav a.primary { background:var(--accent); border-color:var(--accent); color:#211204; }
|
|
main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; 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; }
|
|
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; }
|
|
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; }
|
|
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:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
|
|
details[open].collapsible > summary::after,
|
|
.manual-context[open] > summary::after,
|
|
.group-panel[open] > summary::after { content:"zuklappen"; }
|
|
.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; }
|
|
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; font-size: .92rem; }
|
|
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
|
|
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; }
|
|
.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; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
|
|
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
|
.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; }
|
|
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
|
.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; }
|
|
.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; }
|
|
.status-pill { width:max-content; }
|
|
main { display:block; 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; }
|
|
.quick-nav { padding:8px 10px; }
|
|
.quick-nav a { padding:10px 11px; }
|
|
}
|
|
@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 Next</h1>
|
|
</div>
|
|
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
|
|
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
|
</div>
|
|
<div class="status-pill"><span class="dot"></span><span id="load-budget">Startdaten laden ...</span></div>
|
|
</div>
|
|
</header>
|
|
<nav class="quick-nav" aria-label="Schnellnavigation">
|
|
<a class="primary" href="#choose">Steuerung</a>
|
|
<a href="#observed">Geräte</a>
|
|
<a href="#detail">Freigabe</a>
|
|
<a href="#status-section">System</a>
|
|
<a href="#guide">Ablauf</a>
|
|
</nav>
|
|
<main>
|
|
<section class="control-panel" id="choose">
|
|
<div class="panel-title">
|
|
<h2>Steuerung</h2>
|
|
<span class="chip">v1</span>
|
|
</div>
|
|
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
|
|
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
|
<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">Oder aus Liste wählen</label>
|
|
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
|
|
<option value="">Geräteliste bei Bedarf laden</option>
|
|
</select>
|
|
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
|
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button>
|
|
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
|
<details class="manual-context">
|
|
<summary>Vorschläge anzeigen</summary>
|
|
<p class="muted">Vorschläge können Home Assistant stark abfragen und werden deshalb nicht beim Start geladen.</p>
|
|
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
|
|
</details>
|
|
<div id="actuator-suggestions" class="card-list"></div>
|
|
</section>
|
|
|
|
<section class="board-panel" id="observed">
|
|
<div class="panel-title">
|
|
<div>
|
|
<h2>Beobachtete Geräte</h2>
|
|
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
|
</div>
|
|
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
|
|
</div>
|
|
<div id="configured-actuators">Noch nicht geladen.</div>
|
|
</section>
|
|
|
|
<section class="detail-panel" id="detail">
|
|
<h2>Lernfortschritt und Freigabe</h2>
|
|
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
|
|
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
|
</section>
|
|
|
|
<section class="status-panel" id="status-section">
|
|
<div class="panel-title">
|
|
<div>
|
|
<h2>System & Cache</h2>
|
|
<p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt erst bei Bedarf.</p>
|
|
</div>
|
|
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
|
|
</div>
|
|
<div id="status">Prüfung läuft ...</div>
|
|
<div class="chips" id="status-chips"></div>
|
|
<div id="dashboard-stats" class="metric-grid"></div>
|
|
</section>
|
|
|
|
<section class="guide-panel" id="guide">
|
|
<details class="collapsible">
|
|
<summary><span>So gehst du vor</span></summary>
|
|
<div class="steps">
|
|
<div class="step">
|
|
<span class="step-number">1</span>
|
|
<h3>Aktor auswählen</h3>
|
|
<p><strong>Wo?</strong> Links im Feld „Gerät auswählen“.</p>
|
|
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
|
|
</div>
|
|
<div class="step">
|
|
<span class="step-number">2</span>
|
|
<h3>Wie gewohnt bedienen</h3>
|
|
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
|
|
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
|
|
</div>
|
|
<div class="step">
|
|
<span class="step-number">3</span>
|
|
<h3>Später freigeben</h3>
|
|
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
|
|
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
const escapeHtml = value => String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
let currentActuatorId = null;
|
|
let actuatorChoices = [];
|
|
let contextOptions = [];
|
|
let manualContextState = {options: [], selected: new Set()};
|
|
let cachedActuators = null;
|
|
let cachedEntities = null;
|
|
let cachedDiscovery = null;
|
|
let discoveryLoadPromise = null;
|
|
const ACTUATOR_RESULT_LIMIT = 50;
|
|
const STATUS_TIMEOUT_MS = 2000;
|
|
const DASHBOARD_TIMEOUT_MS = 4500;
|
|
|
|
function uniqueValues(values) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function invalidateDashboardCache() {
|
|
cachedActuators = null;
|
|
cachedEntities = null;
|
|
cachedDiscovery = null;
|
|
}
|
|
|
|
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});
|
|
} 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 "Kontext erkannt";
|
|
const labels = {
|
|
trained: "lernt",
|
|
pending_history: "sammelt Historie",
|
|
pending_assignment: "sucht Kontext",
|
|
review_required: "geringe Zuordnungssicherheit",
|
|
archived: "wartet auf Kontext",
|
|
orphaned: "Aktor nicht gefunden",
|
|
};
|
|
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 "aktiv freigegeben";
|
|
if (status === "trained") return "Shadow-Vorhersage";
|
|
if (status === "blocked") return "Lernen blockiert";
|
|
return "sammelt Handlungen";
|
|
}
|
|
|
|
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)} %)`
|
|
: "Keine fällige Aktion";
|
|
}
|
|
|
|
function entityLabel(entity) {
|
|
const area = entity.area_name || "Ohne Bereich";
|
|
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() {
|
|
const startedAt = performance.now();
|
|
const budget = document.getElementById("load-budget");
|
|
if (budget) budget.textContent = "Startdaten laden ...";
|
|
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
|
try {
|
|
const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS);
|
|
cachedActuators = dashboard.actuators || [];
|
|
cachedEntities = [];
|
|
renderDashboardStatus(dashboard);
|
|
renderConfiguredActuators();
|
|
if (budget) budget.textContent = `Bereit in ${Math.round(performance.now() - startedAt)} ms`;
|
|
} catch (error) {
|
|
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
|
if (budget) budget.textContent = "Startdaten verzögert";
|
|
try {
|
|
await loadSummaryData();
|
|
renderConfiguredActuators();
|
|
} catch (_) {
|
|
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>";
|
|
}
|
|
}
|
|
}
|
|
|
|
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, actuators] = await Promise.allSettled([
|
|
apiWithTimeout("health"),
|
|
apiWithTimeout("health/websocket"),
|
|
apiWithTimeout("ml/health"),
|
|
apiWithTimeout("v1/actuators/reconciliation/state"),
|
|
apiWithTimeout("v1/actuators/summary"),
|
|
]);
|
|
const values = [health, websocket, ml, reconciliation, actuators].map(result =>
|
|
result.status === "fulfilled" ? result.value : null
|
|
);
|
|
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = 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(reconciliationValue.last_completed_at || "noch nie")}</p>`;
|
|
chips.innerHTML = [
|
|
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
|
|
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
|
|
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
|
|
`<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "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 = "";
|
|
}
|
|
}
|
|
|
|
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 discoveryGroups = dashboard.discovery_groups || [];
|
|
const cacheLabel = cache.available
|
|
? `Cache aktuell mit ${cache.entity_count} Entities`
|
|
: "Cache wird nach Discovery aufgebaut";
|
|
status.innerHTML = `
|
|
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">
|
|
Dashboard bereit. WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}
|
|
</p>
|
|
<p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</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>`,
|
|
].join("");
|
|
stats.innerHTML = [
|
|
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml((dashboard.actuators || []).length)} Geräte</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("");
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
}
|
|
|
|
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 = "";
|
|
}
|
|
}
|
|
|
|
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("");
|
|
}
|
|
|
|
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 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>`;
|
|
}
|
|
|
|
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 groups = new Map();
|
|
for (const record of rows) {
|
|
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));
|
|
box.innerHTML = rows.length ? `
|
|
${groupedRows.map(([group, items]) => `
|
|
<details class="group-panel" open>
|
|
<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("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function showActuator(actuatorId, evaluationMessage = "") {
|
|
currentActuatorId = actuatorId;
|
|
const box = document.getElementById("actuator-detail");
|
|
try {
|
|
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
|
await loadContextOptions(actuatorId);
|
|
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 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 learnedAutomationActions = record.behavior.patterns.filter(
|
|
pattern => pattern.source === "automation",
|
|
).length;
|
|
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" open>
|
|
<summary>Kontext selbst festlegen</summary>
|
|
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
|
|
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
|
<select id="manual-numeric-select">
|
|
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
|
${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="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${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>";
|
|
box.innerHTML = `
|
|
<div class="detail-header">
|
|
<div>
|
|
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
|
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
|
|
</div>
|
|
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button>
|
|
</div>
|
|
<div class="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 class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
|
|
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
|
|
</div>
|
|
<div>
|
|
<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>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p>
|
|
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
|
|
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
|
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
|
<div class="actions">${activationButton}</div>
|
|
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
|
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
|
|
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
|
</div>
|
|
</div>
|
|
<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>
|
|
<h3>Passende Home-Assistant-Automationen</h3>
|
|
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
|
|
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
|
${automationControls}
|
|
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
|
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
|
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
|
${currentContextControls}
|
|
${manualAssignment}
|
|
`;
|
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
|
} catch (error) {
|
|
box.textContent = 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 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);
|
|
}
|
|
}
|
|
|
|
loadOverview();
|
|
</script>
|
|
</body>
|
|
</html>
|