Compare commits

...

9 Commits

Author SHA1 Message Date
b9b5def7bb Add actuator sensor weighting controls
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:41:46 +02:00
94530d3ecf Stream dashboard loading and header menu
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:55:28 +02:00
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
387e027fe2 Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:09:35 +02:00
14 changed files with 1425 additions and 180 deletions

View File

@@ -1,5 +1,76 @@
# Changelog # Changelog
## 1.0.4 - 2026-06-17
- Sensor-Relevanz ist in der Aktor-Detailansicht sichtbar: automatische
Relevanz, aktive Gewichtung und Score werden pro verwendetem Sensor/Zustand
angezeigt.
- Gewichtungen koennen im Dashboard korrigiert und per API unter
`/v1/actuators/{actuator_entity_id}/weights` gespeichert werden.
- Gruppen-Gewichtungen buendeln mehrere Sensoren/Zustaende fuer einen Aktor,
damit verbundene Kontextsignale gemeinsam bewertet werden koennen.
## 1.0.3 - 2026-06-17
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.
- Dashboard startet in Phasen: leere Bedienoberflaeche, dann Status, danach
Geraetedaten.
- Detailansicht oeffnet streamartiger: zuerst Basis-Shell, dann Aktorwerte,
danach Kontextvorschlaege.
## 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
- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt
statt voller Lernmuster und kompletter HA-Entityliste.
- Nach Aktionen werden Dashboard-Caches gezielt invalidiert, damit keine
stale oder doppelt geladenen Einträge entstehen.
## 0.7.18 - 2026-06-16 ## 0.7.18 - 2026-06-16
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und - Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege. rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.

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.18" version: "1.0.4"
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

@@ -16,11 +16,12 @@ from app.actuators.models import (
ManualOverride, ManualOverride,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
SensorWeightGroup,
model_id_for_actuator, model_id_for_actuator,
) )
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 +171,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.")
@@ -239,6 +240,10 @@ class ActuatorReconciliationService:
override = ManualOverride( override = ManualOverride(
numeric_entity_id=numeric_entity_id, numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids, context_entity_ids=selected_context_ids,
sensor_weights=record.manual_override.sensor_weights if record.manual_override else {},
sensor_weight_groups=(
record.manual_override.sensor_weight_groups if record.manual_override else []
),
updated_at=now, updated_at=now,
note=note, note=note,
) )
@@ -253,17 +258,23 @@ class ActuatorReconciliationService:
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": override, "manual_override": override,
"numeric_candidates": _merge_manual_candidates( "numeric_candidates": _apply_weight_overrides(
record.numeric_candidates, _merge_manual_candidates(
entities, record.numeric_candidates,
[numeric_entity_id] if numeric_entity_id else [], entities,
role=EntityRole.MEASUREMENT, [numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
override,
), ),
"context_candidates": _merge_manual_candidates( "context_candidates": _apply_weight_overrides(
record.context_candidates, _merge_manual_candidates(
entities, record.context_candidates,
selected_context_ids, entities,
role=EntityRole.CONTEXT, selected_context_ids,
role=EntityRole.CONTEXT,
),
override,
), ),
"lifecycle": lifecycle, "lifecycle": lifecycle,
"updated_at": now, "updated_at": now,
@@ -271,6 +282,65 @@ class ActuatorReconciliationService:
) )
return self._store.upsert(updated) return self._store.upsert(updated)
def set_weight_overrides(
self,
actuator_entity_id: str,
*,
sensor_weights: dict[str, float],
sensor_weight_groups: list[SensorWeightGroup],
note: str | None = None,
) -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
selected_ids.update(sensor_weights)
for group in sensor_weight_groups:
selected_ids.update(group.entity_ids)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
if missing:
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(sorted(missing))}")
previous = record.manual_override
override = ManualOverride(
numeric_entity_id=(
previous.numeric_entity_id
if previous is not None
else record.assignment.selected_numeric_entity_id
),
context_entity_ids=(
previous.context_entity_ids
if previous is not None
else record.assignment.selected_context_entity_ids
),
sensor_weights={entity_id: round(weight, 4) for entity_id, weight in sensor_weights.items()},
sensor_weight_groups=sensor_weight_groups,
updated_at=now,
note=note,
)
updated = record.model_copy(
update={
"manual_override": override,
"numeric_candidates": _apply_weight_overrides(
record.numeric_candidates,
override,
),
"context_candidates": _apply_weight_overrides(
record.context_candidates,
override,
),
"updated_at": now,
}
)
return self._store.upsert(updated)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy( state = self._store.load_reconciliation_state().model_copy(
update={ update={
@@ -376,6 +446,9 @@ class ActuatorReconciliationService:
), ),
context=True, context=True,
) )
if record.manual_override is not None:
numeric_candidates = _apply_weight_overrides(numeric_candidates, record.manual_override)
context_candidates = _apply_weight_overrides(context_candidates, record.manual_override)
assignment = ( assignment = (
self._manual_assignment(record.manual_override) self._manual_assignment(record.manual_override)
if record.manual_override is not None if record.manual_override is not None
@@ -736,6 +809,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 +822,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}"
@@ -897,14 +991,50 @@ def _merge_manual_candidates(
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id)) return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
def _apply_weight_overrides(
candidates: list[AssignmentCandidate],
override: ManualOverride,
) -> list[AssignmentCandidate]:
if not override.sensor_weights and not override.sensor_weight_groups:
return candidates
group_weights: dict[str, float] = {}
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
group_weights[entity_id] = max(group_weights.get(entity_id, 0.0), group.weight)
weighted: list[AssignmentCandidate] = []
for candidate in candidates:
explicit = override.sensor_weights.get(candidate.entity_id)
group_weight = group_weights.get(candidate.entity_id)
manual_weight = explicit if explicit is not None else group_weight
effective_weight = manual_weight if manual_weight is not None else 1.0
evidence = [
item
for item in candidate.evidence
if not item.startswith("Manuelle Gewichtung:")
]
if manual_weight is not None:
evidence.append(f"Manuelle Gewichtung: {round(manual_weight * 100)} %.")
weighted.append(
candidate.model_copy(
update={
"manual_weight": manual_weight,
"effective_weight": round(effective_weight, 4),
"evidence": evidence,
}
)
)
return sorted(weighted, key=lambda item: (-item.confidence * item.effective_weight, item.entity_id))
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

