|
|
|
|
@@ -5,14 +5,18 @@
|
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
|
|
<title>SillyHome Next</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
|
|
|
|
|
body { margin: 0; }
|
|
|
|
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
|
|
|
|
body { margin: 0; font-size:16px; }
|
|
|
|
|
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
|
|
|
|
h1,h2,h3 { margin: 0 0 12px; }
|
|
|
|
|
header p { margin: 5px 0; color: #c3d1dc; }
|
|
|
|
|
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
|
|
|
|
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
|
|
|
|
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
|
|
|
|
.wide { grid-column: 1 / -1; }
|
|
|
|
|
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
|
|
|
|
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
|
|
|
|
.quick-nav a.primary { background:#23715b; }
|
|
|
|
|
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
|
|
|
|
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
|
|
|
|
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
|
|
|
|
@@ -21,10 +25,11 @@
|
|
|
|
|
.warn { color: #f3c969; }
|
|
|
|
|
.bad { color: #ff8f8f; }
|
|
|
|
|
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
|
|
|
|
select,input,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; }
|
|
|
|
|
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
|
|
|
|
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
|
|
|
|
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
|
|
|
|
button.secondary { background: #37495c; }
|
|
|
|
|
button.danger { background: #7b3434; }
|
|
|
|
|
button.compact { width:auto; min-width:120px; margin-right:8px; }
|
|
|
|
|
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; }
|
|
|
|
|
@@ -33,6 +38,35 @@
|
|
|
|
|
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
|
|
|
|
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
|
|
|
|
.muted { color:#9fb0be; }
|
|
|
|
|
.card-list { display:grid; gap:12px; }
|
|
|
|
|
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
|
|
|
|
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
|
|
|
|
.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(150px,1fr)); gap:8px; margin:10px 0; }
|
|
|
|
|
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
|
|
|
|
.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; }
|
|
|
|
|
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
|
|
|
|
@media (max-width: 760px) {
|
|
|
|
|
header { padding:18px 14px; }
|
|
|
|
|
header h1 { font-size:1.55rem; }
|
|
|
|
|
main { display:block; padding:10px; }
|
|
|
|
|
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
|
|
|
|
.steps { grid-template-columns:1fr; }
|
|
|
|
|
.grid-two { grid-template-columns:1fr; }
|
|
|
|
|
.metric-grid { grid-template-columns:1fr 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>
|
|
|
|
|
@@ -41,8 +75,15 @@
|
|
|
|
|
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
|
|
|
|
|
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
|
|
|
|
</header>
|
|
|
|
|
<nav class="quick-nav" aria-label="Schnellnavigation">
|
|
|
|
|
<a class="primary" href="#choose">Gerät wählen</a>
|
|
|
|
|
<a href="#observed">Beobachtet</a>
|
|
|
|
|
<a href="#detail">Details</a>
|
|
|
|
|
<a href="#status-section">Status</a>
|
|
|
|
|
<a href="#guide">Ablauf</a>
|
|
|
|
|
</nav>
|
|
|
|
|
<main>
|
|
|
|
|
<section class="wide">
|
|
|
|
|
<section class="wide" id="guide">
|
|
|
|
|
<h2>So gehst du vor</h2>
|
|
|
|
|
<div class="steps">
|
|
|
|
|
<div class="step">
|
|
|
|
|
@@ -66,7 +107,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<section id="status-section">
|
|
|
|
|
<h2>Systemstatus</h2>
|
|
|
|
|
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
|
|
|
|
|
<div id="status">Prüfung läuft ...</div>
|
|
|
|
|
@@ -74,7 +115,7 @@
|
|
|
|
|
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<section id="choose">
|
|
|
|
|
<h2>1. Gerät zum Lernen auswählen</h2>
|
|
|
|
|
<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>
|
|
|
|
|
@@ -84,13 +125,13 @@
|
|
|
|
|
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="wide">
|
|
|
|
|
<section class="wide" id="observed">
|
|
|
|
|
<h2>2. Beobachtete Geräte</h2>
|
|
|
|
|
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
|
|
|
|
<div id="configured-actuators">Noch nicht geladen.</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="wide">
|
|
|
|
|
<section class="wide" id="detail">
|
|
|
|
|
<h2>3. 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>
|
|
|
|
|
@@ -139,12 +180,19 @@ function behaviorLabel(record) {
|
|
|
|
|
return "sammelt Handlungen";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function predictionLabel(record) {
|
|
|
|
|
return record.behavior.prediction
|
|
|
|
|
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
|
|
|
|
: "Keine fällige Aktion";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOverview() {
|
|
|
|
|
const status = document.getElementById("status");
|
|
|
|
|
const chips = document.getElementById("status-chips");
|
|
|
|
|
try {
|
|
|
|
|
const [health, ml, reconciliation, actuators] = await Promise.all([
|
|
|
|
|
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
|
|
|
|
api("health"),
|
|
|
|
|
api("health/websocket"),
|
|
|
|
|
api("ml/health"),
|
|
|
|
|
api("v1/actuators/reconciliation/state"),
|
|
|
|
|
api("v1/actuators"),
|
|
|
|
|
@@ -152,6 +200,7 @@ async function loadOverview() {
|
|
|
|
|
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
|
|
|
|
chips.innerHTML = [
|
|
|
|
|
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
|
|
|
|
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
|
|
|
|
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
|
|
|
|
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
|
|
|
|
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
|
|
|
|
@@ -194,6 +243,7 @@ async function configureActuator() {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
@@ -204,29 +254,33 @@ async function loadConfiguredActuators() {
|
|
|
|
|
try {
|
|
|
|
|
const rows = await api("v1/actuators");
|
|
|
|
|
box.innerHTML = rows.length ? `
|
|
|
|
|
<table>
|
|
|
|
|
<tr><th>Gerät</th><th>Lernstatus</th><th>Freigabe</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
|
|
|
|
|
<div class="card-list">
|
|
|
|
|
${rows.map(record => `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${escapeHtml(record.actuator_entity_id)}</td>
|
|
|
|
|
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
|
|
|
|
|
<td class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</td>
|
|
|
|
|
<td>${record.behavior.sample_count}</td>
|
|
|
|
|
<td>${record.behavior.prediction
|
|
|
|
|
? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
|
|
|
|
: "-"}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button>
|
|
|
|
|
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
|
|
|
|
<div class="card-title">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
|
|
|
|
<div class="${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.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
|
|
|
|
|
<div class="metric"><strong>Handlungen</strong>${record.behavior.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.behavior.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>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
`).join("")}
|
|
|
|
|
</table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
|
|
|
|
</div>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
|
|
|
|
} catch (error) {
|
|
|
|
|
box.textContent = error.message;
|
|
|
|
|
}
|
|
|
|
|
@@ -268,13 +322,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
<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" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
|
|
|
|
<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="grid-two">
|
|
|
|
|
<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>
|
|
|
|
|
@@ -290,7 +351,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
<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>
|
|
|
|
|
${activationButton}
|
|
|
|
|
<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>` : ""}
|
|
|
|
|
@@ -306,6 +367,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
<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>"}
|
|
|
|
|
`;
|
|
|
|
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
box.textContent = error.message;
|
|
|
|
|
}
|
|
|
|
|
|