Compare commits

...

14 Commits

Author SHA1 Message Date
0101596e93 Add safety dashboard and decision transparency
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:26:49 +02:00
ca253d1e6c Fix dashboard text overflow and close v1 docs gaps
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:53:25 +02:00
b9b5def7bb Add actuator sensor weighting controls
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 11:41:46 +02:00
94530d3ecf Stream dashboard loading and header menu
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:55:28 +02:00
63b8684197 Document v1 acceptance and dashboard stats
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:45:30 +02:00
f8801e469a Polish v1 dashboard loading and layout
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:33:30 +02:00
787516ac67 Avoid per-request discovery classification in dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:19:59 +02:00
7ba9807a4e Prepare SillyHome Next 1.0.0 dashboard and API rework
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:13:43 +02:00
4db4276b95 Rework dashboard loading and cache entity metadata
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:41:50 +02:00
98a2b2cc38 Fix dashboard summary status rendering
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:21:14 +02:00
387e027fe2 Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:09:35 +02:00
f8bee92e64 Optimize dashboard categories and context loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:02:19 +02:00
9ddb065f62 Speed up HA event processing
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:58:58 +02:00
8222f24ebe Group configured actuator overview
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:51:02 +02:00
19 changed files with 2493 additions and 209 deletions

View File

@@ -1,5 +1,120 @@
# Changelog
## 1.1.0 - 2026-06-17
- Dashboard als Einrichtungs- und Visualisierungszentrale erweitert:
Job-Queue, Sicherheitsprofil, Entscheidungsakte, Wissen/Annahmen/
Unsicherheiten und Beitragsfaktoren pro Aktor.
- Lokales Safety-Profil pro Aktor eingefuehrt: manuelle Sperre,
Freigabestufe, Mindest-Confidence und optionaler Cooldown werden vor
autonomem Schalten ausgewertet.
- Sofort-Schaltpfad bleibt schnell: Safety prueft nur lokale Daten; der
Home-Assistant-Serviceaufruf wird nicht durch Discovery, Training oder
Statistik blockiert.
- Sichtbare Job-Queue fuer Discovery, Reconciliation, Training, Evaluation
und Automation-Refresh mit Status, Dauer, Fehler und Zusammenfassung.
- Entscheidungsstatistik erweitert: Sensor-/Kontextfaktoren, aktive
Gewichtungen, Sample-/Confidence-Trends und Feedbackzaehler werden
persistiert.
## 1.0.5 - 2026-06-17
- Lange Friendly Names, Entity-IDs, Chips, Tabellenwerte und Metriken brechen
im Dashboard responsiv um und laufen nicht mehr aus Karten oder Boxen.
- Automatisierter Performance-Budget-Test fuer Root-HTML und
`/v1/actuators/dashboard` gegen das 5-Sekunden-Limit ergaenzt.
- HA-/Ingress-Verifikation mit Supervisor-Status, Backup, Watchdog,
Hard-Reload und Rollback im Operating Guide dokumentiert.
## 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.
- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie
"Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen.
- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände,
Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer
kategorisiert und per Suche/Kategorie erreichbar.
- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur,
Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter.
## 0.7.17 - 2026-06-16
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
dem teuren State-Cache-Listenbau verworfen.
- WebSocket nutzt Keepalive und reconnectet nach Abbrüchen nach 1s statt 5s.
## 0.7.16 - 2026-06-16
- Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und
mit Friendly Name angezeigt.
## 0.7.15 - 2026-06-16
- Add-on-Start ist robust gegen Home-Assistant-Core-502 beim Systemboot:
API und WebSocket-Listener starten trotzdem, Reconciliation/Training werden

View File

@@ -11,6 +11,12 @@ 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)
- Version 1.1.0 Safety, Transparenz und Job-Queue:
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad
@@ -58,6 +64,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

View File

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

View File

