Compare commits

..

6 Commits

Author SHA1 Message Date
63b8684197 Document v1 acceptance and dashboard stats
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:45:30 +02:00
f8801e469a Polish v1 dashboard loading and layout
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:33:30 +02:00
787516ac67 Avoid per-request discovery classification in dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:19:59 +02:00
7ba9807a4e Prepare SillyHome Next 1.0.0 dashboard and API rework
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:13:43 +02:00
4db4276b95 Rework dashboard loading and cache entity metadata
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:41:50 +02:00
98a2b2cc38 Fix dashboard summary status rendering
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:21:14 +02:00
13 changed files with 933 additions and 146 deletions

View File

@@ -1,5 +1,53 @@
# Changelog # Changelog
## 1.0.2 - 2026-06-17
- v1.0-Abnahme als `docs/V1_0_ACCEPTANCE.md` dokumentiert: erledigte,
teilweise erledigte und offene v1.0.x-Punkte sind getrennt sichtbar.
- Dashboard-Startstatistik erweitert: Freigabebereitschaft, Aktiv/Shadow,
Gelernt/Wartet und gelernte Handlungen werden direkt im Startbereich
zusammengefasst.
## 1.0.1 - 2026-06-17
- Dashboard-UI nach v1-Korrektur neu strukturiert: feste Steuerungsleiste,
separate Geräteübersicht, klare Freigabe-/Detailfläche und Statusbereich.
- Orange bleibt Primärfarbe; Cyan ist die sichtbare Komplementärfarbe. Rote
Aktions- und Fehlerflächen wurden aus der Oberfläche entfernt.
- Startpfad weiter beschleunigt: Dashboard lädt nur noch lokale Startdaten.
HA-Discovery, Vorschläge und Automation-Refresh laufen erst nach Nutzeraktion.
- Detailansicht öffnet ohne automatische Automation-Discovery. Passende
Automationen können gezielt per Button neu gesucht werden.
## 1.0.0 - 2026-06-17
- Neuer blockweiser Dashboard-Start über `/v1/actuators/dashboard`: lokale
Store-/Cache-Daten laden sofort, HA-Discovery und Vorschläge laufen
nachgelagert.
- Discovery liest Entities pro Anfrage nur noch einmal und klassifiziert aus
diesem Snapshot weiter. Dadurch entfallen doppelte HA-Vollabfragen.
- Persistenter JSON-Entity-Cache wird für Friendly Name, Raum, Gerät,
Discovery-Gruppen und schnelle Summaries genutzt.
- Dashboard mit Orange als Primärfarbe, kompakter Navigation, aufklappbarer
Anleitung, aufklappbaren Gerätegruppen und Cache-/Systemstatistik.
- Aktor-/Sensor-Kategorien erweitert: Feuchte, Wetter, Helligkeit, Bewegung,
Tür/Fenster, Präsenz, Lichtzustände, Schalter, Steckdosen, Lüftung, Heizung,
Cover, Helper, PV/Akku/Einspeisung.
- Kontextvorschläge vermeiden weitere doppelte HA-Discovery und sortieren
aktortypbezogen nach relevanten Bereichen.
## 0.7.21 - 2026-06-17
- Dashboard-Ladepfad getrennt: beobachtete Geräte laden sofort über
`/v1/actuators/summary`; Status, Discovery und Vorschläge laufen unabhängig
nachgelagert und blockieren die Übersicht nicht mehr.
- Systemstatus nutzt Timeouts und bleibt auch bei langsamem ML-/HA-Status
bedienbar.
- HA-Entity-Metadaten werden als JSON-Cache gespeichert und für Friendly Name,
Raum und Gerät in schlanken Summaries wiederverwendet.
- Anleitung, Gerätegruppen und manuelle Kontextauswahl sind aufklappbar und
kompakter für Smartphone- und Desktopansichten.
## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
## 0.7.19 - 2026-06-17 ## 0.7.19 - 2026-06-17
- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt - Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt
statt voller Lernmuster und kompletter HA-Entityliste. statt voller Lernmuster und kompletter HA-Entityliste.

View File