@@ -49,6 +49,8 @@ class AssignmentCandidate(BaseModel):
device_name: str | None = None device_name: str | None = None
score: float = Field(ge=0.0) score: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.0) confidence: float = Field(ge=0.0, le=1.0)
manual_weight: float | None = Field(default=None, ge=0.0, le=1.0)
effective_weight: float = Field(default=1.0, ge=0.0, le=1.0)
auto_accepted: bool = False auto_accepted: bool = False
evidence: list[str] = Field(default_factory=list) evidence: list[str] = Field(default_factory=list)
@@ -62,9 +64,18 @@ class AssignmentSelection(BaseModel):
reason: str = "Noch keine Zuordnung vorhanden." reason: str = "Noch keine Zuordnung vorhanden."
class SensorWeightGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
entity_ids: list[str] = Field(default_factory=list)
weight: float = Field(default=1.0, ge=0.0, le=1.0)
class ManualOverride(BaseModel): class ManualOverride(BaseModel):
numeric_entity_id: str | None = None numeric_entity_id: str | None = None
context_entity_ids: list[str] = Field(default_factory=list) context_entity_ids: list[str] = Field(default_factory=list)
sensor_weights: dict[str, float] = Field(default_factory=dict)
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
note: str | None = None note: str | None = None

View File