@@ -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
@@ -72,29 +73,42 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"fan",
"humidifier",
"input_boolean",
"input_number",
"input_select",
"light",
"media_player",
"person",
"remote",
"scene",
"sensor",
"sun",
"switch",
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 120
_CONTEXT_SUGGESTION_LIMIT = 500
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"bytes",
"connect",
"count",
"data",
"diagnostic",
"firmware",
"gesehen",
"heat",
"inbytes",
"interface",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"outbytes",
"pfsense",
"reason",
"restart",
"rssi",
@@ -105,6 +119,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
"overheating",
"overload",
"uptime",
"vpn",
"uberhitzung",
"ueberhitzung",
"ueberlast",
@@ -156,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.")
@@ -175,13 +190,10 @@ class ActuatorReconciliationService:
selected = entity.entity_id in selected_ids
if selected:
score = max(score, 1.0)
if not selected and (
_is_diagnostic_context(entity)
or not _has_context_relationship(actuator, entity)
):
continue
if not selected and score < 0.1:
if not selected and _is_diagnostic_context(entity):
continue
if not selected and not _has_context_relationship(actuator, entity):
score = max(score, 0.01)
ranked.append((score, _context_sort_group(entity), entity))
ranked.sort(
key=lambda item: (
@@ -228,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,
)
@@ -242,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,
@@ -260,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={
@@ -365,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
@@ -725,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"}:
@@ -733,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}"
@@ -849,12 +954,17 @@ def _merge_manual_candidates(
for entity_id in selected_entity_ids:
existing = by_id.get(entity_id)
if existing is not None:
evidence = [
item
for item in existing.evidence
if item != "Manuell vom Nutzer als relevant festgelegt."
]
by_id[entity_id] = existing.model_copy(
update={
"auto_accepted": True,
"confidence": 1.0,
"evidence": [
*existing.evidence,
*evidence,
"Manuell vom Nutzer als relevant festgelegt.",
],
}
@@ -881,15 +991,66 @@ 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:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
mapping = {
"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", "illuminance", "motion", "occupancy", "opening", "presence", "window"},
"media_player": {"occupancy", "presence"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
}
return frozenset(
mapping.get(
domain,
{"door", "garage_door", "motion", "occupancy", "opening", "presence"},
)
)
mapping = {
"climate": {"temperature", "humidity", "power"},
"climate": {"temperature", "humidity"},
"cover": {"illuminance", "temperature", "wind_speed"},
"fan": {"temperature", "humidity", "power"},
"humidifier": {"humidity", "temperature", "power"},
"light": {"illuminance", "power", "energy"},
"fan": {"temperature", "humidity", "moisture"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"illuminance"},
"media_player": {"power", "energy"},
"remote": {"battery"},
"switch": {"power", "energy", "current"},
"valve": {"temperature", "pressure", "humidity"},
}

View File

@@ -37,6 +37,21 @@ class BehaviorStatus(StrEnum):
BLOCKED = "blocked"
class SafetyStage(StrEnum):
OBSERVE = "observe"
SUGGEST = "suggest"
SHADOW = "shadow"
PARTIAL = "partial"
ACTIVE = "active"
class JobStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class AssignmentCandidate(BaseModel):
entity_id: str
domain: str
@@ -49,6 +64,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 +79,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
@@ -110,6 +136,61 @@ class BehaviorPrediction(BaseModel):
execution_reason: str = "Vorhersage wurde noch nicht ausgeführt."
class DecisionFactor(BaseModel):
entity_id: str | None = None
label: str
factor_type: str = Field(max_length=40)
state: str | None = None
weight: float = Field(default=1.0, ge=0.0, le=1.0)
contribution: float = Field(default=0.0, ge=0.0, le=1.0)
evidence: list[str] = Field(default_factory=list)
class SafetyRule(BaseModel):
rule_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=160)
enabled: bool = True
blocking: bool = True
reason: str = Field(default="", max_length=300)
def default_safety_rules() -> list[SafetyRule]:
return [
SafetyRule(
rule_id="activation_ready",
label="Nur nach Lernfreigabe aktiv schalten",
reason="Der Aktor muss genug eindeutiges Verhalten gelernt haben.",
),
SafetyRule(
rule_id="confidence_threshold",
label="Mindest-Sicherheit einhalten",
reason="Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus.",
),
SafetyRule(
rule_id="cooldown",
label="Sicherheits-Cooldown gegen Hin-und-her-Schalten",
reason="Gleiche Zielzustände werden nicht zu schnell wiederholt.",
),
SafetyRule(
rule_id="manual_block",
label="Manuelle Sperre respektieren",
reason="Nutzer können jeden Aktor sofort blockieren.",
),
]
class SafetyProfile(BaseModel):
stage: SafetyStage = SafetyStage.SHADOW
manual_block: bool = False
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
min_confidence_on: float | None = Field(default=None, ge=0.0, le=1.0)
min_confidence_off: float | None = Field(default=None, ge=0.0, le=1.0)
cooldown_seconds: int | None = Field(default=None, ge=0)
rules: list[SafetyRule] = Field(default_factory=default_safety_rules)
updated_at: datetime | None = None
note: str | None = Field(default=None, max_length=500)
class ExecutionEvent(BaseModel):
target_state: str
executed_at: datetime
@@ -139,6 +220,16 @@ class BehaviorState(BaseModel):
related_automations: list[RelatedAutomation] = Field(default_factory=list)
paused_automation_entity_ids: list[str] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert."
safety: SafetyProfile = Field(default_factory=SafetyProfile)
decision_factors: list[DecisionFactor] = Field(default_factory=list)
knowledge: list[str] = Field(default_factory=list)
assumptions: list[str] = Field(default_factory=list)
uncertainties: list[str] = Field(default_factory=list)
safety_blockers: list[str] = Field(default_factory=list)
sample_trend: list[int] = Field(default_factory=list)
confidence_trend: list[float] = Field(default_factory=list)
correct_feedback_count: int = Field(default=0, ge=0)
incorrect_feedback_count: int = Field(default=0, ge=0)
class ActuatorRecord(BaseModel):
@@ -165,5 +256,22 @@ class ReconciliationState(BaseModel):
last_summary: str = "Noch keine Reconciliation ausgeführt."
class JobQueueItem(BaseModel):
job_id: str = Field(min_length=1, max_length=120)
kind: str = Field(min_length=1, max_length=40)
target: str | None = Field(default=None, max_length=160)
trigger: str = Field(default="manual", max_length=40)
status: JobStatus = JobStatus.PENDING
started_at: datetime | None = None
completed_at: datetime | None = None
duration_ms: int | None = Field(default=None, ge=0)
error: str | None = Field(default=None, max_length=500)
summary: str = Field(default="", max_length=500)
class JobQueueState(BaseModel):
jobs: list[JobQueueItem] = Field(default_factory=list)
def model_id_for_actuator(actuator_entity_id: str) -> str:
return f"actuator.{actuator_entity_id}"

View File

@@ -8,6 +8,9 @@ from threading import RLock
from app.actuators.models import (
ActuatorRecord,
JobQueueItem,
JobQueueState,
JobStatus,
LifecycleStatus,
ModelLifecycleState,
ReconciliationState,
@@ -22,6 +25,7 @@ class ActuatorStore:
self._actuators_root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._reconciliation_state_path = self._root / "reconciliation_state.json"
self._job_queue_path = self._root / "job_queue.json"
def list(self) -> list[ActuatorRecord]:
with self._lock:
@@ -85,6 +89,75 @@ class ActuatorStore:
self._persist_reconciliation_state(state)
return state
def load_job_queue(self) -> JobQueueState:
with self._lock:
if not self._job_queue_path.exists():
return JobQueueState()
try:
return JobQueueState.model_validate_json(
self._job_queue_path.read_text(encoding="utf-8")
)
except ValueError as exc:
raise ValueError("Ungültiger Job-Queue-Status.") from exc
def start_job(
self,
*,
kind: str,
trigger: str,
target: str | None = None,
summary: str = "",
) -> JobQueueItem:
now = datetime.now(timezone.utc)
job = JobQueueItem(
job_id=f"{now.strftime('%Y%m%d%H%M%S%f')}-{kind}-{target or 'all'}",
kind=kind,
target=target,
trigger=trigger,
status=JobStatus.RUNNING,
started_at=now,
summary=summary,
)
with self._lock:
queue = self.load_job_queue()
queue.jobs = [*queue.jobs, job][-50:]
self._persist_job_queue(queue)
return job
def finish_job(
self,
job_id: str,
*,
status: JobStatus,
summary: str = "",
error: str | None = None,
) -> JobQueueItem | None:
now = datetime.now(timezone.utc)
with self._lock:
queue = self.load_job_queue()
updated_job: JobQueueItem | None = None
jobs: list[JobQueueItem] = []
for job in queue.jobs:
if job.job_id != job_id:
jobs.append(job)
continue
duration_ms = None
if job.started_at is not None:
duration_ms = max(0, int((now - job.started_at).total_seconds() * 1000))
updated_job = job.model_copy(
update={
"status": status,
"completed_at": now,
"duration_ms": duration_ms,
"summary": summary or job.summary,
"error": error,
}
)
jobs.append(updated_job)
queue.jobs = jobs[-50:]
self._persist_job_queue(queue)
return updated_job
def _target(self, actuator_entity_id: str) -> Path:
if "." not in actuator_entity_id:
raise ValueError("Ungültige actuator_entity_id.")
@@ -108,6 +181,14 @@ class ActuatorStore:
)
os.replace(temporary, self._reconciliation_state_path)
def _persist_job_queue(self, state: JobQueueState) -> None:
temporary = self._job_queue_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, self._job_queue_path)
@staticmethod
def _load(path: Path) -> ActuatorRecord:
try:

View File

@@ -1,14 +1,21 @@
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.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
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,11 +45,21 @@ 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)
class SafetyProfileRequest(BaseModel):
safety: SafetyProfile
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
@@ -55,10 +72,79 @@ 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]
jobs: JobQueueState = Field(default_factory=JobQueueState)
@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:
job = _start_job(
request,
kind="discovery",
trigger="manual" if refresh else "cache-miss",
summary="Home-Assistant-Entities werden gelesen und klassifiziert.",
)
try:
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
except Exception as exc:
_finish_job(job, request, status=JobStatus.FAILED, summary="Discovery fehlgeschlagen.", error=str(exc))
raise
_finish_job(job, request, status=JobStatus.COMPLETED, summary=f"{len(fresh_entities)} Entities klassifiziert.")
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 +162,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 +229,102 @@ 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)
store = getattr(request.app.state, "actuator_store", None)
jobs = (
store.load_job_queue()
if isinstance(store, ActuatorStore)
else JobQueueState()
)
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,
jobs=jobs,
)
@router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@@ -214,6 +396,18 @@ def record_feedback(
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
def set_safety_profile(
actuator_entity_id: str,
payload: SafetyProfileRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).set_safety_profile(actuator_entity_id, profile=payload.safety)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation(
actuator_entity_id: str,
@@ -254,6 +448,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,
@@ -262,11 +477,27 @@ def refresh_related_automations(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
job = _start_job(
request,
kind="automation_refresh",
trigger="manual",
target=actuator_entity_id,
summary="Passende HA-Automationen werden gesucht.",
)
try:
return _behavior(request).refresh_related_automations(actuator_entity_id)
record = _behavior(request).refresh_related_automations(actuator_entity_id)
_finish_job(
job,
request,
status=JobStatus.COMPLETED,
summary=f"{len(record.behavior.related_automations)} Automationen gefunden.",
)
return record
except KeyError as exc:
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, HaClientError) as exc:
_finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc))
raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -307,12 +538,85 @@ def run_reconciliation(
request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState:
state = _service(request).reconcile_all(trigger=trigger)
_behavior(request).train_all()
_behavior(request).evaluate_all()
reconciliation_job = _start_job(
request,
kind="reconciliation",
trigger=trigger,
summary="Kontext, Zuordnung und Modelle werden abgeglichen.",
)
training_job: JobQueueItem | None = None
evaluation_job: JobQueueItem | None = None
try:
state = _service(request).reconcile_all(trigger=trigger)
_finish_job(reconciliation_job, request, status=JobStatus.COMPLETED, summary=state.last_summary)
reconciliation_job = None
training_job = _start_job(
request,
kind="training",
trigger=trigger,
summary="Gelernte Aktorhandlungen werden aktualisiert.",
)
_behavior(request).train_all()
_finish_job(training_job, request, status=JobStatus.COMPLETED, summary="Training abgeschlossen.")
training_job = None
evaluation_job = _start_job(
request,
kind="evaluation",
trigger=trigger,
summary="Aktuelle Vorhersagen werden neu berechnet.",
)
_behavior(request).evaluate_all()
_finish_job(evaluation_job, request, status=JobStatus.COMPLETED, summary="Evaluation abgeschlossen.")
evaluation_job = None
except Exception as exc:
for job in [reconciliation_job, training_job, evaluation_job]:
if isinstance(job, JobQueueItem) and job.status is JobStatus.RUNNING:
_finish_job(job, request, status=JobStatus.FAILED, summary="Job fehlgeschlagen.", error=str(exc))
raise
return state
@router.get("/job-queue/state", response_model=JobQueueState)
def get_job_queue(request: Request) -> JobQueueState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator Store nicht initialisiert.",
)
return store.load_job_queue()
def _start_job(
request: Request,
*,
kind: str,
trigger: str,
target: str | None = None,
summary: str = "",
) -> JobQueueItem | None:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return None
return store.start_job(kind=kind, trigger=trigger, target=target, summary=summary)
def _finish_job(
job: JobQueueItem | None,
request: Request,
*,
status: JobStatus,
summary: str,
error: str | None = None,
) -> None:
if job is None:
return
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return
store.finish_job(job.job_id, status=status, summary=summary, error=error)
def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
@@ -333,6 +637,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],

