323 lines
15 KiB
HTML
323 lines
15 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: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
|
|
body { margin: 0; }
|
|
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; }
|
|
.wide { grid-column: 1 / -1; }
|
|
.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; }
|
|
.step p { margin:5px 0; }
|
|
.ok { color: #66dfa9; }
|
|
.warn { color: #f3c969; }
|
|
.bad { color: #ff8f8f; }
|
|
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
|
select,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; }
|
|
button.secondary { background: #37495c; }
|
|
button.danger { background: #7b3434; }
|
|
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 #66dfa9; 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:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
|
.muted { color:#9fb0be; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>SillyHome Next</h1>
|
|
<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>
|
|
<main>
|
|
<section class="wide">
|
|
<h2>So gehst du vor</h2>
|
|
<div class="steps">
|
|
<div class="step">
|
|
<span class="step-number">1</span>
|
|
<h3>Aktor auswählen</h3>
|
|
<p><strong>Wo?</strong> Unten 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>
|
|
</section>
|
|
|
|
<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>
|
|
<div class="chips" id="status-chips"></div>
|
|
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
|
|
</section>
|
|
|
|
<section>
|
|
<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-select">Gerät aus Home Assistant</label>
|
|
<select id="actuator-select"></select>
|
|
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
|
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
|
</section>
|
|
|
|
<section class="wide">
|
|
<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">
|
|
<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>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
const escapeHtml = value => String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
let currentActuatorId = 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;
|
|
}
|
|
|
|
function lifecycleLabel(record) {
|
|
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[record.lifecycle.status] || record.lifecycle.status;
|
|
}
|
|
|
|
function statusClass(record) {
|
|
if (record.lifecycle.status === "trained") return "ok";
|
|
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
|
|
return "bad";
|
|
}
|
|
|
|
function behaviorLabel(record) {
|
|
if (record.behavior.mode === "active") return "aktiv freigegeben";
|
|
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
|
|
if (record.behavior.status === "blocked") return "Lernen blockiert";
|
|
return "sammelt Handlungen";
|
|
}
|
|
|
|
async function loadOverview() {
|
|
const status = document.getElementById("status");
|
|
const chips = document.getElementById("status-chips");
|
|
try {
|
|
const [health, ml, reconciliation, actuators] = await Promise.all([
|
|
api("health"),
|
|
api("ml/health"),
|
|
api("v1/actuators/reconciliation/state"),
|
|
api("v1/actuators"),
|
|
]);
|
|
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">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
|
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
|
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
|
].join("");
|
|
} catch (error) {
|
|
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
|
chips.innerHTML = "";
|
|
}
|
|
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
|
|
}
|
|
|
|
async function loadActuatorDiscovery() {
|
|
const select = document.getElementById("actuator-select");
|
|
try {
|
|
const [available, configured] = await Promise.all([
|
|
api("v1/actuators/discovery"),
|
|
api("v1/actuators"),
|
|
]);
|
|
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
|
|
const choices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
|
select.innerHTML = choices.length
|
|
? choices.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`).join("")
|
|
: "<option value=''>Alle erkannten Aktoren sind ausgewählt</option>";
|
|
} catch (error) {
|
|
select.innerHTML = `<option value="">${escapeHtml(error.message)}</option>`;
|
|
}
|
|
}
|
|
|
|
async function configureActuator() {
|
|
const actuatorId = document.getElementById("actuator-select").value;
|
|
const result = document.getElementById("actuator-config-result");
|
|
if (!actuatorId) 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);
|
|
} catch (error) {
|
|
result.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function loadConfiguredActuators() {
|
|
const box = document.getElementById("configured-actuators");
|
|
try {
|
|
const rows = await api("v1/actuators");
|
|
box.innerHTML = rows.length ? `
|
|
<table>
|
|
<tr><th>Gerät</th><th>Lernstatus</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
|
|
${rows.map(record => `
|
|
<tr>
|
|
<td>${escapeHtml(record.actuator_entity_id)}</td>
|
|
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</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>
|
|
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
|
</td>
|
|
</tr>
|
|
`).join("")}
|
|
</table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function showActuator(actuatorId) {
|
|
currentActuatorId = actuatorId;
|
|
const box = document.getElementById("actuator-detail");
|
|
try {
|
|
const record = await api(`v1/actuators/${encodeURIComponent(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>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
|
.join("");
|
|
const prediction = record.behavior.prediction;
|
|
const activationButton = record.behavior.mode === "active"
|
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
|
|
: record.behavior.status === "trained"
|
|
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
|
|
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
|
|
box.innerHTML = `
|
|
<div class="grid-two">
|
|
<div>
|
|
<h3>${escapeHtml(record.actuator_entity_id)}</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 Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</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>
|
|
${activationButton}
|
|
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Vorhersage jetzt prüfen</button>
|
|
</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)} ${prediction.executed ? "<span class='ok'>Ausgeführt.</span>" : "<span class='muted'>Nicht ausgeführt.</span>"}</p>`
|
|
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
|
|
<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>"}
|
|
`;
|
|
} catch (error) {
|
|
box.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function evaluateActuator(actuatorId) {
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`, {method: "POST"});
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function setActivation(actuatorId, active) {
|
|
const question = active
|
|
? `${actuatorId} wirklich für autonomes Lernen und Schalten freigeben?`
|
|
: `${actuatorId} wieder in den Shadow-Modus setzen?`;
|
|
if (!confirm(question)) return;
|
|
try {
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
|
|
method: "POST",
|
|
body: JSON.stringify({active}),
|
|
});
|
|
await loadConfiguredActuators();
|
|
await showActuator(actuatorId);
|
|
} 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>
|