@@ -1,14 +1,20 @@
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
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ReconciliationState from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup
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
@@ -38,6 +44,12 @@ class ManualAssignmentRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) note: str | None = Field(default=None, max_length=500)
class WeightOverrideRequest(BaseModel):
sensor_weights: dict[str, float] = Field(default_factory=dict)
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=500)
class FeedbackRequest(BaseModel): class FeedbackRequest(BaseModel):
correct: bool correct: bool
expected_state: str | None = Field(default=None, max_length=100) expected_state: str | None = Field(default=None, max_length=100)
@@ -55,10 +67,67 @@ class ActuatorSuggestion(BaseModel):
likely_context_count: int = 0 likely_context_count: int = 0
class ActuatorSummary(BaseModel):
actuator_entity_id: str
domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
enabled: bool
behavior_mode: str
behavior_status: str
lifecycle_status: str
activation_ready: bool
activation_reason: str
sample_count: int
prediction_target_state: str | None = None
prediction_confidence: float | None = None
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)
@@ -76,7 +145,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(
[ [
@@ -143,6 +212,95 @@ def context_options(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/summary", response_model=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 [
ActuatorSummary(
actuator_entity_id=record.actuator_entity_id,
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,
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
activation_ready=record.behavior.activation_ready,
activation_reason=record.behavior.activation_reason,
sample_count=record.behavior.sample_count,
prediction_target_state=(
record.behavior.prediction.target_state
if record.behavior.prediction is not None
else None
),
prediction_confidence=(
record.behavior.prediction.confidence
if record.behavior.prediction is not None
else None
),
updated_at=record.updated_at.isoformat(),
)
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()
@@ -254,6 +412,27 @@ def set_manual_assignment(
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/weights", response_model=ActuatorRecord)
def set_weight_overrides(
actuator_entity_id: str,
payload: WeightOverrideRequest,
request: Request,
) -> ActuatorRecord:
try:
_validate_weight_payload(payload)
record = _service(request).set_weight_overrides(
actuator_entity_id,
sensor_weights=payload.sensor_weights,
sensor_weight_groups=payload.sensor_weight_groups,
note=payload.note,
)
return record
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post( @router.post(
"/{actuator_entity_id}/related-automations/refresh", "/{actuator_entity_id}/related-automations/refresh",
response_model=ActuatorRecord, response_model=ActuatorRecord,
@@ -333,6 +512,111 @@ def _behavior(request: Request) -> BehaviorEngine:
return engine return engine
def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
for entity_id, weight in payload.sensor_weights.items():
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
if not 0.0 <= weight <= 1.0:
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
for group in payload.sensor_weight_groups:
if not group.entity_ids:
raise ValueError(f"Gruppe {group.name} enthält keine Entities.")
for entity_id in group.entity_ids:
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
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.18", version="1.0.4",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -5,69 +5,118 @@
<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; }
.header-actions { display:grid; gap:8px; min-width:230px; }
.header-actions label { margin:0; font-size:.82rem; }
.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); }
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; } .header-actions { min-width:0; }
.status-pill { width:max-content; max-width:100%; white-space:normal; }
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 a { padding:10px 11px; }
} }
@media (max-width: 430px) { @media (max-width: 430px) {
.metric-grid { grid-template-columns:1fr; } .metric-grid { grid-template-columns:1fr; }
@@ -77,52 +126,34 @@
</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">
</header> <span class="brand-mark">SH</span>
<nav class="quick-nav" aria-label="Schnellnavigation"> <h1>SillyHome Next</h1>
<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" id="guide">
<h2>So gehst du vor</h2>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Unten im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div> </div>
<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="header-actions">
<label for="section-jump">Menü</label>
<select id="section-jump" onchange="jumpToSection(this.value)">
<option value="#choose">Steuerung</option>
<option value="#observed">Geräte</option>
<option value="#detail">Freigabe</option>
<option value="#status-section">System</option>
<option value="#guide">Ablauf</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
</div>
</header>
<main>
<section class="control-panel" id="choose">
<div class="panel-title">
<h2>Steuerung</h2>
<span class="chip">v1</span>
</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 +182,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,12 +270,27 @@ 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;
let currentSensorWeightGroups = [];
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500;
function jumpToSection(target) {
if (!target) return;
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function uniqueValues(values) { function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
}
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = response.status === 204 ? null : await response.json().catch(() => ({})); const body = response.status === 204 ? null : await response.json().catch(() => ({}));
@@ -202,8 +298,20 @@ 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) {
if (record.behavior.status === "trained") return "Kontext erkannt"; const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt";
const labels = { const labels = {
trained: "lernt", trained: "lernt",
pending_history: "sammelt Historie", pending_history: "sammelt Historie",
@@ -212,26 +320,32 @@ function lifecycleLabel(record) {
archived: "wartet auf Kontext", archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden", orphaned: "Aktor nicht gefunden",
}; };
return labels[record.lifecycle.status] || record.lifecycle.status; return labels[lifecycleStatus] || lifecycleStatus;
} }
function statusClass(record) { function statusClass(record) {
if (record.behavior.status === "trained") return "ok"; const behaviorStatus = record.behavior_status || record.behavior?.status;
if (record.lifecycle.status === "trained") return "ok"; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; if (behaviorStatus === "trained") return "ok";
if (lifecycleStatus === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
return "bad"; return "bad";
} }
function behaviorLabel(record) { function behaviorLabel(record) {
if (record.behavior.mode === "active") return "aktiv freigegeben"; const mode = record.behavior_mode || record.behavior?.mode;
if (record.behavior.status === "trained") return "Shadow-Vorhersage"; const status = record.behavior_status || record.behavior?.status;
if (record.behavior.status === "blocked") return "Lernen blockiert"; if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Shadow-Vorhersage";
if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen"; return "sammelt Handlungen";
} }
function predictionLabel(record) { function predictionLabel(record) {
return record.behavior.prediction const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)` const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target
? `${target} (${Math.round(confidence * 100)} %)`
: "Keine fällige Aktion"; : "Keine fällige Aktion";
} }
@@ -310,52 +424,158 @@ 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] = 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"),
]); ]);
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].map(result =>
result.status === "fulfilled" ? result.value : null
);
const [healthValue, websocketValue, mlValue, reconciliationValue] = 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">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</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();
api("v1/actuators"), await loadDiscoveryData();
api("v1/entities"), }
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/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");
@@ -370,7 +590,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>`;
} }
} }
@@ -491,7 +711,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", {
@@ -509,8 +733,8 @@ async function configureActuator() {
} }
async function loadConfiguredActuators() { async function loadConfiguredActuators() {
if (!cachedActuators || !cachedEntities) { if (!cachedActuators) {
await loadDashboardData(); await loadSummaryData();
} }
renderConfiguredActuators(); renderConfiguredActuators();
} }
@@ -519,40 +743,38 @@ 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">
<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>
<div class="metric-grid"> <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>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div> <div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div> <div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div> </div>
<div class="actions"> <div class="actions">
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button> <button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
${record.behavior.mode === "active" ${record.behavior_mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
: record.behavior.activation_ready : record.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>` ? `<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> <button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
@@ -560,6 +782,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;
@@ -569,14 +792,10 @@ function renderConfiguredActuators() {
async function showActuator(actuatorId, evaluationMessage = "") { async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
renderActuatorDetailShell(actuatorId);
try { try {
let record; const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
try { contextOptions = [];
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
} catch (_) {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
}
await loadContextOptions(actuatorId);
const contexts = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids, ...record.assignment.selected_context_entity_ids,
@@ -585,6 +804,62 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.filter(candidate => contexts.includes(candidate.entity_id)) .filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const weightedCandidates = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id));
const weightGroups = record.manual_override?.sensor_weight_groups || [];
currentSensorWeightGroups = weightGroups;
const weightControls = weightedCandidates.length ? `
<div class="card-list">
${weightedCandidates.map(candidate => {
const relevance = Math.round((candidate.confidence ?? 0) * 100);
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
const manual = candidate.manual_weight == null ? effective : Math.round(candidate.manual_weight * 100);
return `
<article class="actuator-card">
<div class="card-title">
<div>
<div><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
</div>
<span class="chip">${relevance} % relevant</span>
</div>
<div class="metric-grid">
<div class="metric"><strong>Automatische Relevanz</strong>${relevance} %</div>
<div class="metric"><strong>Aktive Gewichtung</strong>${effective} %</div>
<div class="metric"><strong>Score</strong>${escapeHtml(candidate.score)}</div>
</div>
<label for="weight-${escapeHtml(candidate.entity_id)}">Gewichtung korrigieren</label>
<input id="weight-${escapeHtml(candidate.entity_id)}" data-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${manual}">
</article>
`;
}).join("")}
</div>
<div class="actions">
<button onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}')">Gewichtungen speichern</button>
</div>
` : "<p class='muted'>Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.</p>";
const weightGroupControls = `
<details class="manual-context">
<summary>Gruppen-Gewichtung</summary>
${weightGroups.length ? `<ul>${weightGroups.map(group => `
<li><strong>${escapeHtml(group.name)}</strong>: ${Math.round(group.weight * 100)} %
<span class="muted">${group.entity_ids.map(escapeHtml).join(", ")}</span></li>
`).join("")}</ul>` : "<p class='muted'>Noch keine Gruppe gespeichert.</p>"}
<div class="inline-controls">
<div>
<label for="weight-group-name">Gruppenname</label>
<input id="weight-group-name" placeholder="z. B. Flur Bewegung + Helligkeit">
</div>
<div>
<label for="weight-group-value">Gruppen-Gewicht in %</label>
<input id="weight-group-value" type="number" min="0" max="100" step="5" value="100">
</div>
</div>
<label for="weight-group-entities">Entity-IDs der Gruppe</label>
<textarea id="weight-group-entities" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs">${escapeHtml(contexts.join("\n"))}</textarea>
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
</details>
`;
const currentContextControls = contexts.length const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => ` ? `<ul>${contexts.map(entityId => `
<li> <li>
@@ -614,8 +889,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
selected: manualContextIds, selected: manualContextIds,
}; };
const manualAssignment = ` const manualAssignment = `
<div class="manual-context"> <details class="manual-context">
<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">
@@ -643,9 +918,9 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea> <textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<div class="actions"> <div class="actions">
<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="hydrateCurrentContextOptions('${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>
@@ -705,19 +980,112 @@ 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>"}
<h3>Sensor-Gewichtung</h3>
<p class="muted">Automatische Relevanz kommt aus der Zuordnung. Die aktive Gewichtung kannst du korrigieren; Gruppen bündeln mehrere Sensoren/Zustände.</p>
${weightControls}
${weightGroupControls}
<h3>Verwendete Sensoren/Zustände ändern</h3> <h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
void hydrateContextOptions(record);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
} }
function renderActuatorDetailShell(actuatorId) {
document.getElementById("actuator-detail").innerHTML = `
<div class="detail-header">
<div>
<h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Basisdaten werden geladen ...</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
</div>
`;
}
async function hydrateContextOptions(record) {
await loadContextOptions(record.actuator_entity_id);
const manualContextSelect = document.getElementById("manual-context-select");
const numericSelect = document.getElementById("manual-numeric-select");
const categorySelect = document.getElementById("manual-context-category");
if (!manualContextSelect || !numericSelect || !categorySelect) return;
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
numericSelect.innerHTML = `
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
`;
categorySelect.innerHTML = `
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`;
renderManualContextSelect();
}
async function hydrateCurrentContextOptions(actuatorId) {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
await hydrateContextOptions(record);
}
async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
const sensorWeights = {};
for (const input of document.querySelectorAll("[data-weight-entity]")) {
const value = Number(input.value);
if (Number.isFinite(value)) {
sensorWeights[input.dataset.weightEntity] = Math.max(0, Math.min(100, value)) / 100;
}
}
const groups = [...currentSensorWeightGroups];
if (includeNewGroup) {
const name = document.getElementById("weight-group-name")?.value.trim();
const value = Number(document.getElementById("weight-group-value")?.value || 100);
const entityIds = parseEntityIds(document.getElementById("weight-group-entities")?.value || "");
if (name && entityIds.length) {
groups.push({
group_id: name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "gruppe",
name,
entity_ids: entityIds,
weight: Math.max(0, Math.min(100, Number.isFinite(value) ? value : 100)) / 100,
});
}
}
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
method: "POST",
body: JSON.stringify({
sensor_weights: sensorWeights,
sensor_weight_groups: groups,
note: "Gewichtung im Dashboard korrigiert",
}),
});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Sensor-Gewichtung gespeichert.");
} catch (error) {
alert(error.message);
}
}
async function saveManualAssignment(actuatorId) { async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null; const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from( const selectedContextIds = Array.from(
@@ -737,6 +1105,7 @@ async function saveManualAssignment(actuatorId) {
note: "Manuell im Dashboard gesetzt", note: "Manuell im Dashboard gesetzt",
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert."); await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) { } catch (error) {
@@ -760,6 +1129,7 @@ async function removeContextEntity(actuatorId, entityId) {
note: `Entity ${entityId} entfernt`, note: `Entity ${entityId} entfernt`,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt."); await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) { } catch (error) {
@@ -784,6 +1154,7 @@ async function evaluateActuator(actuatorId) {
const message = record.behavior.prediction const message = record.behavior.prediction
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.` ? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`; : `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, message); await showActuator(actuatorId, message);
} catch (error) { } catch (error) {
@@ -801,6 +1172,7 @@ async function sendFeedback(actuatorId, correct) {
expected_state: expectedState || null, expected_state: expectedState || null,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert."); await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) { } catch (error) {
@@ -826,6 +1198,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
restore_paused_automations: restorePausedAutomations, restore_paused_automations: restorePausedAutomations,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
@@ -844,6 +1217,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
enabled, enabled,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
@@ -851,6 +1225,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 {
@@ -865,7 +1250,16 @@ async function removeActuator(actuatorId) {
} }
} }
loadOverview(); async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Geräte werden nach dem Status geladen.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
await new Promise(resolve => requestAnimationFrame(resolve));
await loadStatus();
await loadOverview();
}
void startDashboard();
</script> </script>
</body> </body>
</html> </html>

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.18" version = "1.0.4"
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,101 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
] ]
def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
},
)
response = client.post(
"/v1/actuators/light.abstellkammer/weights",
json={
"sensor_weights": {
"sensor.abstellkammer_illuminance": 0.75,
"binary_sensor.abstellkammer_motion": 0.5,
},
"sensor_weight_groups": [
{
"group_id": "abstellkammer_context",
"name": "Abstellkammer Kontext",
"entity_ids": [
"sensor.abstellkammer_illuminance",
"binary_sensor.abstellkammer_motion",
],
"weight": 0.8,
}
],
"note": "Gewichtung korrigiert",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["manual_override"]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.75
assert payload["manual_override"]["sensor_weight_groups"][0]["group_id"] == (
"abstellkammer_context"
)
numeric = {
candidate["entity_id"]: candidate
for candidate in payload["numeric_candidates"]
}
assert numeric["sensor.abstellkammer_illuminance"]["manual_weight"] == 0.75
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
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