View File

@@ -12,8 +12,11 @@ from app.actuators.models import (
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
DecisionFactor,
ExecutionEvent,
RelatedAutomation,
SafetyProfile,
SafetyStage,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
@@ -175,6 +178,10 @@ class BehaviorEngine:
"patterns": patterns[-_MAX_PATTERNS:],
"last_trained_at": now,
"reason": reason,
"sample_trend": [*record.behavior.sample_trend, len(patterns)][-30:],
"knowledge": _knowledge_lines(record, len(patterns), trusted_actions),
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, len(patterns), trusted_actions),
}
)
return self._save_behavior(record, behavior)
@@ -268,16 +275,25 @@ class BehaviorEngine:
timezone_name=self._settings.timezone,
)
if prediction is not None:
safety_allowed, safety_blockers = self._assess_safety(
record,
actuator.state,
prediction,
now,
)
prediction = prediction.model_copy(
update={
"execution_reason": self._prediction_execution_reason(
record,
actuator.state,
prediction,
now,
"execution_reason": (
"Ausführung ist freigegeben."
if safety_allowed
else "Nicht ausgeführt: " + " ".join(safety_blockers)
)
}
)
else:
safety_allowed = False
safety_blockers = ["Keine fällige Vorhersage."]
decision_factors = _decision_factors_for(record, current_context, prediction)
behavior = record.behavior.model_copy(
update={
"last_evaluated_at": now,
@@ -287,18 +303,21 @@ class BehaviorEngine:
if prediction is not None
else "Aktuell ist kein gelerntes Handlungsmuster fällig."
),
"decision_factors": decision_factors,
"knowledge": _knowledge_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"safety_blockers": safety_blockers if prediction is not None else [],
"confidence_trend": (
[*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:]
if prediction is not None
else record.behavior.confidence_trend
),
}
)
if (
prediction is not None
and behavior.mode is BehaviorMode.ACTIVE
and prediction.confidence >= self._settings.prediction_confidence
and actuator.state != prediction.target_state
and self._cooldown_elapsed(
behavior,
now,
prediction.target_state,
)
and safety_allowed
):
domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state)
@@ -403,6 +422,8 @@ class BehaviorEngine:
)
)
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
correct_count = record.behavior.correct_feedback_count + 1
incorrect_count = record.behavior.incorrect_feedback_count
else:
target = prediction.target_state if prediction is not None else None
if target:
@@ -431,6 +452,8 @@ class BehaviorEngine:
)
)
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
correct_count = record.behavior.correct_feedback_count
incorrect_count = record.behavior.incorrect_feedback_count + 1
behavior = record.behavior.model_copy(
update={
"patterns": patterns[-_MAX_PATTERNS:],
@@ -441,6 +464,23 @@ class BehaviorEngine:
),
"reason": reason,
"last_trained_at": now,
"correct_feedback_count": correct_count,
"incorrect_feedback_count": incorrect_count,
}
)
return self._save_behavior(record, behavior)
def set_safety_profile(
self,
actuator_entity_id: str,
*,
profile: SafetyProfile,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
behavior = record.behavior.model_copy(
update={
"safety": profile.model_copy(update={"updated_at": datetime.now(timezone.utc)}),
"reason": "Sicherheitsprofil wurde manuell aktualisiert.",
}
)
return self._save_behavior(record, behavior)
@@ -527,6 +567,9 @@ class BehaviorEngine:
update={
"mode": mode,
"approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.ACTIVE, "updated_at": now}
),
"reason": (
"Autonomes Lernen und Schalten wurde ausdrücklich freigegeben."
),
@@ -607,6 +650,9 @@ class BehaviorEngine:
update={
"mode": mode,
"approved_at": approved_at,
"safety": record.behavior.safety.model_copy(
update={"stage": SafetyStage.SHADOW, "updated_at": now}
),
"related_automations": [
automation.model_copy(update={"enabled": True})
if (
@@ -651,6 +697,51 @@ class BehaviorEngine:
return "Nicht ausgeführt: Sicherheits-Cooldown ist noch aktiv."
return "Ausführung ist freigegeben."
def _assess_safety(
self,
record: ActuatorRecord,
current_state: str | None,
prediction: BehaviorPrediction,
now: datetime,
) -> tuple[bool, list[str]]:
profile = record.behavior.safety
blockers: list[str] = []
domain = record.actuator_entity_id.split(".", 1)[0]
if not record.enabled:
blockers.append("Aktor ist in SillyHome deaktiviert.")
if domain not in _SAFE_ACTIVE_DOMAINS:
blockers.append(f"Domain {domain} ist nicht für autonomes Schalten freigegeben.")
if profile.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
stage = profile.stage
if (
record.behavior.mode is BehaviorMode.ACTIVE
and profile.updated_at is None
and stage is SafetyStage.SHADOW
):
stage = SafetyStage.ACTIVE
if stage not in {SafetyStage.ACTIVE, SafetyStage.PARTIAL}:
blockers.append(f"Safety-Stufe {stage.value} erlaubt noch kein Schalten.")
if record.behavior.mode is not BehaviorMode.ACTIVE:
blockers.append("SillyHome ist im Shadow-Modus.")
if not record.behavior.activation_ready:
blockers.append(record.behavior.activation_reason)
threshold = _confidence_threshold_for(profile, prediction.target_state)
if prediction.confidence < threshold:
blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
)
if current_state == prediction.target_state:
blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed(
record.behavior,
now,
prediction.target_state,
cooldown_seconds=profile.cooldown_seconds,
):
blockers.append("Sicherheits-Cooldown ist noch aktiv.")
return not blockers, blockers
def _build_patterns(
self,
*,
@@ -701,6 +792,8 @@ class BehaviorEngine:
behavior: BehaviorState,
now: datetime,
target_state: str,
*,
cooldown_seconds: int | None = None,
) -> bool:
if behavior.last_executed_at is None:
return True
@@ -708,7 +801,9 @@ class BehaviorEngine:
if last_event is not None and last_event.target_state != target_state:
return True
return (now - behavior.last_executed_at) >= timedelta(
seconds=self._settings.execution_cooldown_seconds
seconds=cooldown_seconds
if cooldown_seconds is not None
else self._settings.execution_cooldown_seconds
)
def _save_behavior(
@@ -791,6 +886,110 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
return parsed
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
if target_state == "on" and profile.min_confidence_on is not None:
return profile.min_confidence_on
if target_state in {"off", "closed"} and profile.min_confidence_off is not None:
return profile.min_confidence_off
return profile.min_confidence
def _decision_factors_for(
record: ActuatorRecord,
current_context: dict[str, str | None],
prediction: BehaviorPrediction | None,
) -> list[DecisionFactor]:
factors: list[DecisionFactor] = []
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
for entity_id, state in current_context.items():
candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0
relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4)
factors.append(
DecisionFactor(
entity_id=entity_id,
label=(
candidate.friendly_name
if candidate is not None and candidate.friendly_name
else entity_id
),
factor_type="context",
state=state,
weight=round(weight, 4),
contribution=contribution,
evidence=(
candidate.evidence[:4]
if candidate is not None
else ["Aktuell ausgewähltes Kontextsignal."]
),
)
)
if prediction is not None:
factors.append(
DecisionFactor(
label=f"Vorhersage {prediction.target_state}",
factor_type="prediction",
state=prediction.target_state,
weight=1.0,
contribution=prediction.confidence,
evidence=[prediction.reason],
)
)
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _knowledge_lines(
record: ActuatorRecord,
sample_count: int,
trusted_actions: int,
) -> list[str]:
lines = [
f"{sample_count} historische Aktorhandlungen sind ausgewertet.",
f"{trusted_actions} Handlungen stammen eindeutig von Nutzer oder HA-Automationen.",
]
if record.assignment.selected_numeric_entity_id:
lines.append(f"Hauptsensor: {record.assignment.selected_numeric_entity_id}.")
if record.assignment.selected_context_entity_ids:
lines.append(
f"{len(record.assignment.selected_context_entity_ids)} Kontextsignale sind verbunden."
)
return lines
def _assumption_lines(record: ActuatorRecord) -> list[str]:
lines = [
"Ähnliche Zeitfenster und ähnliche Kontextzustände deuten auf ähnliche Nutzerabsicht hin."
]
if record.manual_override is not None:
lines.append("Manuelle Sensor-/Kontextkorrekturen werden höher gewichtet.")
if record.behavior.related_automations:
lines.append("Passende HA-Automationen gelten als starker Hinweis auf vorhandene Logik.")
return lines
def _uncertainty_lines(
record: ActuatorRecord,
sample_count: int,
trusted_actions: int,
) -> list[str]:
lines: list[str] = []
if sample_count < trusted_actions + 3:
lines.append("Noch wenig Varianz in den gelernten Handlungen.")
if trusted_actions < sample_count:
lines.append("Ein Teil der Handlungen ist nicht eindeutig Nutzer oder Automation zugeordnet.")
if record.assignment.review_required:
lines.append("Die automatische Kontextzuordnung verlangt noch Prüfung.")
if record.behavior.incorrect_feedback_count:
lines.append(
f"{record.behavior.incorrect_feedback_count} negative Feedbacks senken Vertrauen."
)
return lines or ["Keine kritische Unsicherheit aus den lokalen Daten erkannt."]
def predict_behavior(
patterns: list[BehaviorPattern],
*,
@@ -900,6 +1099,8 @@ def predict_behavior(
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "scene":
return "turn_on" if target_state == "on" else None
if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
return None

View File

@@ -95,6 +95,7 @@ _ACTUATOR_DOMAINS = frozenset({
"media_player",
"number",
"remote",
"scene",
"siren",
"switch",
"valve",
@@ -213,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"
@@ -229,6 +233,8 @@ def _actuator_category(entity: HaEntitySummary) -> str:
return "fan"
if entity.domain in {"media_player", "remote"}:
return "media_tv"
if entity.domain == "scene":
return "scene"
if entity.domain in {"input_boolean", "number"}:
return "helper"
return entity.domain
@@ -236,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"
@@ -257,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
)

View File

@@ -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.15",
version="1.1.0",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -211,7 +211,11 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(ws_url, ping_interval=None) as websocket:
async with websockets.connect(
ws_url,
ping_interval=20,
ping_timeout=10,
) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required":
@@ -263,6 +267,8 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
continue
new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state)
if not _is_relevant_state_change(store, str(entity_id)):
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread(
@@ -280,17 +286,17 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
if ws_status is not None:
ws_status.status = "reconnecting"
ws_status.error = str(exc)
await asyncio.sleep(5)
await asyncio.sleep(1)
except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None:
ws_status.status = "error"
ws_status.error = str(exc)
await asyncio.sleep(5)
await asyncio.sleep(1)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -341,6 +347,17 @@ def _update_ha_state_cache(
)
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
for record in store.list():
if record.actuator_entity_id == entity_id:
return True
if record.assignment.selected_numeric_entity_id == entity_id:
return True
if entity_id in record.assignment.selected_context_entity_ids:
return True
return False
def _ha_entity_from_event(
entity_id: str,
new_state: dict[str, object],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
# 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
```
Wenn der Add-on-Container aus dem Agent-Host nicht direkt routbar ist, gilt der
Home-Assistant-Supervisor als Verifikationsquelle:
- Add-on-Info pruefen: Version, `version_latest`, `update_available`, `state`,
`boot` und `watchdog`.
- Vor Updates eine Home-Assistant-Teil-Sicherung fuer **SillyHome Next**
erstellen.
- Nach einem Store-Reload und Update muss `version == version_latest`,
`update_available == false`, `state == started`, `boot == auto` und
`watchdog == true` gelten.
- Den HA-/Ingress-Tab nach jedem Update hart neu laden, weil Home Assistant
sonst alte HTML-/JavaScript-Ressourcen aus dem bestehenden Tab verwenden kann.
- Rollback erfolgt ueber die vorherige Add-on-Teil-Sicherung oder den letzten
Git-Tag; beide Referenzen im Release-/Abnahmeprotokoll notieren.
## 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.

82
docs/V1_0_ACCEPTANCE.md Normal file
View File

@@ -0,0 +1,82 @@
# 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`
- Performance-Budget:
- Automatisierter Test prueft Root-HTML und `/v1/actuators/dashboard` gegen
das 5-Sekunden-Budget mit kontrollierten Fake-HA-/Cache-Daten.
- HA-/Ingress-Verifikation:
- Supervisor-Update, Add-on-Status, Watchdog, Backup, Ingress-Hard-Reload
und Rollback sind im Operating Guide dokumentiert.
## 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
- 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.
## Rollback
- Git-Bundle-Backups liegen unter
`/root/.openclaw/workspace/backups/sillyhome-next/`.
- Vor `v1.0.1` wurde ein Home-Assistant-Teilbackup des Add-ons angelegt.
Referenz: `18a5b387`.
- Letzter Vor-1.0-Stand: `v0.7.21`.

View File

@@ -0,0 +1,72 @@
# SillyHome Next v1.1.0 Operating Guide
## Ziel
v1.1.0 macht das Dashboard zur Zentrale fuer Visualisierung, Einrichtung,
Sicherheit und manuelles Gegensteuern. Autonomes Schalten bleibt ein kurzer
lokaler Pfad: Vorhersage und Safety-Profil werden aus bereits vorhandenen Daten
bewertet, danach folgt direkt der Home-Assistant-Serviceaufruf.
## Sicherheitsmodell
Jeder Aktor hat ein Safety-Profil:
- `stage`: Beobachten, Vorschlagen, Shadow, Teilaktiv oder Aktiv.
- `manual_block`: harte manuelle Sperre.
- `min_confidence`: Mindest-Sicherheit fuer autonomes Schalten.
- `cooldown_seconds`: optionaler Aktor-Cooldown gegen schnelles Hin-und-her.
- Safety-Regeln: Freigabe, Confidence, Cooldown und manuelle Sperre.
Ein Aktor schaltet nur, wenn alle lokalen Safety-Regeln frei sind, der
Behavior-Modus aktiv ist, die Freigabe bereit ist, die Confidence passt, der
Zielzustand noch nicht erreicht ist und der Cooldown abgelaufen ist.
## Transparenz
Die Aktor-Detailansicht trennt:
- Wissen: belegte Fakten aus Historie, Zuordnung und Automationen.
- Annahmen: heuristische Schluesse wie Zeit-/Kontext-Aehnlichkeit.
- Unsicherheiten: geringe Datenmenge, unklare Quellen, Review-Bedarf oder
negatives Feedback.
- Beitragsfaktoren: Sensoren, Kontextsignale, aktive Gewichtung und Beitrag.
- Safety-Blocker: Gruende, warum nicht geschaltet wird.
## Job-Queue
Das Dashboard zeigt die letzten Jobs mit Status, Dauer, Fehler und
Zusammenfassung. Sichtbar sind:
- Discovery
- Reconciliation
- Training
- Evaluation
- Automation-Refresh
Die Queue ist persistent in `job_queue.json` und dient als Betriebsanzeige. Sie
blockiert nicht den Startpfad und nicht den Schaltpfad.
## Manuelles Gegensteuern
Im Dashboard koennen pro Aktor gesetzt werden:
- manuelle Sicherheitssperre
- Freigabestufe
- Mindest-Confidence
- optionaler Cooldown
- Sensor-Gewichtungen und Gruppen-Gewichtungen
- Kontextauswahl
- Feedback: Vorhersage korrekt/falsch
- HA-Automationen pausieren/fortsetzen
## Qualitaetspruefung
Vor Release:
```bash
.venv/bin/pytest -q
.venv/bin/ruff check .
.venv/bin/mypy app backend tests
git diff --check
node --check /tmp/sillyhome-dashboard.js
```

View File

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

View File

@@ -310,6 +310,48 @@ def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="fan.bad_lueftung",
domain="fan",
friendly_name="Bad Lüftung",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_luftfeuchtigkeit",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Bad Luftfeuchtigkeit",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Bad Leistung",
area_name="Bad",
),
]
service = _service(
tmp_path,
entities,
{
"sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0),
"sensor.bad_power": _points(8, start, 5.0),
},
)
record = service.configure_actuator("fan.bad_lueftung")
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
@@ -358,3 +400,39 @@ def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Pa
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
assert record.assignment.source is AssignmentSource.MANUAL
assert record.manual_override is not None
def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
domain="binary_sensor",
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
]
service = _service(tmp_path, entities, {})
service.configure_actuator("light.abstellkammer")
for _ in range(3):
service.set_manual_assignment(
"light.abstellkammer",
numeric_entity_id=None,
context_entity_ids=["binary_sensor.abstellkammer_motion"],
note="Manuell gesetzt",
)
record = service.get_actuator("light.abstellkammer")
candidate = next(
item
for item in record.context_candidates
if item.entity_id == "binary_sensor.abstellkammer_motion"
)
assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta
from pathlib import Path
@@ -28,8 +29,11 @@ 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
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
def read_entities(self) -> list[HaEntitySummary]:
self.read_entities_calls += 1
return list(self._entities)
def discover(
@@ -83,6 +87,7 @@ class FakeHaReader(HaReader):
service: str,
service_data: dict[str, object],
) -> list[object]:
self.service_calls.append((domain, service, service_data))
return []
def find_automations_for_entity(
@@ -213,6 +218,175 @@ 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_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post(
"/v1/actuators/light.abstellkammer/safety",
json={
"safety": {
"stage": "shadow",
"manual_block": True,
"min_confidence": 0.9,
"cooldown_seconds": 120,
"rules": [
{
"rule_id": "manual_block",
"label": "Manuelle Sperre respektieren",
"enabled": True,
"blocking": True,
"reason": "Test",
}
],
"note": "Test",
}
},
)
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["safety"]["min_confidence"] == 0.9
assert payload["behavior"]["safety"]["cooldown_seconds"] == 120
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"]
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post("/v1/actuators/reconciliation/run")
jobs = client.get("/v1/actuators/job-queue/state")
assert response.status_code == 200
assert jobs.status_code == 200
payload = jobs.json()
assert [job["kind"] for job in payload["jobs"][-3:]] == [
"reconciliation",
"training",
"evaluation",
]
assert payload["jobs"][-1]["status"] == "completed"
def test_dashboard_start_path_stays_within_five_second_budget(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"})
root_started_at = perf_counter()
root_response = client.get("/")
root_elapsed = perf_counter() - root_started_at
dashboard_started_at = perf_counter()
dashboard_response = client.get("/v1/actuators/dashboard")
dashboard_elapsed = perf_counter() - dashboard_started_at
assert root_response.status_code == 200
assert dashboard_response.status_code == 200
assert root_elapsed < 5.0
assert dashboard_elapsed < 5.0
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)

View File

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

View File

@@ -94,7 +94,8 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=None,
ping_interval=20,
ping_timeout=10,
)
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},
@@ -110,6 +111,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store")
mock_store.configure("light.test")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()