fix: batch ha metadata and improve mobile dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-14 22:52:42 +02:00
parent 2ae5576b8f
commit d87d3abc00
9 changed files with 170 additions and 32 deletions

View File

@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_SERVICE_PART_PATTERN = re.compile(r"^[a-z0-9_]+$")
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
_METADATA_BATCH_SIZE = 200
@dataclass(frozen=True)
@@ -141,6 +142,17 @@ class HaClient:
return {}
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
raise ValueError("entity_id enthält ein ungültiges Format.")
result: dict[str, dict[str, str | None]] = {}
for start in range(0, len(entity_ids), _METADATA_BATCH_SIZE):
result.update(
self._list_entity_metadata_batch(entity_ids[start:start + _METADATA_BATCH_SIZE])
)
return result
def _list_entity_metadata_batch(
self,
entity_ids: list[str],
) -> dict[str, dict[str, str | None]]:
template = _metadata_template(entity_ids)
rendered = self._post_text("/api/template", {"template": template})
try:

View File

@@ -6,7 +6,7 @@ from threading import RLock
from typing import Any
import logging
from app.ha.exceptions import HaClientError
from app.ha.exceptions import HaClientError, HaHttpError
from app.ha.client import HaClient
from app.ha.discovery import DiscoveredEntity, discover_entities
@@ -167,6 +167,19 @@ class HaReader:
continue
try:
config = self._client.get_automation_config(config_id)
except HaHttpError as exc:
if exc.status_code == 404:
logger.info(
"Automation config not exposed by Home Assistant for %s.",
raw_entity_id,
)
continue
logger.warning(
"Automation config unavailable for %s: %s",
raw_entity_id,
exc,
)
continue
except (HaClientError, ValueError) as exc:
logger.warning(
"Automation config unavailable for %s: %s",

View File

@@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.1",
version="0.7.2",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -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;
}