From d87d3abc00fe22c1169df5b4d45188d068dd4f56 Mon Sep 17 00:00:00 2001 From: Otto Date: Sun, 14 Jun 2026 22:52:42 +0200 Subject: [PATCH] fix: batch ha metadata and improve mobile dashboard --- CHANGELOG.md | 9 +++ addon/config.yaml | 2 +- app/ha/client.py | 12 ++++ app/ha/reader.py | 15 ++++- app/main.py | 2 +- app/static/index.html | 118 ++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- tests/ha/test_ha_client.py | 21 +++++++ tests/ha/test_ha_reader.py | 21 +++++++ 9 files changed, 170 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef16134..5a9a439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.7.2 - 2026-06-14 +- Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA- + Installationen nicht mehr am Template-Ausgabe-Limit scheitern +- Nicht über die HA-Config-API exponierte Automationen werden leise übersprungen, + statt wiederholt Warnungen in die Logs zu schreiben +- Dashboard für mobile Nutzung optimiert: Sticky-Schnellnavigation, Karten statt + breiter Tabelle, größere Touch-Ziele und bessere Detail-/Menüführung +- WebSocket-Status ist direkt im Dashboard-Systemstatus sichtbar + ## 0.7.1 - 2026-06-14 - Event-basierter Home-Assistant-WebSocket-Listener authentifiziert sich jetzt mit dem echten HA-WebSocket-Protokoll (`auth_required` -> `auth` -> `auth_ok`) diff --git a/addon/config.yaml b/addon/config.yaml index 8faa97f..b13f328 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.1" +version: "0.7.2" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/ha/client.py b/app/ha/client.py index ab92709..d5a5447 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -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: diff --git a/app/ha/reader.py b/app/ha/reader.py index 653411c..8103015 100644 --- a/app/ha/reader.py +++ b/app/ha/reader.py @@ -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", diff --git a/app/main.py b/app/main.py index 70f593e..ab733a1 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/static/index.html b/app/static/index.html index 79eeff6..38964e6 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,14 +5,18 @@ SillyHome Next @@ -41,8 +75,15 @@

Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.

Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.

+
-
+

So gehst du vor

@@ -66,7 +107,7 @@
-
+

Systemstatus

Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.

Prüfung läuft ...
@@ -74,7 +115,7 @@
-
+

1. Gerät zum Lernen auswählen

Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.

@@ -84,13 +125,13 @@

Noch kein Aktor ausgewählt.

-
+

2. Beobachtete Geräte

Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.

Noch nicht geladen.
-
+

3. Lernfortschritt und Freigabe

Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.

Öffne bei einem beobachteten Gerät die Details.
@@ -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 = `

System bereit

Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}

`; chips.innerHTML = [ `API: ${escapeHtml(health.status)}`, + `WebSocket: ${escapeHtml(websocket.status)}`, `Lernsystem: ${escapeHtml(ml.status)}`, `Aktoren: ${actuators.length}`, `Lernbereite Geräte: ${reconciliation.trained_models}`, @@ -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 ? ` - - +
${rows.map(record => ` -
- - - - - - - + + `).join("")} -
GerätLernstatusFreigabeGelernte HandlungenLetzte VorhersageAktionen
${escapeHtml(record.actuator_entity_id)}${escapeHtml(behaviorLabel(record))}${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}${record.behavior.sample_count}${record.behavior.prediction - ? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)` - : "-"} - +
+
+
+
${escapeHtml(record.actuator_entity_id)}
+
${escapeHtml(behaviorLabel(record))}
+
+ ${escapeHtml(lifecycleLabel(record))} +
+
+
Freigabe${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}
+
Handlungen${record.behavior.sample_count}
+
Vorhersage${escapeHtml(predictionLabel(record))}
+
+
+ ${record.behavior.mode === "active" ? `` : record.behavior.activation_ready ? `` : ""} -
` : "

Noch keine Aktoren ausgewählt.

"; + ` : "