@@ -11,6 +11,10 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md) [`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md)
- Entwickeln, testen, veröffentlichen und installieren: - Entwickeln, testen, veröffentlichen und installieren:
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) [`docs/OPERATIONS.md`](docs/OPERATIONS.md)
- Version 1.0.0 bedienen und prüfen:
[`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md)
- Version 1.0.x Abnahme und offene Punkte:
[`docs/V1_0_ACCEPTANCE.md`](docs/V1_0_ACCEPTANCE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## Reifegrad
@@ -58,6 +62,8 @@ uvicorn app.main:app --reload
- `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities - `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen - `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow - `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
- `http://127.0.0.1:8000/v1/actuators/dashboard` - schnelle Dashboard-Startdaten aus Store und JSON-Cache
- `http://127.0.0.1:8000/v1/actuators/summary` - schlanke Liste beobachteter Aktoren
- `POST http://127.0.0.1:8000/v1/actuators` - Aktor freigeben; Kontextzuordnung und Modell-Lebenszyklus starten automatisch - `POST http://127.0.0.1:8000/v1/actuators` - Aktor freigeben; Kontextzuordnung und Modell-Lebenszyklus starten automatisch
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/evaluate` - Shadow-Vorhersage aktualisieren - `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/evaluate` - Shadow-Vorhersage aktualisieren
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen - `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.19" version: "1.0.2"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -20,7 +20,7 @@ from app.actuators.models import (
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.config import Settings from app.config import Settings
from app.ha.discovery import DiscoveredEntity, EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint from app.ha.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -170,7 +170,7 @@ class ActuatorReconciliationService:
limit: int = _CONTEXT_SUGGESTION_LIMIT, limit: int = _CONTEXT_SUGGESTION_LIMIT,
) -> list[HaEntitySummary]: ) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()} discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
actuator = entities.get(actuator_entity_id) actuator = entities.get(actuator_entity_id)
if actuator is None: if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.") raise KeyError("Aktuator-Konfiguration nicht gefunden.")
@@ -736,6 +736,11 @@ def _manual_context_role(
def _context_sort_group(entity: HaEntitySummary) -> str: def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or "" device_class = entity.device_class or ""
text = " ".join(
value.lower().replace("_", " ")
for value in [entity.entity_id, entity.friendly_name, entity.area_name, entity.device_name]
if value
)
if device_class in {"motion", "occupancy", "presence"}: if device_class in {"motion", "occupancy", "presence"}:
return "01_presence" return "01_presence"
if device_class in {"illuminance"}: if device_class in {"illuminance"}:
@@ -744,10 +749,26 @@ def _context_sort_group(entity: HaEntitySummary) -> str:
return "03_opening" return "03_opening"
if device_class in {"humidity", "moisture"}: if device_class in {"humidity", "moisture"}:
return "04_humidity" return "04_humidity"
if device_class in {"temperature"}:
return "05_temperature"
if any(token in text for token in {"pv", "solar", "akku", "batterie", "battery", "einspeisung"}):
return "06_pv_battery"
if device_class in {"power", "energy", "current", "voltage"}: if device_class in {"power", "energy", "current", "voltage"}:
return "05_power" return "07_power"
if entity.domain in {"weather"}:
return "08_weather"
if entity.domain in {"fan", "humidifier"}:
return "09_ventilation"
if entity.domain in {"climate"}:
return "10_heating"
if entity.domain in {"cover"}:
return "11_cover"
if entity.domain in {"light", "switch"}: if entity.domain in {"light", "switch"}:
return "06_states" return "12_states"
if entity.domain.startswith("input_"):
return "13_helper"
if entity.domain in {"person", "device_tracker"}:
return "14_people"
return f"20_{entity.domain}_{device_class}" return f"20_{entity.domain}_{device_class}"
@@ -900,11 +921,12 @@ def _merge_manual_candidates(
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
mapping = { mapping = {
"climate": {"occupancy", "presence", "window"}, "climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"},
"cover": {"illuminance", "wind_speed"}, "cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"}, "fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"}, "humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, "light": {"door", "garage_door", "illuminance", "motion", "occupancy", "opening", "presence", "window"},
"media_player": {"occupancy", "presence"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, "switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
} }
return frozenset( return frozenset(

View File

@@ -1,5 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -7,8 +12,9 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ReconciliationState from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import DiscoveredEntity, EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -58,6 +64,9 @@ class ActuatorSuggestion(BaseModel):
class ActuatorSummary(BaseModel): class ActuatorSummary(BaseModel):
actuator_entity_id: str actuator_entity_id: str
domain: str domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
enabled: bool enabled: bool
behavior_mode: str behavior_mode: str
behavior_status: str behavior_status: str
@@ -70,10 +79,49 @@ class ActuatorSummary(BaseModel):
updated_at: str updated_at: str
class EntityCacheStatus(BaseModel):
available: bool
updated_at: str | None = None
entity_count: int = 0
class DashboardSystemStatus(BaseModel):
api_status: str = "ok"
websocket_status: str = "unavailable"
websocket_error: str | None = None
reconciliation_last_completed_at: str | None = None
configured_actuators: int = 0
trained_models: int = 0
review_required: int = 0
class DashboardDiscoveryGroup(BaseModel):
category: str
role: str
count: int
class DashboardOverview(BaseModel):
system: DashboardSystemStatus
cache: EntityCacheStatus
actuators: list[ActuatorSummary]
discovery_groups: list[DashboardDiscoveryGroup]
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: def discover_actuators(
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} request: Request,
discovered = ha_reader.discover() refresh: bool = Query(default=False),
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[HaEntitySummary]:
cached_entities = [] if refresh else _load_cached_entities(request)
if cached_entities:
entities = {entity.entity_id: entity for entity in cached_entities}
else:
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
entities = {entity.entity_id: entity for entity in fresh_entities}
discovered = discover_entities(list(entities.values()))
actuator_ids = _deduplicate_actuator_ids( actuator_ids = _deduplicate_actuator_ids(
[ [
(entity.entity_id, entity.category) (entity.entity_id, entity.category)
@@ -91,7 +139,7 @@ def suggest_actuators(
ha_reader: HaReader = Depends(get_ha_reader), ha_reader: HaReader = Depends(get_ha_reader),
) -> list[ActuatorSuggestion]: ) -> list[ActuatorSuggestion]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in ha_reader.discover()} discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()} configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
actuator_ids = _deduplicate_actuator_ids( actuator_ids = _deduplicate_actuator_ids(
[ [
@@ -160,10 +208,30 @@ def context_options(
@router.get("/summary", response_model=list[ActuatorSummary]) @router.get("/summary", response_model=list[ActuatorSummary])
def list_configured_summary(request: Request) -> list[ActuatorSummary]: def list_configured_summary(request: Request) -> list[ActuatorSummary]:
records = _service(request).list_configured()
entity_map = _load_cached_entity_map(
request,
{record.actuator_entity_id for record in records},
)
return [ return [
ActuatorSummary( ActuatorSummary(
actuator_entity_id=record.actuator_entity_id, actuator_entity_id=record.actuator_entity_id,
domain=record.actuator_entity_id.split(".", 1)[0], domain=record.actuator_entity_id.split(".", 1)[0],
friendly_name=(
entity_map[record.actuator_entity_id].friendly_name
if record.actuator_entity_id in entity_map
else None
),
area_name=(
entity_map[record.actuator_entity_id].area_name
if record.actuator_entity_id in entity_map
else None
),
device_name=(
entity_map[record.actuator_entity_id].device_name
if record.actuator_entity_id in entity_map
else None
),
enabled=record.enabled, enabled=record.enabled,
behavior_mode=record.behavior.mode.value, behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value, behavior_status=record.behavior.status.value,
@@ -183,10 +251,50 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
), ),
updated_at=record.updated_at.isoformat(), updated_at=record.updated_at.isoformat(),
) )
for record in _service(request).list_configured() for record in records
] ]
@router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
raw_groups = cache_payload.get("discovery_groups", [])
cached_groups = [
DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups
if isinstance(group, dict)
] if isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
websocket_error=getattr(ws_status, "error", None),
reconciliation_last_completed_at=(
reconciliation.last_completed_at.isoformat()
if reconciliation.last_completed_at is not None
else None
),
configured_actuators=len(actuators),
trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required,
),
cache=EntityCacheStatus(
available=bool(raw_entities),
updated_at=updated_at,
entity_count=len(raw_entities),
),
actuators=actuators,
discovery_groups=cached_groups,
)
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -377,6 +485,97 @@ def _behavior(request: Request) -> BehaviorEngine:
return engine return engine
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return ReconciliationState()
try:
return store.load_reconciliation_state()
except ValueError:
return ReconciliationState(last_summary="Reconciliation-Status ist unlesbar.")
def _entity_cache_path(request: Request) -> Path:
store = getattr(request.app.state, "actuator_store", None)
settings = getattr(request.app.state, "settings", None)
if isinstance(store, ActuatorStore):
base_dir = store._root
elif isinstance(settings, Settings):
base_dir = Path(settings.actuator_store).resolve().parent
else:
base_dir = Path(".").resolve()
return Path(os.getenv("SILLYHOME_ENTITY_CACHE", base_dir / "ha_entity_cache.json"))
def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
return []
try:
return [HaEntitySummary.model_validate(entity) for entity in raw_entities]
except ValueError:
return []
def _load_cached_entity_map(
request: Request,
entity_ids: set[str],
) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
return {}
result: dict[str, HaEntitySummary] = {}
for raw_entity in raw_entities:
if not isinstance(raw_entity, dict):
continue
entity_id = raw_entity.get("entity_id")
if not isinstance(entity_id, str) or entity_id not in entity_ids:
continue
try:
result[entity_id] = HaEntitySummary.model_validate(raw_entity)
except ValueError:
continue
return result
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
path = _entity_cache_path(request)
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
except (OSError, TypeError, ValueError):
return {}
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
path = _entity_cache_path(request)
path.parent.mkdir(parents=True, exist_ok=True)
group_counts: dict[tuple[str, str], int] = {}
for entity in discover_entities(entities):
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
payload = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"discovery_groups": [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
],
"entities": [entity.model_dump(mode="json") for entity in entities],
}
temporary = path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def _deduplicate_actuator_ids( def _deduplicate_actuator_ids(
discovered: list[tuple[str, str]], discovered: list[tuple[str, str]],
entities: dict[str, HaEntitySummary], entities: dict[str, HaEntitySummary],

View File

@@ -214,9 +214,12 @@ def _result(
def _actuator_category(entity: HaEntitySummary) -> str: def _actuator_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain == "light": if entity.domain == "light":
return "light" return "light"
if entity.domain == "switch": if entity.domain == "switch":
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket"
return "switch_socket" return "switch_socket"
if entity.domain == "button" or entity.domain == "input_button": if entity.domain == "button" or entity.domain == "input_button":
return "button" return "button"
@@ -239,13 +242,31 @@ def _actuator_category(entity: HaEntitySummary) -> str:
def _measurement_category(entity: HaEntitySummary) -> str: def _measurement_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or "" device_class = entity.device_class or ""
text = _entity_text(entity)
if any(
token in text
for token in {
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
}
):
return "pv_battery_grid"
if entity.domain == "weather":
return "weather"
if device_class == "illuminance": if device_class == "illuminance":
return "brightness" return "brightness"
if device_class == "temperature": if device_class == "temperature":
return "temperature" return "temperature"
if device_class in {"humidity", "moisture"}: if device_class in {"humidity", "moisture"}:
return "humidity" return "humidity"
if device_class in {"power", "energy", "current", "voltage"}: if device_class in {"power", "energy", "current", "voltage", "apparent_power"}:
return "energy_power" return "energy_power"
if device_class in {"battery", "signal_strength"}: if device_class in {"battery", "signal_strength"}:
return "diagnostic" return "diagnostic"
@@ -260,12 +281,42 @@ def _binary_category(entity: HaEntitySummary) -> str:
return "opening" return "opening"
if device_class in {"smoke", "safety", "problem"}: if device_class in {"smoke", "safety", "problem"}:
return "safety" return "safety"
if device_class in {"lock"}:
return "lock_state"
return "binary" return "binary"
def _context_category(entity: HaEntitySummary) -> str: def _context_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain.startswith("input_"): if entity.domain.startswith("input_"):
return "helper" return "helper"
if entity.domain in {"person", "device_tracker", "zone"}: if entity.domain in {"person", "device_tracker", "zone"}:
return "presence_location" return "presence_location"
if entity.domain == "weather":
return "weather"
if entity.domain in {"light"}:
return "light_state"
if entity.domain in {"switch"}:
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket_state"
return "switch_state"
if entity.domain in {"climate"}:
return "heating_state"
if entity.domain in {"fan", "humidifier"}:
return "ventilation_state"
if entity.domain in {"cover"}:
return "cover_state"
return entity.domain return entity.domain
def _entity_text(entity: HaEntitySummary) -> str:
return " ".join(
value.lower().replace("_", " ")
for value in [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
if value
)

View File

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

View File

@@ -5,65 +5,116 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title> <title>SillyHome Next</title>
<style> <style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; } :root {
body { margin: 0; font-size:16px; } color-scheme: dark;
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); } font-family: Inter, ui-sans-serif, system-ui, sans-serif;
h1,h2,h3 { margin: 0 0 12px; } background:#0e1218;
header p { margin: 5px 0; color: #c3d1dc; } color:#f4f7fb;
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; } scroll-behavior:smooth;
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; } --accent:#ff8a1c;
section:target { outline:2px solid #66dfa9; outline-offset:2px; } --accent-strong:#ffb45b;
.wide { grid-column: 1 / -1; } --complement:#1cc7ff;
.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); } --complement-soft:#123447;
.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; } --panel:#171d25;
.quick-nav a.primary { background:#23715b; } --panel-soft:#202833;
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; } --panel-quiet:#111720;
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; } --border:#314050;
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; } --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; } .step p { margin:5px 0; }
.ok { color: #66dfa9; } .ok { color: #73e0a9; }
.warn { color: #f3c969; } .warn { color: #f3c969; }
.bad { color: #ff8f8f; } .bad { color: var(--bad); }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; } label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; } 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:190px; } select[multiple] { min-height:150px; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
button.secondary { background: #37495c; } button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
button.danger { background: #7b3434; } button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; }
button.compact { width:auto; min-width:120px; margin-right:8px; } 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; } table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; } td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; } ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #66dfa9; padding-left: 10px; } .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; } .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; } .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; } .chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; }
.muted { color:#9fb0be; } .muted { color:var(--text-soft); }
.card-list { display:grid; gap:12px; } .card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; }
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } .actuator-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); } .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; } .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; } .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-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; } .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; } .metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; } .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; } .detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } .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; } .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; } .manual-entry { min-height:80px; resize:vertical; }
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; } textarea { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
optgroup { color:#cfe0ec; background:#101820; } optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; } 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) { @media (max-width: 760px) {
header { padding:18px 14px; } header { padding:16px 12px; }
header h1 { font-size:1.55rem; } header h1 { font-size:1.55rem; }
main { display:block; padding:10px; } .topbar { display:grid; }
section { margin-bottom:12px; padding:14px; border-radius:14px; } .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; } .steps { grid-template-columns:1fr; }
.grid-two { grid-template-columns:1fr; } .grid-two { grid-template-columns:1fr; }
.metric-grid { grid-template-columns:1fr 1fr; } .metric-grid { grid-template-columns:1fr 1fr; }
.card-list { grid-template-columns:1fr; }
.actions { display:grid; grid-template-columns:1fr; } .actions { display:grid; grid-template-columns:1fr; }
.actions button, button.compact { width:100%; min-width:0; margin-right:0; } .actions button, button.compact { width:100%; min-width:0; margin-right:0; }
.quick-nav { padding:8px 10px; } .quick-nav { padding:8px 10px; }
@@ -77,52 +128,31 @@
</head> </head>
<body> <body>
<header> <header>
<h1>SillyHome Next</h1> <div class="topbar">
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p> <div class="brand">
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p> <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> </header>
<nav class="quick-nav" aria-label="Schnellnavigation"> <nav class="quick-nav" aria-label="Schnellnavigation">
<a class="primary" href="#choose">Gerät wählen</a> <a class="primary" href="#choose">Steuerung</a>
<a href="#observed">Beobachtet</a> <a href="#observed">Geräte</a>
<a href="#detail">Details</a> <a href="#detail">Freigabe</a>
<a href="#status-section">Status</a> <a href="#status-section">System</a>
<a href="#guide">Ablauf</a> <a href="#guide">Ablauf</a>
</nav> </nav>
<main> <main>
<section class="wide" id="guide"> <section class="control-panel" id="choose">
<h2>So gehst du vor</h2> <div class="panel-title">
<div class="steps"> <h2>Steuerung</h2>
<div class="step"> <span class="chip">v1</span>
<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> </div>
</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>
<div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
</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> <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> <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"> <input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
@@ -151,29 +181,79 @@
</div> </div>
<div> <div>
<label for="actuator-search">Liste durchsuchen</label> <label for="actuator-search">Liste durchsuchen</label>
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off"> <input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
</div> </div>
</div> </div>
<label for="actuator-select">Oder aus Liste wählen</label> <label for="actuator-select">Oder aus Liste wählen</label>
<select id="actuator-select" onchange="selectActuatorFromList()"> <select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
<option value="">Geräteliste wird geladen ...</option> <option value="">Geräteliste bei Bedarf laden</option>
</select> </select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <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> <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> <div id="actuator-suggestions" class="card-list"></div>
</section> </section>
<section class="wide" id="observed"> <section class="board-panel" id="observed">
<h2>2. Beobachtete Geräte</h2> <div class="panel-title">
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p> <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> <div id="configured-actuators">Noch nicht geladen.</div>
</section> </section>
<section class="wide" id="detail"> <section class="detail-panel" id="detail">
<h2>3. Lernfortschritt und Freigabe</h2> <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> <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> <div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section> </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> </main>
<script> <script>
const escapeHtml = value => String(value ?? "") const escapeHtml = value => String(value ?? "")
@@ -189,7 +269,10 @@ let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null; let cachedActuators = null;
let cachedEntities = null; let cachedEntities = null;
let cachedDiscovery = null; let cachedDiscovery = null;
let discoveryLoadPromise = null;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500;
function uniqueValues(values) { function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
@@ -208,6 +291,16 @@ async function api(path, options = {}) {
return body; 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) { function lifecycleLabel(record) {
const behaviorStatus = record.behavior_status || record.behavior?.status; const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
@@ -324,52 +417,160 @@ function optionGroups(entities, selectedIds = new Set()) {
} }
async function loadOverview() { 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 status = document.getElementById("status");
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
try { try {
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([ const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([
api("health"), apiWithTimeout("health"),
api("health/websocket"), apiWithTimeout("health/websocket"),
api("ml/health"), apiWithTimeout("ml/health"),
api("v1/actuators/reconciliation/state"), apiWithTimeout("v1/actuators/reconciliation/state"),
api("v1/actuators"), apiWithTimeout("v1/actuators/summary"),
]); ]);
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`; 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 = [ chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(health.status)}</span>`, `<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`, `<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Aktoren: ${actuators.length}</span>`, `<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`, `<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await loadDashboardData(); }
renderActuatorDiscovery();
renderConfiguredActuators(); function renderDashboardStatus(dashboard) {
void loadActuatorSuggestions(); 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 actuators = dashboard.actuators || [];
const discoveryGroups = dashboard.discovery_groups || [];
const activeCount = actuators.filter(record => record.behavior_mode === "active").length;
const shadowCount = actuators.filter(record => record.behavior_mode !== "active").length;
const readyCount = actuators.filter(record => record.activation_ready).length;
const pendingCount = actuators.filter(record =>
["pending_history", "pending_assignment", "archived"].includes(record.lifecycle_status)
).length;
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
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(actuators.length)} Geräte</div>`,
`<div class="metric"><strong>Freigabebereit</strong>${escapeHtml(readyCount)} Geräte</div>`,
`<div class="metric"><strong>Aktiv / Shadow</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
`<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`,
`<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</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() { async function loadDashboardData() {
const [actuators, entities, discovery] = await Promise.all([ 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/summary"),
Promise.resolve([]),
api("v1/actuators/discovery"), api("v1/actuators/discovery"),
]); ]);
cachedActuators = actuators; cachedActuators = actuators;
cachedEntities = entities; cachedEntities = [];
cachedDiscovery = discovery; cachedDiscovery = discovery;
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
if (!cachedActuators || !cachedDiscovery) { const select = document.getElementById("actuator-select");
await loadDashboardData(); if (select && !cachedDiscovery) {
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
}
if (!cachedActuators) {
await loadSummaryData();
}
if (!cachedDiscovery) {
await loadDiscoveryData();
} }
renderActuatorDiscovery(); 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() { function renderActuatorDiscovery() {
const options = document.getElementById("actuator-options"); const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select"); const select = document.getElementById("actuator-select");
@@ -384,7 +585,7 @@ function renderActuatorDiscovery() {
renderActuatorSelect(); renderActuatorSelect();
} catch (error) { } catch (error) {
options.innerHTML = ""; options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`; select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
} }
} }
@@ -505,7 +706,11 @@ async function configureActuator() {
|| document.getElementById("actuator-select").value.trim() || document.getElementById("actuator-select").value.trim()
); );
const result = document.getElementById("actuator-config-result"); const result = document.getElementById("actuator-config-result");
if (!actuatorId) return; 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 ..."; result.textContent = "Kontext wird automatisch analysiert ...";
try { try {
const record = await api("v1/actuators", { const record = await api("v1/actuators", {
@@ -523,8 +728,8 @@ async function configureActuator() {
} }
async function loadConfiguredActuators() { async function loadConfiguredActuators() {
if (!cachedActuators || !cachedEntities) { if (!cachedActuators) {
await loadDashboardData(); await loadSummaryData();
} }
renderConfiguredActuators(); renderConfiguredActuators();
} }
@@ -533,27 +738,25 @@ function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators"); const box = document.getElementById("configured-actuators");
try { try {
const rows = cachedActuators || []; const rows = cachedActuators || [];
const entities = cachedEntities || [];
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
const groups = new Map(); const groups = new Map();
for (const record of rows) { for (const record of rows) {
const entity = entityMap.get(record.actuator_entity_id) || {}; const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
if (!groups.has(group)) groups.set(group, []); if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({record, entity}); groups.get(group).push({record});
} }
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => ` ${groupedRows.map(([group, items]) => `
<h3>${escapeHtml(group)}</h3> <details class="group-panel" open>
<summary>${escapeHtml(group)} (${items.length})</summary>
<div class="card-list"> <div class="card-list">
${items.map(({record, entity}) => ` ${items.map(({record}) => `
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}"> <article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
<div class="card-title"> <div class="card-title">
<div> <div>
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></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="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div> <div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
</div> </div>
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span> <span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</div> </div>
@@ -574,6 +777,7 @@ function renderConfiguredActuators() {
</article> </article>
`).join("")} `).join("")}
</div> </div>
</details>
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>"; `).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
@@ -584,12 +788,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
try { try {
let record; const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
try {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
} catch (_) {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
}
await loadContextOptions(actuatorId); await loadContextOptions(actuatorId);
const contexts = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
@@ -628,8 +827,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
selected: manualContextIds, selected: manualContextIds,
}; };
const manualAssignment = ` const manualAssignment = `
<div class="manual-context"> <details class="manual-context" open>
<h3>Kontext selbst festlegen</h3> <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> <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> <label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select"> <select id="manual-numeric-select">
@@ -659,7 +858,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button> <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> <button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
</div> </div>
</div> </details>
`; `;
const activationButton = record.behavior.mode === "active" 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="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
@@ -719,6 +918,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
</div> </div>
<h3>Passende Home-Assistant-Automationen</h3> <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> <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} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <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>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
@@ -871,6 +1071,17 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
} }
} }
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) { async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return; if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try { try {

View File

@@ -0,0 +1,111 @@
# SillyHome Next 1.0.0 Operating Guide
Diese Version stabilisiert den produktiven Kern: schnelle Dashboard-Nutzung,
lokales Caching, klare Aktor-/Sensor-Kategorien und nachvollziehbare Freigabe
gelernter Aktionen.
Die detaillierte Abnahme steht in
[`V1_0_ACCEPTANCE.md`](V1_0_ACCEPTANCE.md). Dort sind erledigte, teilweise
erledigte und fuer v1.0.x offene Punkte getrennt dokumentiert.
## Grundprinzip
- Home Assistant bleibt die Quelle fuer aktuelle States und Services.
- SillyHome cached schwere Entity-/Discovery-Metadaten lokal als JSON.
- Die Startansicht liest nur lokale Store-/Cache-Daten.
- Vollstaendige Discovery, Vorschlaege und Detailanalysen laden blockweise nach.
- Es gibt keine externen Pings oder Cloud-Abfragen im Dashboard-Startpfad.
## Wichtige Endpunkte
- `GET /health`
Lokaler API-Status ohne externe Abfrage.
- `GET /health/websocket`
Status des Home-Assistant-WebSocket-Listeners.
- `GET /v1/actuators/dashboard`
Schnelle Dashboard-Startdaten aus Store und JSON-Cache.
- `GET /v1/actuators/summary`
Schlanke Liste beobachteter Aktoren ohne Lernmuster-Payload.
- `GET /v1/actuators/discovery`
Aktor-Auswahl aus gecachten oder frisch geladenen HA-Entities.
- `GET /v1/actuators/context-options?actuator_entity_id=...`
Sensor-/Kontextvorschlaege fuer einen konkreten Aktor.
- `POST /v1/actuators/{entity_id}/assignment`
Manuelle Sensor-/Kontextzuordnung speichern.
- `POST /v1/actuators/{entity_id}/activation`
Freigabe oder Stop des automatischen Schaltens.
## Cache
Der Entity-Cache liegt neben dem Aktor-Store als `ha_entity_cache.json`.
Er enthaelt HA-Entity-Metadaten wie Friendly Name, Bereich, Device und
Kategoriegrundlagen.
Der Cache wird geschrieben, wenn Discovery frische HA-Entities liest. Danach
koennen Dashboard und Summary ohne erneute HA-Vollabfrage Namen, Raeume und
Gruppen anzeigen.
## Dashboard-Nutzung
1. Startansicht oeffnen.
2. `System & Cache` zeigt API, WebSocket, Cache-Groesse und geladene
Discovery-Gruppen.
3. `Geraet zum Lernen auswaehlen` nutzt Suche, Typfilter und direkte
Entity-ID-Eingabe.
4. `Beobachtete Geraete` zeigt gelernte Aktoren nach Raum oder Typ gruppiert.
5. `Details` zeigt Lernfortschritt, Freigabe, Vorhersage, verwendete
Sensoren/Zustaende und Entscheidungsgruende.
## Kategorien
Aktoren:
- Licht, LED, Lampen
- Schalter, Steckdosen, Helper
- Lueftung, Ventilatoren, Befeuchter/Entfeuchter
- Heizungen/Klima
- Rolllaeden/Cover
- TV/Medien/Fernbedienungen
- Szenen, Buttons, Schloesser, Ventile
Sensoren und Kontext:
- Luftfeuchtigkeit und Feuchte
- Temperatur
- Wetter
- Helligkeit/Lux
- Bewegung, Praesenz, Anwesenheit
- Tuer/Fenster/Oeffnung
- Licht-/Schalter-/Steckdosenstatus
- Strom, Leistung, Energie, Einspeisung
- PV, Akku, Wechselrichter
- Helper und Szenen
## Qualitaetspruefung
Vor Release:
```bash
.venv/bin/pytest -q
.venv/bin/ruff check .
.venv/bin/mypy app backend tests
git diff --check
```
Live nach Installation:
```bash
wget -qO- http://58adbe1e-sillyhome-next:8000/health
wget -qO- http://58adbe1e-sillyhome-next:8000/health/websocket
wget -qO /tmp/summary.json http://58adbe1e-sillyhome-next:8000/v1/actuators/summary
wget -qO /tmp/dashboard.json http://58adbe1e-sillyhome-next:8000/v1/actuators/dashboard
```
## Rollback
Der stabile Vor-1.0-Stand ist `v0.7.21`. Vor dem 1.0.0-Umbau wurde ein
Git-Bundle-Backup erstellt:
`/root/.openclaw/workspace/backups/sillyhome-next/`
Bei Problemen kann auf `v0.7.21` zurueck installiert werden.

80
docs/V1_0_ACCEPTANCE.md Normal file
View File

@@ -0,0 +1,80 @@
# SillyHome Next v1.0 Acceptance
Stand: 2026-06-17
Diese Abnahme trennt belegte Umsetzung von offenen v1.0.x-Nacharbeiten. Der
Funktionskern bleibt aktorzentriert: Nutzer waehlen Aktoren, SillyHome lernt
Kontext und Verhalten, laeuft zuerst im Shadow-Modus und schaltet erst nach
expliziter Freigabe.
## Erfuellt
- Versioniert, gepusht und installiert:
- `v1.0.0`: API-/Cache-Umbau
- `v1.0.1`: Dashboard-/Performance-Korrektur
- Startpfad:
- `/v1/actuators/dashboard` liefert lokale Startdaten aus Store und Cache.
- Dashboard blockiert nicht mehr auf Discovery, Vorschlaegen oder
Automation-Refresh.
- Frontend bricht den Startdaten-Request nach 4,5 Sekunden ab und bleibt
bedienbar.
- Cache:
- HA-Entity-Metadaten werden als `ha_entity_cache.json` gespeichert.
- Summary und Dashboard verwenden Friendly Name, Area und Device aus Cache.
- Keine externen Abfragen im Dashboard-Startpfad:
- Kein Cloud-Ping, keine Fremd-API.
- HA-Zugriffe bleiben lokal gegen Home Assistant.
- Dashboard:
- Orange ist Primaerfarbe.
- Cyan ist sichtbare Komplementaerfarbe.
- Rote UI-Flaechen wurden entfernt.
- Steuerung, beobachtete Geraete, Lernfortschritt/Freigabe und Systemstatus
sind getrennte Bereiche.
- Discovery, Vorschlaege und Automation-Suche laden erst bei Nutzeraktion.
- Lernfortschritt und Freigabe:
- Karten zeigen Modus, Status, Handlungen, Vorhersage und Freigabestatus.
- Detailansicht zeigt Zuordnung, Sicherheit, Lernstand, Vorhersage,
Feedback, passende HA-Automationen und verwendete Sensoren/Zustaende.
- Direkte HA-Nutzung:
- Aktor-Schaltungen laufen ueber Home-Assistant-Serviceaufrufe.
- Automation-Steuerung nutzt Home-Assistant-Endpunkte und gecachte
Automation-Metadaten.
- Qualitaet:
- `pytest -q`
- `ruff check .`
- `mypy app backend tests`
- `git diff --check`
## Teilweise Erfuellt
- Bessere Statistik:
- Startbereich zeigt Aktoren, Freigabebereitschaft, Aktiv/Shadow,
Gelernt/Wartet, gelernte Handlungen, Discovery-Gruppen und Cache-Zeitpunkt.
- Noch offen: Verlaufsgrafiken, p95-Latenzen und Trendstatistik je Aktor.
- Kontrollierte Abarbeitung und Queue:
- Reconciliation/Training laufen kontrolliert im Prozess und sind testbar.
- Noch offen: sichtbare Job-Queue mit Laufzeit, Fehlern und Retry-Status im
Dashboard.
- Saubere Issues:
- v1.0.0-Issues #41 bis #47 wurden geschlossen.
- Rueckblickend waren sie zu grob; v1.0.x bekommt feinere Folgeissues fuer
Statistik, Queue-Sichtbarkeit und Performance-Budgets.
## Offen Fuer v1.0.x
- Echte Dashboard-Performance-Budget-Tests, die Start-HTML und
`/v1/actuators/dashboard` gegen ein 5-Sekunden-Limit messen.
- Dashboard-Jobstatus fuer Reconciliation, Training, Discovery und
Automation-Refresh.
- Mehr Entscheidungsstatistik pro Aktor: welche Sensoren wie stark
beigetragen haben, wie sich Confidence und Sample Count entwickeln.
- Dokumentierte HA-Installationspruefung mit Supervisor-/Ingress-Hinweisen,
weil direkte Container-HTTP-Pruefung ausserhalb HA nicht immer routbar ist.
## Rollback
- Git-Bundle-Backups liegen unter
`/root/.openclaw/workspace/backups/sillyhome-next/`.
- Vor `v1.0.1` wurde ein Home-Assistant-Teilbackup des Add-ons angelegt.
Referenz: `18a5b387`.
- Letzter Vor-1.0-Stand: `v0.7.21`.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.7.19" version = "1.0.2"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -28,8 +28,10 @@ class FakeHaReader(HaReader):
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None: def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
self._entities = entities self._entities = entities
self._history = history self._history = history
self.read_entities_calls = 0
def read_entities(self) -> list[HaEntitySummary]: def read_entities(self) -> list[HaEntitySummary]:
self.read_entities_calls += 1
return list(self._entities) return list(self._entities)
def discover( def discover(
@@ -213,6 +215,53 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
] ]
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/summary")
assert response.status_code == 200
payload = response.json()
assert payload[0]["actuator_entity_id"] == "light.abstellkammer"
assert payload[0]["friendly_name"] == "Abstellkammer Licht"
assert payload[0]["area_name"] == "Abstellkammer"
assert "behavior" not in payload[0]
assert "numeric_candidates" not in payload[0]
def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
reader = app.state.ha_reader
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
calls_before = reader.read_entities_calls
response = client.get("/v1/actuators/dashboard")
assert response.status_code == 200
assert reader.read_entities_calls == calls_before
payload = response.json()
assert payload["cache"]["available"] is True
assert payload["cache"]["entity_count"] == 4
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
assert payload["discovery_groups"]
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
reader = app.state.ha_reader
response = client.get("/v1/actuators/discovery", params={"refresh": True})
assert response.status_code == 200
assert reader.read_entities_calls == 1
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None: def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)

View File

@@ -9,11 +9,14 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
assert "So gehst du vor" in response.text assert "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" in response.text assert "Steuerung" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Oder aus Liste wählen" in response.text assert "Oder aus Liste wählen" in response.text
assert "Liste durchsuchen" in response.text assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text
assert "Wie gewohnt bedienen" in response.text assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
@@ -32,5 +35,12 @@ def test_dashboard_is_served_at_root() -> None:
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert "record.behavior.activation_ready" in response.text assert "record.behavior.activation_ready" in response.text
assert "record.behavior.status ===" not in response.text
assert "record.behavior_status || record.behavior?.status" in response.text
assert 'api("v1/actuators")' not in response.text
assert 'api("v1/actuators/summary")' in response.text
assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' in response.text
assert 'class="group-panel"' in response.text
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text