Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9b5def7bb | |||
| 94530d3ecf | |||
| 63b8684197 | |||
| f8801e469a | |||
| 787516ac67 | |||
| 7ba9807a4e | |||
| 4db4276b95 | |||
| 98a2b2cc38 | |||
| 387e027fe2 |
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,5 +1,76 @@
|
||||
# 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
|
||||
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
|
||||
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.
|
||||
|
||||
@@ -11,6 +11,10 @@ nach einer ausdrücklichen Freigabe ausführen.
|
||||
[`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md)
|
||||
- Entwickeln, testen, veröffentlichen und installieren:
|
||||
[`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)
|
||||
|
||||
## 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/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/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/{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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "0.7.18"
|
||||
version: "1.0.4"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -16,11 +16,12 @@ from app.actuators.models import (
|
||||
ManualOverride,
|
||||
ModelLifecycleState,
|
||||
ReconciliationState,
|
||||
SensorWeightGroup,
|
||||
model_id_for_actuator,
|
||||
)
|
||||
from app.actuators.store import ActuatorStore
|
||||
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.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
@@ -170,7 +171,7 @@ class ActuatorReconciliationService:
|
||||
limit: int = _CONTEXT_SUGGESTION_LIMIT,
|
||||
) -> list[HaEntitySummary]:
|
||||
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)
|
||||
if actuator is None:
|
||||
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
|
||||
@@ -239,6 +240,10 @@ class ActuatorReconciliationService:
|
||||
override = ManualOverride(
|
||||
numeric_entity_id=numeric_entity_id,
|
||||
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,
|
||||
note=note,
|
||||
)
|
||||
@@ -253,17 +258,23 @@ class ActuatorReconciliationService:
|
||||
update={
|
||||
"assignment": assignment,
|
||||
"manual_override": override,
|
||||
"numeric_candidates": _merge_manual_candidates(
|
||||
record.numeric_candidates,
|
||||
entities,
|
||||
[numeric_entity_id] if numeric_entity_id else [],
|
||||
role=EntityRole.MEASUREMENT,
|
||||
"numeric_candidates": _apply_weight_overrides(
|
||||
_merge_manual_candidates(
|
||||
record.numeric_candidates,
|
||||
entities,
|
||||
[numeric_entity_id] if numeric_entity_id else [],
|
||||
role=EntityRole.MEASUREMENT,
|
||||
),
|
||||
override,
|
||||
),
|
||||
"context_candidates": _merge_manual_candidates(
|
||||
record.context_candidates,
|
||||
entities,
|
||||
selected_context_ids,
|
||||
role=EntityRole.CONTEXT,
|
||||
"context_candidates": _apply_weight_overrides(
|
||||
_merge_manual_candidates(
|
||||
record.context_candidates,
|
||||
entities,
|
||||
selected_context_ids,
|
||||
role=EntityRole.CONTEXT,
|
||||
),
|
||||
override,
|
||||
),
|
||||
"lifecycle": lifecycle,
|
||||
"updated_at": now,
|
||||
@@ -271,6 +282,65 @@ class ActuatorReconciliationService:
|
||||
)
|
||||
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:
|
||||
state = self._store.load_reconciliation_state().model_copy(
|
||||
update={
|
||||
@@ -376,6 +446,9 @@ class ActuatorReconciliationService:
|
||||
),
|
||||
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 = (
|
||||
self._manual_assignment(record.manual_override)
|
||||
if record.manual_override is not None
|
||||
@@ -736,6 +809,11 @@ def _manual_context_role(
|
||||
|
||||
def _context_sort_group(entity: HaEntitySummary) -> str:
|
||||
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"}:
|
||||
return "01_presence"
|
||||
if device_class in {"illuminance"}:
|
||||
@@ -744,10 +822,26 @@ def _context_sort_group(entity: HaEntitySummary) -> str:
|
||||
return "03_opening"
|
||||
if device_class in {"humidity", "moisture"}:
|
||||
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"}:
|
||||
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"}:
|
||||
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}"
|
||||
|
||||
|
||||
@@ -897,14 +991,50 @@ def _merge_manual_candidates(
|
||||
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]:
|
||||
if context:
|
||||
mapping = {
|
||||
"climate": {"occupancy", "presence", "window"},
|
||||
"cover": {"illuminance", "wind_speed"},
|
||||
"climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"},
|
||||
"cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"},
|
||||
"fan": {"humidity", "moisture", "occupancy", "presence", "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"},
|
||||
}
|
||||
return frozenset(
|
||||
|
||||
@@ -49,6 +49,8 @@ class AssignmentCandidate(BaseModel):
|
||||
device_name: str | None = None
|
||||
score: float = Field(ge=0.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
|
||||
evidence: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -62,9 +64,18 @@ class AssignmentSelection(BaseModel):
|
||||
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):
|
||||
numeric_entity_id: str | None = None
|
||||
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))
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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 pydantic import BaseModel, Field
|
||||
|
||||
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.behavior.engine import BehaviorEngine
|
||||
from app.config import Settings
|
||||
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.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
@@ -38,6 +44,12 @@ class ManualAssignmentRequest(BaseModel):
|
||||
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):
|
||||
correct: bool
|
||||
expected_state: str | None = Field(default=None, max_length=100)
|
||||
@@ -55,10 +67,67 @@ class ActuatorSuggestion(BaseModel):
|
||||
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])
|
||||
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
|
||||
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
|
||||
discovered = ha_reader.discover()
|
||||
def discover_actuators(
|
||||
request: Request,
|
||||
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(
|
||||
[
|
||||
(entity.entity_id, entity.category)
|
||||
@@ -76,7 +145,7 @@ def suggest_actuators(
|
||||
ha_reader: HaReader = Depends(get_ha_reader),
|
||||
) -> list[ActuatorSuggestion]:
|
||||
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()}
|
||||
actuator_ids = _deduplicate_actuator_ids(
|
||||
[
|
||||
@@ -143,6 +212,95 @@ def context_options(
|
||||
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])
|
||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||
return _service(request).list_configured()
|
||||
@@ -254,6 +412,27 @@ def set_manual_assignment(
|
||||
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(
|
||||
"/{actuator_entity_id}/related-automations/refresh",
|
||||
response_model=ActuatorRecord,
|
||||
@@ -333,6 +512,111 @@ def _behavior(request: Request) -> BehaviorEngine:
|
||||
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(
|
||||
discovered: list[tuple[str, str]],
|
||||
entities: dict[str, HaEntitySummary],
|
||||
|
||||
@@ -214,9 +214,12 @@ def _result(
|
||||
|
||||
|
||||
def _actuator_category(entity: HaEntitySummary) -> str:
|
||||
text = _entity_text(entity)
|
||||
if entity.domain == "light":
|
||||
return "light"
|
||||
if entity.domain == "switch":
|
||||
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
|
||||
return "socket"
|
||||
return "switch_socket"
|
||||
if entity.domain == "button" or entity.domain == "input_button":
|
||||
return "button"
|
||||
@@ -239,13 +242,31 @@ def _actuator_category(entity: HaEntitySummary) -> str:
|
||||
|
||||
def _measurement_category(entity: HaEntitySummary) -> str:
|
||||
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":
|
||||
return "brightness"
|
||||
if device_class == "temperature":
|
||||
return "temperature"
|
||||
if device_class in {"humidity", "moisture"}:
|
||||
return "humidity"
|
||||
if device_class in {"power", "energy", "current", "voltage"}:
|
||||
if device_class in {"power", "energy", "current", "voltage", "apparent_power"}:
|
||||
return "energy_power"
|
||||
if device_class in {"battery", "signal_strength"}:
|
||||
return "diagnostic"
|
||||
@@ -260,12 +281,42 @@ def _binary_category(entity: HaEntitySummary) -> str:
|
||||
return "opening"
|
||||
if device_class in {"smoke", "safety", "problem"}:
|
||||
return "safety"
|
||||
if device_class in {"lock"}:
|
||||
return "lock_state"
|
||||
return "binary"
|
||||
|
||||
|
||||
def _context_category(entity: HaEntitySummary) -> str:
|
||||
text = _entity_text(entity)
|
||||
if entity.domain.startswith("input_"):
|
||||
return "helper"
|
||||
if entity.domain in {"person", "device_tracker", "zone"}:
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Next API",
|
||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||
version="0.7.18",
|
||||
version="1.0.4",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
|
||||
@@ -5,69 +5,118 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SillyHome Next</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
||||
body { margin: 0; font-size:16px; }
|
||||
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||
h1,h2,h3 { margin: 0 0 12px; }
|
||||
header p { margin: 5px 0; color: #c3d1dc; }
|
||||
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
||||
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
||||
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
||||
.quick-nav a.primary { background:#23715b; }
|
||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
||||
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
||||
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background:#0e1218;
|
||||
color:#f4f7fb;
|
||||
scroll-behavior:smooth;
|
||||
--accent:#ff8a1c;
|
||||
--accent-strong:#ffb45b;
|
||||
--complement:#1cc7ff;
|
||||
--complement-soft:#123447;
|
||||
--panel:#171d25;
|
||||
--panel-soft:#202833;
|
||||
--panel-quiet:#111720;
|
||||
--border:#314050;
|
||||
--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; }
|
||||
.ok { color: #66dfa9; }
|
||||
.ok { color: #73e0a9; }
|
||||
.warn { color: #f3c969; }
|
||||
.bad { color: #ff8f8f; }
|
||||
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
||||
select[multiple] { min-height:190px; }
|
||||
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
||||
button.secondary { background: #37495c; }
|
||||
button.danger { background: #7b3434; }
|
||||
button.compact { width:auto; min-width:120px; margin-right:8px; }
|
||||
.bad { color: var(--bad); }
|
||||
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
|
||||
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:150px; }
|
||||
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
|
||||
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
|
||||
button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; }
|
||||
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; }
|
||||
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
|
||||
ul { margin: 8px 0; padding-left: 18px; }
|
||||
.notice { border-left: 4px solid #66dfa9; padding-left: 10px; }
|
||||
.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; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
||||
.muted { color:#9fb0be; }
|
||||
.card-list { display:grid; gap:12px; }
|
||||
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
||||
.chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; }
|
||||
.muted { color:var(--text-soft); }
|
||||
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; }
|
||||
.actuator-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
|
||||
.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; }
|
||||
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
|
||||
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
||||
.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; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.actions button { flex:1 1 180px; margin-top:0; }
|
||||
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||
.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; }
|
||||
.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; }
|
||||
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) {
|
||||
header { padding:18px 14px; }
|
||||
header { padding:16px 12px; }
|
||||
header h1 { font-size:1.55rem; }
|
||||
main { display:block; padding:10px; }
|
||||
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
||||
.topbar { display:grid; }
|
||||
.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; }
|
||||
.grid-two { grid-template-columns:1fr; }
|
||||
.metric-grid { grid-template-columns:1fr 1fr; }
|
||||
.card-list { grid-template-columns:1fr; }
|
||||
.actions { display:grid; grid-template-columns:1fr; }
|
||||
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
|
||||
.quick-nav { padding:8px 10px; }
|
||||
.quick-nav a { padding:10px 11px; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.metric-grid { grid-template-columns:1fr; }
|
||||
@@ -77,52 +126,34 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>SillyHome Next</h1>
|
||||
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
|
||||
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
||||
</header>
|
||||
<nav class="quick-nav" aria-label="Schnellnavigation">
|
||||
<a class="primary" href="#choose">Gerät wählen</a>
|
||||
<a href="#observed">Beobachtet</a>
|
||||
<a href="#detail">Details</a>
|
||||
<a href="#status-section">Status</a>
|
||||
<a href="#guide">Ablauf</a>
|
||||
</nav>
|
||||
<main>
|
||||
<section class="wide" 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 class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-row">
|
||||
<span class="brand-mark">SH</span>
|
||||
<h1>SillyHome Next</h1>
|
||||
</div>
|
||||
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
|
||||
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
||||
</div>
|
||||
<div class="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>
|
||||
</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>
|
||||
<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">
|
||||
@@ -151,29 +182,79 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<label for="actuator-select">Oder aus Liste wählen</label>
|
||||
<select id="actuator-select" onchange="selectActuatorFromList()">
|
||||
<option value="">Geräteliste wird geladen ...</option>
|
||||
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
|
||||
<option value="">Geräteliste bei Bedarf laden</option>
|
||||
</select>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="wide" id="observed">
|
||||
<h2>2. Beobachtete Geräte</h2>
|
||||
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
||||
<section class="board-panel" id="observed">
|
||||
<div class="panel-title">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="wide" id="detail">
|
||||
<h2>3. Lernfortschritt und Freigabe</h2>
|
||||
<section class="detail-panel" id="detail">
|
||||
<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>
|
||||
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
||||
</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>
|
||||
<script>
|
||||
const escapeHtml = value => String(value ?? "")
|
||||
@@ -189,12 +270,27 @@ let manualContextState = {options: [], selected: new Set()};
|
||||
let cachedActuators = null;
|
||||
let cachedEntities = null;
|
||||
let cachedDiscovery = null;
|
||||
let discoveryLoadPromise = null;
|
||||
let currentSensorWeightGroups = [];
|
||||
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) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function invalidateDashboardCache() {
|
||||
cachedActuators = null;
|
||||
cachedEntities = null;
|
||||
cachedDiscovery = null;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
||||
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
|
||||
@@ -202,8 +298,20 @@ async function api(path, options = {}) {
|
||||
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) {
|
||||
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 = {
|
||||
trained: "lernt",
|
||||
pending_history: "sammelt Historie",
|
||||
@@ -212,26 +320,32 @@ function lifecycleLabel(record) {
|
||||
archived: "wartet auf Kontext",
|
||||
orphaned: "Aktor nicht gefunden",
|
||||
};
|
||||
return labels[record.lifecycle.status] || record.lifecycle.status;
|
||||
return labels[lifecycleStatus] || lifecycleStatus;
|
||||
}
|
||||
|
||||
function statusClass(record) {
|
||||
if (record.behavior.status === "trained") return "ok";
|
||||
if (record.lifecycle.status === "trained") return "ok";
|
||||
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
|
||||
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||
if (behaviorStatus === "trained") return "ok";
|
||||
if (lifecycleStatus === "trained") return "ok";
|
||||
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
|
||||
function behaviorLabel(record) {
|
||||
if (record.behavior.mode === "active") return "aktiv freigegeben";
|
||||
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
|
||||
if (record.behavior.status === "blocked") return "Lernen blockiert";
|
||||
const mode = record.behavior_mode || record.behavior?.mode;
|
||||
const status = record.behavior_status || record.behavior?.status;
|
||||
if (mode === "active") return "aktiv freigegeben";
|
||||
if (status === "trained") return "Shadow-Vorhersage";
|
||||
if (status === "blocked") return "Lernen blockiert";
|
||||
return "sammelt Handlungen";
|
||||
}
|
||||
|
||||
function predictionLabel(record) {
|
||||
return record.behavior.prediction
|
||||
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
||||
const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
|
||||
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
|
||||
return target
|
||||
? `${target} (${Math.round(confidence * 100)} %)`
|
||||
: "Keine fällige Aktion";
|
||||
}
|
||||
|
||||
@@ -310,52 +424,158 @@ function optionGroups(entities, selectedIds = new Set()) {
|
||||
}
|
||||
|
||||
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 chips = document.getElementById("status-chips");
|
||||
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
|
||||
try {
|
||||
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
||||
api("health"),
|
||||
api("health/websocket"),
|
||||
api("ml/health"),
|
||||
api("v1/actuators/reconciliation/state"),
|
||||
api("v1/actuators"),
|
||||
const [health, websocket, ml, reconciliation] = await Promise.allSettled([
|
||||
apiWithTimeout("health"),
|
||||
apiWithTimeout("health/websocket"),
|
||||
apiWithTimeout("ml/health"),
|
||||
apiWithTimeout("v1/actuators/reconciliation/state"),
|
||||
]);
|
||||
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 = [
|
||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
||||
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
||||
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
||||
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
|
||||
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
|
||||
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
|
||||
].join("");
|
||||
} catch (error) {
|
||||
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||
chips.innerHTML = "";
|
||||
}
|
||||
await loadDashboardData();
|
||||
renderActuatorDiscovery();
|
||||
renderConfiguredActuators();
|
||||
void loadActuatorSuggestions();
|
||||
}
|
||||
|
||||
function renderDashboardStatus(dashboard) {
|
||||
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() {
|
||||
const [actuators, entities, discovery] = await Promise.all([
|
||||
api("v1/actuators"),
|
||||
api("v1/entities"),
|
||||
await loadSummaryData();
|
||||
await loadDiscoveryData();
|
||||
}
|
||||
|
||||
async function loadDiscoveryData() {
|
||||
const discovery = await api("v1/actuators/discovery");
|
||||
cachedDiscovery = discovery;
|
||||
}
|
||||
|
||||
async function loadFullDashboardData() {
|
||||
const [actuators, discovery] = await Promise.all([
|
||||
api("v1/actuators/summary"),
|
||||
api("v1/actuators/discovery"),
|
||||
]);
|
||||
cachedActuators = actuators;
|
||||
cachedEntities = entities;
|
||||
cachedEntities = [];
|
||||
cachedDiscovery = discovery;
|
||||
}
|
||||
|
||||
async function loadActuatorDiscovery() {
|
||||
if (!cachedActuators || !cachedDiscovery) {
|
||||
await loadDashboardData();
|
||||
const select = document.getElementById("actuator-select");
|
||||
if (select && !cachedDiscovery) {
|
||||
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
|
||||
}
|
||||
if (!cachedActuators) {
|
||||
await loadSummaryData();
|
||||
}
|
||||
if (!cachedDiscovery) {
|
||||
await loadDiscoveryData();
|
||||
}
|
||||
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() {
|
||||
const options = document.getElementById("actuator-options");
|
||||
const select = document.getElementById("actuator-select");
|
||||
@@ -370,7 +590,7 @@ function renderActuatorDiscovery() {
|
||||
renderActuatorSelect();
|
||||
} catch (error) {
|
||||
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()
|
||||
);
|
||||
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 ...";
|
||||
try {
|
||||
const record = await api("v1/actuators", {
|
||||
@@ -509,8 +733,8 @@ async function configureActuator() {
|
||||
}
|
||||
|
||||
async function loadConfiguredActuators() {
|
||||
if (!cachedActuators || !cachedEntities) {
|
||||
await loadDashboardData();
|
||||
if (!cachedActuators) {
|
||||
await loadSummaryData();
|
||||
}
|
||||
renderConfiguredActuators();
|
||||
}
|
||||
@@ -519,40 +743,38 @@ function renderConfiguredActuators() {
|
||||
const box = document.getElementById("configured-actuators");
|
||||
try {
|
||||
const rows = cachedActuators || [];
|
||||
const entities = cachedEntities || [];
|
||||
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
|
||||
const groups = new Map();
|
||||
for (const record of rows) {
|
||||
const entity = entityMap.get(record.actuator_entity_id) || {};
|
||||
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
|
||||
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
|
||||
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));
|
||||
box.innerHTML = rows.length ? `
|
||||
${groupedRows.map(([group, items]) => `
|
||||
<h3>${escapeHtml(group)}</h3>
|
||||
<details class="group-panel">
|
||||
<summary>${escapeHtml(group)} (${items.length})</summary>
|
||||
<div class="card-list">
|
||||
${items.map(({record, entity}) => `
|
||||
${items.map(({record}) => `
|
||||
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
||||
<div class="card-title">
|
||||
<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="${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>
|
||||
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
|
||||
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div>
|
||||
<div class="metric"><strong>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.sample_count}</div>
|
||||
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
|
||||
${record.behavior.mode === "active"
|
||||
${record.behavior_mode === "active"
|
||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
||||
: record.behavior.activation_ready
|
||||
: record.activation_ready
|
||||
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
||||
: ""}
|
||||
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
||||
@@ -560,6 +782,7 @@ function renderConfiguredActuators() {
|
||||
</article>
|
||||
`).join("")}
|
||||
</div>
|
||||
</details>
|
||||
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||
} catch (error) {
|
||||
box.textContent = error.message;
|
||||
@@ -569,14 +792,10 @@ function renderConfiguredActuators() {
|
||||
async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
currentActuatorId = actuatorId;
|
||||
const box = document.getElementById("actuator-detail");
|
||||
renderActuatorDetailShell(actuatorId);
|
||||
try {
|
||||
let record;
|
||||
try {
|
||||
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
|
||||
} catch (_) {
|
||||
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
||||
}
|
||||
await loadContextOptions(actuatorId);
|
||||
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
||||
contextOptions = [];
|
||||
const contexts = [
|
||||
record.assignment.selected_numeric_entity_id,
|
||||
...record.assignment.selected_context_entity_ids,
|
||||
@@ -585,6 +804,62 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
.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>`)
|
||||
.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
|
||||
? `<ul>${contexts.map(entityId => `
|
||||
<li>
|
||||
@@ -614,8 +889,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
selected: manualContextIds,
|
||||
};
|
||||
const manualAssignment = `
|
||||
<div class="manual-context">
|
||||
<h3>Kontext selbst festlegen</h3>
|
||||
<details class="manual-context">
|
||||
<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>
|
||||
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
||||
<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>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</details>
|
||||
`;
|
||||
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>
|
||||
@@ -705,19 +980,112 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
</div>
|
||||
<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>
|
||||
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
||||
${automationControls}
|
||||
<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>"}
|
||||
<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>
|
||||
${currentContextControls}
|
||||
${manualAssignment}
|
||||
`;
|
||||
void hydrateContextOptions(record);
|
||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
} catch (error) {
|
||||
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) {
|
||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||
const selectedContextIds = Array.from(
|
||||
@@ -737,6 +1105,7 @@ async function saveManualAssignment(actuatorId) {
|
||||
note: "Manuell im Dashboard gesetzt",
|
||||
}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
|
||||
} catch (error) {
|
||||
@@ -760,6 +1129,7 @@ async function removeContextEntity(actuatorId, entityId) {
|
||||
note: `Entity ${entityId} entfernt`,
|
||||
}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, "Kontext-Entity entfernt.");
|
||||
} catch (error) {
|
||||
@@ -784,6 +1154,7 @@ async function evaluateActuator(actuatorId) {
|
||||
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}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, message);
|
||||
} catch (error) {
|
||||
@@ -801,6 +1172,7 @@ async function sendFeedback(actuatorId, correct) {
|
||||
expected_state: expectedState || null,
|
||||
}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
|
||||
} catch (error) {
|
||||
@@ -826,6 +1198,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
|
||||
restore_paused_automations: restorePausedAutomations,
|
||||
}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId);
|
||||
} catch (error) {
|
||||
@@ -844,6 +1217,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
|
||||
enabled,
|
||||
}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId);
|
||||
} 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) {
|
||||
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
111
docs/V1_0_0_OPERATING_GUIDE.md
Normal file
111
docs/V1_0_0_OPERATING_GUIDE.md
Normal 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
80
docs/V1_0_ACCEPTANCE.md
Normal 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`.
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "0.7.18"
|
||||
version = "1.0.4"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -28,8 +28,10 @@ class FakeHaReader(HaReader):
|
||||
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
|
||||
self._entities = entities
|
||||
self._history = history
|
||||
self.read_entities_calls = 0
|
||||
|
||||
def read_entities(self) -> list[HaEntitySummary]:
|
||||
self.read_entities_calls += 1
|
||||
return list(self._entities)
|
||||
|
||||
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:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
@@ -9,11 +9,14 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
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 "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 "Oder aus Liste wählen" 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 "Ohne deine spätere Freigabe wird nichts geschaltet" 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 "Vorhersage jetzt prüfen" not 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 "Manuelle Overrides" not in response.text
|
||||
|
||||
Reference in New Issue
Block a user