Noch keine Aktoren ausgewählt.

"; } catch (error) { box.textContent = error.message; } @@ -268,13 +322,20 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${escapeHtml(automation.friendly_name)} ${escapeHtml(automation.entity_id)}: ${automation.enabled ? "aktiv" : "pausiert"} - + `).join("")}` : "

Keine eindeutig passende HA-Automation gefunden.

"; box.innerHTML = ` -
+

${escapeHtml(record.actuator_entity_id)}

+

Alle wichtigen Aktionen für dieses Gerät.

+
+ +
+
+
+

Zuordnung

Status: ${escapeHtml(lifecycleLabel(record))}

Kontextzuordnung: automatisch erledigt

Zuordnungssicherheit: ${Math.round(record.assignment.confidence * 100)} %

@@ -290,7 +351,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {

Letztes Training: ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}

Was noch passiert: ${escapeHtml(record.behavior.reason)}

Freigabestatus: ${escapeHtml(record.behavior.activation_reason)}

- ${activationButton} +
${activationButton}

Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.

${evaluationMessage ? `

${escapeHtml(evaluationMessage)}

` : ""} @@ -306,6 +367,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {

Welche Zusammenhänge automatisch verwendet werden

${evidence ? `
    ${evidence}
` : "

Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.

"} `; + document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); } catch (error) { box.textContent = error.message; } diff --git a/pyproject.toml b/pyproject.toml index f33edb3..2e52ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.1" +version = "0.7.2" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py index 3f18516..9309712 100644 --- a/tests/ha/test_ha_client.py +++ b/tests/ha/test_ha_client.py @@ -108,6 +108,27 @@ def test_list_entity_metadata_calls_template_api() -> None: } +def test_list_entity_metadata_batches_template_calls() -> None: + responses = [] + for index in range(3): + response = _response() + response.text = ( + f'[{{"entity_id":"sensor.test_{index}",' + f'"area_name":"Area {index}","device_name":"Device {index}"}}]' + ) + responses.append(response) + client = HaClient(HaClientSettings(url="http://ha.local", token="test-token")) + client._session.post = Mock(side_effect=responses) # type: ignore[method-assign] + + entity_ids = [f"sensor.test_{index}" for index in range(401)] + metadata = client.list_entity_metadata(entity_ids) + + assert client._session.post.call_count == 3 + assert metadata["sensor.test_0"]["area_name"] == "Area 0" + assert metadata["sensor.test_1"]["device_name"] == "Device 1" + assert metadata["sensor.test_2"]["device_name"] == "Device 2" + + def test_get_logbook_filters_entity_and_period() -> None: response = _response(payload=[{"entity_id": "light.office"}]) client = _client_with_response(response) diff --git a/tests/ha/test_ha_reader.py b/tests/ha/test_ha_reader.py index 2ceef09..af34248 100644 --- a/tests/ha/test_ha_reader.py +++ b/tests/ha/test_ha_reader.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone from app.ha.client import HaClient, HaClientSettings +from app.ha.exceptions import HaHttpError from app.ha.reader import HaReader @@ -150,3 +151,23 @@ def test_ha_reader_finds_automation_that_targets_entity() -> None: assert len(matches) == 1 assert matches[0].entity_id == "automation.storage_light" assert matches[0].enabled is True + + +def test_ha_reader_ignores_automation_configs_not_exposed_by_ha() -> None: + client = FakeHaClient() + client.list_entities = lambda: [ # type: ignore[method-assign] + { + "entity_id": "automation.storage_light", + "state": "on", + "attributes": { + "id": "123", + "friendly_name": "Storage light", + }, + } + ] + client.get_automation_config = lambda automation_id: (_ for _ in ()).throw( # type: ignore[method-assign] + HaHttpError(404, "Resource not found") + ) + reader = HaReader(client) + + assert reader.find_automations_for_entity("light.storage") == []