Compare commits

...

7 Commits

Author SHA1 Message Date
954c4511a7 Fix complete dashboard i18n refresh
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:25:13 +02:00
33cce32098 Release SillyHome Next 1.7.4
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:59:21 +02:00
08e41b0198 Improve learning discovery and dashboard i18n
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:57:57 +02:00
1b9db62294 Add simulation apply workflow
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 20:10:53 +02:00
5ca0c53f6a Reduce websocket reconnect load
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:17:50 +02:00
8070a85b52 Add actuator simulation tuning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:06:47 +02:00
575211f0db Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 11:53:53 +02:00
19 changed files with 2330 additions and 83 deletions

View File

@@ -1,5 +1,43 @@
# Changelog # Changelog
## 1.7.5 - 2026-07-26
- Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte
Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte.
- Aufklapp-Hinweise (`expand`/`collapse`) kommen nicht mehr fest aus CSS auf
Deutsch, sondern werden pro Sprache gesetzt.
- Detail-Cache wird beim Sprachwechsel geleert, damit keine alten deutschen
HTML-Fragmente in der englischen Oberfläche sichtbar bleiben.
## 1.7.4 - 2026-07-26
- Dashboard-Sprachumschaltung aktualisiert statische Texte, Labels,
Platzhalter und wichtige Laufzeittexte direkt beim Wechsel.
- Verhaltenslernen speichert Lichtattribute wie Helligkeit und Farbwerte aus
der Home-Assistant-Historie und gibt sie bei Lichtvorhersagen an den
`light.turn_on` Service weiter.
- Kontext-Discovery erkennt Umlaute/Raumvarianten robuster, ignoriert
Markenwörter wie `lidl` als falsche Gemeinsamkeit und bevorzugt Raum-
Präsenzsensoren für Lidl-/Treppenlichter.
- Lüftungen bevorzugen Luftfeuchte und Belegungs-/Präsenzkontext; gelernte
Sensorwechsel dürfen jetzt eine Verzögerung haben, z. B. WC besetzt -> nach
2-3 Minuten Lüftung an.
- Briefkasten-Reset-Buttons können Schrank-/Entnahme-Türen als Kontext
erkennen; Button-Aktoren können im aktiven Modus per `press` ausgeführt
werden.
- PV-/Akku-/Verbrauchssensoren werden als Energiemanagement-Kontext stärker
einsortiert.
## 1.7.0 - 2026-06-18
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
Event-Latenzmessungen und Dry-run pro Aktor.
- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und
sichtbare Job-Historie.
- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und
`never_automate` speichern; `never_automate` setzt eine manuelle Sperre.
- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und
lokale Agent-Insights aus vorhandenen Daten.
- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro
Home-Assistant-State-Change.
## 1.6.1 - 2026-06-18 ## 1.6.1 - 2026-06-18
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping. - Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren

View File

@@ -31,6 +31,8 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md) [`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard: - Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md) [`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## Reifegrad

View File

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

View File

@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
"entity", "entity",
"humidity", "humidity",
"illuminance", "illuminance",
"led",
"lidl",
"light", "light",
"licht", "licht",
"lichtschalter", "lichtschalter",
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
"presence", "presence",
"window", "window",
}) })
_PRESENCE_TOKENS = frozenset({
"besetzt",
"occupied",
"occupancy",
"presence",
"prasenz",
"praesenz",
"motion",
"bewegung",
"bewegungsmelder",
})
_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"})
_CABINET_TOKENS = frozenset({"schrank", "cabinet"})
_PV_TOKENS = frozenset({
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
"netzbezug",
"grid",
"verbrauch",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -864,6 +893,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
return True return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)): if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity):
return True
if actuator.domain in {"fan", "humidifier"} and (
_is_presence_context(entity) or entity.device_class in {"humidity", "moisture"}
):
return True
if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and (
entity_tokens.intersection(_PV_TOKENS)
):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True) entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool( return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS) entity_tokens.intersection(_OUTDOOR_TOKENS)
@@ -878,6 +919,18 @@ def _eligible_for_auto_context(
device_class = candidate.device_class or "" device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES: if device_class in _AUTO_CONTEXT_CLASSES:
return True return True
if actuator.domain in {"fan", "humidifier"} and device_class in {
"humidity",
"moisture",
"temperature",
}:
return True
if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate):
return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
candidate_tokens = _candidate_tokens(candidate, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate):
return True
if ( if (
actuator.device_name actuator.device_name
and candidate.device_name and candidate.device_name
@@ -899,6 +952,7 @@ def _score_candidate(
score = 0.0 score = 0.0
actuator_tokens = _metadata_tokens(actuator) actuator_tokens = _metadata_tokens(actuator)
entity_tokens = _metadata_tokens(entity) entity_tokens = _metadata_tokens(entity)
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
overlap = sorted(actuator_tokens.intersection(entity_tokens)) overlap = sorted(actuator_tokens.intersection(entity_tokens))
if overlap: if overlap:
score += min(0.4, 0.1 * len(overlap)) score += min(0.4, 0.1 * len(overlap))
@@ -924,6 +978,31 @@ def _score_candidate(
if entity.device_class in preferred_device_classes: if entity.device_class in preferred_device_classes:
score += 0.2 score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}") evidence.append(f"Passende device_class: {entity.device_class}")
if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.")
if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.")
if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity):
score += 0.3
evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.")
if context and _is_mailbox_reset_candidate(
_metadata_tokens(actuator, include_stopwords=True),
_metadata_tokens(entity, include_stopwords=True),
entity,
):
score += 0.45
evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.")
if full_entity_tokens.intersection(_PV_TOKENS):
score += 0.12 if context else 0.18
evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.")
if not context and actuator.domain == "light" and entity.device_class == "illuminance": if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2 score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.") evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
@@ -1068,11 +1147,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False
for value in raw_values: for value in raw_values:
if value is None: if value is None:
continue continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS): if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue continue
tokens.add(token) tokens.add(token)
return tokens return _expand_room_tokens(tokens)
def _candidate_tokens(
candidate: AssignmentCandidate,
*,
include_stopwords: bool = False,
) -> set[str]:
raw_values = [
candidate.entity_id,
candidate.friendly_name,
candidate.area_name,
candidate.device_name,
]
tokens: set[str] = set()
for value in raw_values:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return _expand_room_tokens(tokens)
def _expand_room_tokens(tokens: set[str]) -> set[str]:
expanded = set(tokens)
if "gaste" in expanded:
expanded.add("gaeste")
if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded):
expanded.add("gaestewc")
if {"gaeste", "zimmer"}.issubset(expanded):
expanded.add("gaestezimmer")
return expanded
def _normalize_text(value: str) -> str:
return (
value.lower()
.replace("_", " ")
.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
)
def _is_presence_context(entity: HaEntitySummary) -> bool:
if entity.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_presence_candidate(candidate: AssignmentCandidate) -> bool:
if candidate.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_mailbox_reset_candidate(
actuator_tokens: set[str],
context_tokens: set[str],
entity: HaEntitySummary | AssignmentCandidate,
) -> bool:
if not actuator_tokens.intersection(_MAILBOX_TOKENS):
return False
if not context_tokens.intersection(_CABINET_TOKENS):
return False
return entity.domain == "binary_sensor" and entity.device_class in {
"door",
"garage_door",
"opening",
}
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str: def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:

View File

@@ -52,6 +52,14 @@ class JobStatus(StrEnum):
FAILED = "failed" FAILED = "failed"
class FeedbackKind(StrEnum):
CORRECT = "correct"
WRONG = "wrong"
TOO_EARLY = "too_early"
TOO_LATE = "too_late"
NEVER_AUTOMATE = "never_automate"
class AssignmentCandidate(BaseModel): class AssignmentCandidate(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -115,12 +123,14 @@ class ModelLifecycleState(BaseModel):
class BehaviorPattern(BaseModel): class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100) target_state: str = Field(min_length=1, max_length=100)
target_attributes: dict[str, object] = Field(default_factory=dict)
minute_of_day: int = Field(ge=0, le=1439) minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6) weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict) context_states: dict[str, str] = Field(default_factory=dict)
trigger_entity_id: str | None = None trigger_entity_id: str | None = None
trigger_from_state: str | None = None trigger_from_state: str | None = None
trigger_to_state: str | None = None trigger_to_state: str | None = None
trigger_delay_seconds: int | None = Field(default=None, ge=0)
source: str = Field(default="observed", max_length=40) source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0) weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime observed_at: datetime
@@ -128,6 +138,7 @@ class BehaviorPattern(BaseModel):
class BehaviorPrediction(BaseModel): class BehaviorPrediction(BaseModel):
target_state: str target_state: str
target_attributes: dict[str, object] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0) confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime generated_at: datetime
reason: str reason: str
@@ -146,6 +157,43 @@ class DecisionFactor(BaseModel):
evidence: list[str] = Field(default_factory=list) evidence: list[str] = Field(default_factory=list)
class SimulationOutcome(BaseModel):
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
actuator_entity_id: str
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
prediction: BehaviorPrediction | None = None
decision_factors: list[DecisionFactor] = Field(default_factory=list)
would_execute: bool = False
blockers: list[str] = Field(default_factory=list)
score: float = Field(default=0.0, ge=0.0, le=1.0)
recommendation: str = Field(default="", max_length=700)
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
trigger_state: str | None = None
target_state: str | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
executed: bool = False
blocked: bool = False
reason: str = Field(default="", max_length=700)
blockers: list[str] = Field(default_factory=list)
duration_ms: int | None = Field(default=None, ge=0)
class LatencyMeasurement(BaseModel):
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
event_to_decision_ms: int | None = Field(default=None, ge=0)
decision_to_service_ms: int | None = Field(default=None, ge=0)
event_to_done_ms: int | None = Field(default=None, ge=0)
executed: bool = False
source: str = Field(default="manual", max_length=40)
class AdaptiveWeightUpdate(BaseModel): class AdaptiveWeightUpdate(BaseModel):
entity_id: str entity_id: str
previous_weight: float = Field(ge=0.0, le=1.0) previous_weight: float = Field(ge=0.0, le=1.0)
@@ -248,6 +296,32 @@ class RelatedAutomation(BaseModel):
enabled: bool enabled: bool
class ActuatorGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
area_name: str | None = Field(default=None, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
reason: str = Field(default="", max_length=300)
class SceneSuggestion(BaseModel):
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str = Field(default="", max_length=500)
last_seen_at: datetime | None = None
class AgentInsight(BaseModel):
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=700)
action: str | None = Field(default=None, max_length=300)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class BehaviorState(BaseModel): class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING status: BehaviorStatus = BehaviorStatus.COLLECTING
@@ -281,6 +355,16 @@ class BehaviorState(BaseModel):
automation_conflicts: list[AutomationConflict] = Field(default_factory=list) automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list) time_profiles: list[TimeProfile] = Field(default_factory=list)
anomalies: list[AnomalyEvent] = Field(default_factory=list) anomalies: list[AnomalyEvent] = Field(default_factory=list)
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
feedback_log: list[FeedbackKind] = Field(default_factory=list)
dry_run_enabled: bool = False
dry_run_started_at: datetime | None = None
dry_run_sample_count: int = Field(default=0, ge=0)
dry_run_hit_count: int = Field(default=0, ge=0)
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
agent_insights: list[AgentInsight] = Field(default_factory=list)
class ActuatorRecord(BaseModel): class ActuatorRecord(BaseModel):

View File

@@ -100,6 +100,11 @@ class ActuatorStore:
except ValueError as exc: except ValueError as exc:
raise ValueError("Ungültiger Job-Queue-Status.") from exc raise ValueError("Ungültiger Job-Queue-Status.") from exc
def save_job_queue(self, queue: JobQueueState) -> JobQueueState:
with self._lock:
self._persist_job_queue(queue)
return queue
def start_job( def start_job(
self, self,
*, *,

View File

@@ -10,7 +10,14 @@ from pydantic import BaseModel, Field
from app.actuators.cache_db import DashboardCache from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup from app.actuators.models import (
ActuatorRecord,
AnomalyEvent,
FeedbackKind,
ReconciliationState,
SensorWeightGroup,
SimulationOutcome,
)
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
@@ -52,9 +59,40 @@ class WeightOverrideRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) note: str | None = Field(default=None, max_length=500)
class SimulationRequest(BaseModel):
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
state_options: dict[str, list[str]] = Field(default_factory=dict)
include_current: bool = True
max_results: int = Field(default=8, ge=1, le=20)
class FeedbackRequest(BaseModel): class FeedbackRequest(BaseModel):
correct: bool correct: bool
expected_state: str | None = Field(default=None, max_length=100) expected_state: str | None = Field(default=None, max_length=100)
kind: FeedbackKind | None = None
class DryRunRequest(BaseModel):
enabled: bool
class BackupPayload(BaseModel):
exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
records: list[ActuatorRecord] = Field(default_factory=list)
reconciliation: ReconciliationState = Field(default_factory=ReconciliationState)
jobs: JobQueueState = Field(default_factory=JobQueueState)
class RestoreRequest(BaseModel):
backup: BackupPayload
replace_existing: bool = False
class RestoreResult(BaseModel):
restored_records: int = 0
skipped_existing: int = 0
restored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class SafetyProfileRequest(BaseModel): class SafetyProfileRequest(BaseModel):
@@ -406,6 +444,48 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
return overview return overview
@router.get("/backup/export", response_model=BackupPayload)
def export_backup(request: Request) -> BackupPayload:
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 BackupPayload(
records=store.list(),
reconciliation=store.load_reconciliation_state(),
jobs=store.load_job_queue(),
)
@router.post("/backup/restore", response_model=RestoreResult)
def restore_backup(payload: RestoreRequest, request: Request) -> RestoreResult:
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.",
)
existing_ids = {record.actuator_entity_id for record in store.list()}
restored = 0
skipped = 0
for record in payload.backup.records:
if record.actuator_entity_id in existing_ids and not payload.replace_existing:
skipped += 1
continue
store.upsert(record)
restored += 1
store.save_reconciliation_state(payload.backup.reconciliation)
store.save_job_queue(payload.backup.jobs)
return RestoreResult(restored_records=restored, skipped_existing=skipped)
@router.post("/planning/refresh", response_model=list[ActuatorRecord])
def refresh_planning_insights(request: Request) -> list[ActuatorRecord]:
return _behavior(request).refresh_planning_insights()
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -502,6 +582,28 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/simulate", response_model=list[SimulationOutcome])
def simulate_actuator(
actuator_entity_id: str,
payload: SimulationRequest,
request: Request,
) -> list[SimulationOutcome]:
try:
_validate_simulation_payload(payload)
return _behavior(request).simulate(
actuator_entity_id,
sensor_states=payload.sensor_states,
sensor_weights=payload.sensor_weights,
state_options=payload.state_options,
include_current=payload.include_current,
max_results=payload.max_results,
)
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}/feedback", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback( def record_feedback(
actuator_entity_id: str, actuator_entity_id: str,
@@ -513,11 +615,24 @@ def record_feedback(
actuator_entity_id, actuator_entity_id,
correct=payload.correct, correct=payload.correct,
expected_state=payload.expected_state, expected_state=payload.expected_state,
kind=payload.kind,
) )
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/dry-run", response_model=ActuatorRecord)
def set_dry_run(
actuator_entity_id: str,
payload: DryRunRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).set_dry_run(actuator_entity_id, enabled=payload.enabled)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
def set_safety_profile( def set_safety_profile(
actuator_entity_id: str, actuator_entity_id: str,
@@ -807,6 +922,22 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}") raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _validate_simulation_payload(payload: SimulationRequest) -> None:
for entity_id in [
*payload.sensor_states.keys(),
*payload.sensor_weights.keys(),
*payload.state_options.keys(),
]:
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
for entity_id, weight in payload.sensor_weights.items():
if not 0.0 <= weight <= 1.0:
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
for entity_id, states in payload.state_options.items():
if not states:
raise ValueError(f"Keine Zustände für {entity_id} angegeben.")
def _reconciliation_state_or_default(request: Request) -> ReconciliationState: def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore): if not isinstance(store, ActuatorStore):

View File

@@ -1,14 +1,18 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from itertools import product
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from time import perf_counter
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
AdaptiveWeightUpdate, AdaptiveWeightUpdate,
AgentInsight,
AnomalyEvent, AnomalyEvent,
ActuatorGroup,
AutomationConflict, AutomationConflict,
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
@@ -16,12 +20,17 @@ from app.actuators.models import (
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
DecisionFactor, DecisionFactor,
DecisionTrace,
ExecutionEvent, ExecutionEvent,
FeedbackKind,
LatencyMeasurement,
ManualOverride, ManualOverride,
ModelSnapshot, ModelSnapshot,
RelatedAutomation, RelatedAutomation,
SafetyProfile, SafetyProfile,
SafetyStage, SafetyStage,
SceneSuggestion,
SimulationOutcome,
TimeProfile, TimeProfile,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
@@ -35,11 +44,31 @@ _MAX_PATTERNS = 500
_MAX_MODEL_SNAPSHOTS = 3 _MAX_MODEL_SNAPSHOTS = 3
_MAX_SNAPSHOT_PATTERNS = 120 _MAX_SNAPSHOT_PATTERNS = 120
_MAX_EXECUTION_EVENTS = 100 _MAX_EXECUTION_EVENTS = 100
_MAX_DECISION_TRACES = 30
_MAX_LATENCY_MEASUREMENTS = 50
_MAX_FEEDBACK_LOG = 50
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3) _CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20) _OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"}) _SAFE_ACTIVE_DOMAINS = frozenset({
"button",
"cover",
"fan",
"humidifier",
"input_button",
"light",
"switch",
})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"}) _AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
_LIGHT_TARGET_ATTRIBUTES = frozenset({
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
})
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -254,7 +283,11 @@ class BehaviorEngine:
context_state_overrides: dict[str, str | None] | None = None, context_state_overrides: dict[str, str | None] | None = None,
context_changed_at_overrides: dict[str, datetime | None] | None = None, context_changed_at_overrides: dict[str, datetime | None] | None = None,
current_entities: Sequence[HaEntitySummary] | None = None, current_entities: Sequence[HaEntitySummary] | None = None,
trigger_entity_id: str | None = None,
trigger_state: str | None = None,
event_received_at: datetime | None = None,
) -> ActuatorRecord: ) -> ActuatorRecord:
started_perf = perf_counter()
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if current_entities is None: if current_entities is None:
@@ -319,10 +352,11 @@ class BehaviorEngine:
record.behavior.patterns, record.behavior.patterns,
current_context=current_context, current_context=current_context,
current_context_changed_at=current_context_changed_at, current_context_changed_at=current_context_changed_at,
context_weights=_context_weights_for(record),
now=now, now=now,
min_support=self._settings.min_behavior_actions, min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes, window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2, causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240),
timezone_name=self._settings.timezone, timezone_name=self._settings.timezone,
) )
if prediction is not None: if prediction is not None:
@@ -344,6 +378,7 @@ class BehaviorEngine:
else: else:
safety_allowed = False safety_allowed = False
safety_blockers = ["Keine fällige Vorhersage."] safety_blockers = ["Keine fällige Vorhersage."]
decision_to_service_ms: int | None = None
decision_factors = _decision_factors_for(record, current_context, prediction) decision_factors = _decision_factors_for(record, current_context, prediction)
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
@@ -383,12 +418,51 @@ class BehaviorEngine:
domain = actuator_entity_id.split(".", 1)[0] domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state) service = service_for_state(domain, prediction.target_state)
if service is not None: if service is not None:
if record.behavior.dry_run_enabled:
behavior = behavior.model_copy(
update={
"prediction": prediction.model_copy(
update={
"executed": False,
"execution_reason": (
"Dry-run: Aktion wäre ausgeführt worden."
),
}
),
"dry_run_sample_count": record.behavior.dry_run_sample_count + 1,
"reason": (
f"Dry-run hätte {prediction.target_state!r} mit "
f"{prediction.confidence:.0%} Sicherheit ausgeführt."
),
}
)
return self._save_behavior(
record,
_append_decision_trace(
behavior,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
prediction=prediction,
safety_blockers=safety_blockers,
duration_ms=_elapsed_ms(started_perf),
event_received_at=event_received_at,
decision_to_service_ms=None,
executed=False,
source="event" if event_received_at is not None else "manual",
),
)
try: try:
service_started_perf = perf_counter()
self._ha_reader.call_service( self._ha_reader.call_service(
domain, domain,
service, service,
{"entity_id": actuator_entity_id}, _service_data_for_prediction(
actuator_entity_id,
domain,
prediction,
),
) )
decision_to_service_ms = _elapsed_ms(service_started_perf)
except (HaClientError, ValueError) as exc: except (HaClientError, ValueError) as exc:
logger.error( logger.error(
"Predicted action failed for %s: %s", "Predicted action failed for %s: %s",
@@ -400,7 +474,21 @@ class BehaviorEngine:
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}" "reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
} }
) )
return self._save_behavior(record, behavior) return self._save_behavior(
record,
_append_decision_trace(
behavior,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
prediction=prediction,
safety_blockers=[str(exc)],
duration_ms=_elapsed_ms(started_perf),
event_received_at=event_received_at,
decision_to_service_ms=None,
executed=False,
source="event" if event_received_at is not None else "manual",
),
)
event = ExecutionEvent( event = ExecutionEvent(
target_state=prediction.target_state, target_state=prediction.target_state,
executed_at=now, executed_at=now,
@@ -434,14 +522,139 @@ class BehaviorEngine:
) )
} }
) )
behavior = _append_decision_trace(
behavior,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
prediction=prediction,
safety_blockers=safety_blockers,
duration_ms=_elapsed_ms(started_perf),
event_received_at=event_received_at,
decision_to_service_ms=(
decision_to_service_ms
),
executed=bool(prediction is not None and behavior.prediction is not None and behavior.prediction.executed),
source="event" if event_received_at is not None else "manual",
)
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
def simulate(
self,
actuator_entity_id: str,
*,
sensor_states: dict[str, str],
sensor_weights: dict[str, float],
state_options: dict[str, list[str]],
max_results: int,
include_current: bool = True,
) -> list[SimulationOutcome]:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
current_entities = self._ha_reader.read_entities()
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
selected_context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
if not selected_context_ids:
return []
base_context = {
entity_id: entities[entity_id].state
for entity_id in selected_context_ids
if entity_id in entities and entities[entity_id].state is not None
}
base_changed_at = {
entity_id: entities[entity_id].last_changed
for entity_id in base_context
}
context_weights = _context_weights_for(record)
for entity_id, weight in sensor_weights.items():
if entity_id in selected_context_ids:
context_weights[entity_id] = max(0.0, min(1.0, weight))
scenarios = _simulation_contexts(
base_context,
sensor_states=sensor_states,
state_options=state_options,
selected_context_ids=selected_context_ids,
include_current=include_current,
)
outcomes: list[SimulationOutcome] = []
for index, context in enumerate(scenarios[:64], start=1):
prediction_context: dict[str, str | None] = dict(context)
changed_at = dict(base_changed_at)
for entity_id, state in context.items():
if base_context.get(entity_id) != state:
changed_at[entity_id] = now
prediction = predict_behavior(
record.behavior.patterns,
current_context=prediction_context,
current_context_changed_at=changed_at,
context_weights=context_weights,
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
timezone_name=self._settings.timezone,
)
if prediction is not None:
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
recommendation = (
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
if would_execute
else (
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
"aber blockiert: " + " ".join(blockers)
)
)
else:
would_execute = False
blockers = ["Keine fällige Vorhersage."]
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
outcomes.append(
SimulationOutcome(
scenario_id=f"scenario-{index}",
actuator_entity_id=actuator_entity_id,
sensor_states=context,
sensor_weights={
entity_id: round(context_weights.get(entity_id, 1.0), 4)
for entity_id in context
},
prediction=prediction,
decision_factors=_decision_factors_for(
record,
prediction_context,
prediction,
context_weights=context_weights,
),
would_execute=would_execute,
blockers=blockers,
score=round(prediction.confidence if prediction is not None else 0.0, 4),
recommendation=recommendation,
)
)
return sorted(
outcomes,
key=lambda item: (
item.prediction is None,
-item.score,
item.scenario_id,
),
)[:max_results]
def record_feedback( def record_feedback(
self, self,
actuator_entity_id: str, actuator_entity_id: str,
*, *,
correct: bool, correct: bool,
expected_state: str | None = None, expected_state: str | None = None,
kind: FeedbackKind | None = None,
) -> ActuatorRecord: ) -> ActuatorRecord:
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -485,6 +698,7 @@ class BehaviorEngine:
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt." reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
correct_count = record.behavior.correct_feedback_count + 1 correct_count = record.behavior.correct_feedback_count + 1
incorrect_count = record.behavior.incorrect_feedback_count incorrect_count = record.behavior.incorrect_feedback_count
feedback_kind = kind or FeedbackKind.CORRECT
else: else:
target = prediction.target_state if prediction is not None else None target = prediction.target_state if prediction is not None else None
if target: if target:
@@ -515,11 +729,24 @@ class BehaviorEngine:
reason = "Vorhersage wurde vom Nutzer als falsch markiert." reason = "Vorhersage wurde vom Nutzer als falsch markiert."
correct_count = record.behavior.correct_feedback_count correct_count = record.behavior.correct_feedback_count
incorrect_count = record.behavior.incorrect_feedback_count + 1 incorrect_count = record.behavior.incorrect_feedback_count + 1
feedback_kind = kind or FeedbackKind.WRONG
if feedback_kind is FeedbackKind.NEVER_AUTOMATE:
safety = record.behavior.safety.model_copy(
update={
"manual_block": True,
"updated_at": now,
"note": "Durch Nutzerfeedback dauerhaft blockiert.",
}
)
else:
safety = record.behavior.safety
adaptive_updates, manual_override = _adapt_sensor_weights( adaptive_updates, manual_override = _adapt_sensor_weights(
record, record,
current_context, current_context,
correct=correct, correct=correct,
) )
if correct and prediction is not None:
safety = record.behavior.safety
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
"patterns": patterns[-_MAX_PATTERNS:], "patterns": patterns[-_MAX_PATTERNS:],
@@ -532,6 +759,11 @@ class BehaviorEngine:
"last_trained_at": now, "last_trained_at": now,
"correct_feedback_count": correct_count, "correct_feedback_count": correct_count,
"incorrect_feedback_count": incorrect_count, "incorrect_feedback_count": incorrect_count,
"feedback_log": [
*record.behavior.feedback_log,
feedback_kind,
][-_MAX_FEEDBACK_LOG:],
"safety": safety,
"adaptive_weight_updates": [ "adaptive_weight_updates": [
*record.behavior.adaptive_weight_updates, *record.behavior.adaptive_weight_updates,
*adaptive_updates, *adaptive_updates,
@@ -557,6 +789,43 @@ class BehaviorEngine:
) )
return self._save_behavior(record_for_save, behavior) return self._save_behavior(record_for_save, behavior)
def set_dry_run(self, actuator_entity_id: str, *, enabled: bool) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
behavior = record.behavior.model_copy(
update={
"dry_run_enabled": enabled,
"dry_run_started_at": now if enabled else record.behavior.dry_run_started_at,
"reason": (
"Dry-run aktiv; freigegebene Aktionen werden protokolliert, aber nicht geschaltet."
if enabled
else "Dry-run beendet."
),
}
)
return self._save_behavior(record, behavior)
def refresh_planning_insights(self) -> list[ActuatorRecord]:
records = self._store.list()
groups = _derive_actuator_groups(records)
scenes = _derive_scene_suggestions(records)
insights_by_actuator = _derive_agent_insights(records)
updated: list[ActuatorRecord] = []
for record in records:
behavior = record.behavior.model_copy(
update={
"actuator_groups": [
group for group in groups if record.actuator_entity_id in group.member_entity_ids
],
"scene_suggestions": [
scene for scene in scenes if record.actuator_entity_id in scene.member_entity_ids
],
"agent_insights": insights_by_actuator.get(record.actuator_entity_id, []),
}
)
updated.append(self._save_behavior(record, behavior))
return updated
def rollback_model( def rollback_model(
self, self,
actuator_entity_id: str, actuator_entity_id: str,
@@ -859,7 +1128,7 @@ class BehaviorEngine:
blockers.append( blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}." f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
) )
if current_state == prediction.target_state: if _target_reached(record.actuator_entity_id, current_state, prediction):
blockers.append("Zielzustand ist bereits erreicht.") blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed( if not self._cooldown_elapsed(
record.behavior, record.behavior,
@@ -902,12 +1171,16 @@ class BehaviorEngine:
patterns.append( patterns.append(
BehaviorPattern( BehaviorPattern(
target_state=point.state, target_state=point.state,
target_attributes=_target_attributes_for(point),
minute_of_day=local.hour * 60 + local.minute, minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(), weekday=local.weekday(),
context_states=contexts, context_states=contexts,
trigger_entity_id=trigger[0] if trigger else None, trigger_entity_id=trigger[1] if trigger else None,
trigger_from_state=trigger[1] if trigger else None, trigger_from_state=trigger[2] if trigger else None,
trigger_to_state=trigger[2] if trigger else None, trigger_to_state=trigger[3] if trigger else None,
trigger_delay_seconds=(
int(trigger[0].total_seconds()) if trigger else None
),
source=source, source=source,
weight=weight, weight=weight,
observed_at=point.timestamp, observed_at=point.timestamp,
@@ -966,11 +1239,19 @@ class BehaviorEngine:
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem - Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
WebSocket-State-Cache statt aus einer frischen REST-Abfrage. WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
""" """
event_received_at = datetime.now(timezone.utc)
records = self._store.list()
# Aktor direkt evaluieren # Aktor direkt evaluieren
for record in self._store.list(): for record in records:
if record.actuator_entity_id == entity_id: if record.actuator_entity_id == entity_id:
try: try:
self.evaluate(record.actuator_entity_id, current_entities=current_entities) self.evaluate(
record.actuator_entity_id,
current_entities=current_entities,
trigger_entity_id=entity_id,
trigger_state=_event_state(new_state),
event_received_at=event_received_at,
)
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
return return
@@ -979,7 +1260,7 @@ class BehaviorEngine:
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen # Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
affected_actuators = [ affected_actuators = [
record.actuator_entity_id record.actuator_entity_id
for record in self._store.list() for record in records
if ( if (
record.assignment.selected_numeric_entity_id == entity_id record.assignment.selected_numeric_entity_id == entity_id
or entity_id in record.assignment.selected_context_entity_ids or entity_id in record.assignment.selected_context_entity_ids
@@ -992,6 +1273,9 @@ class BehaviorEngine:
context_state_overrides={entity_id: event_state}, context_state_overrides={entity_id: event_state},
context_changed_at_overrides={entity_id: event_changed_at}, context_changed_at_overrides={entity_id: event_changed_at},
current_entities=current_entities, current_entities=current_entities,
trigger_entity_id=entity_id,
trigger_state=event_state,
event_received_at=event_received_at,
) )
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
@@ -1019,6 +1303,208 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
return parsed return parsed
def _elapsed_ms(started_perf: float) -> int:
return max(0, int((perf_counter() - started_perf) * 1000))
def _append_decision_trace(
behavior: BehaviorState,
*,
trigger_entity_id: str | None,
trigger_state: str | None,
prediction: BehaviorPrediction | None,
safety_blockers: list[str],
duration_ms: int,
event_received_at: datetime | None,
decision_to_service_ms: int | None,
executed: bool,
source: str,
) -> BehaviorState:
now = datetime.now(timezone.utc)
blocked = prediction is None or bool(safety_blockers)
trace = DecisionTrace(
trace_id=f"{now.strftime('%Y%m%d%H%M%S%f')}.{trigger_entity_id or 'manual'}",
created_at=now,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
target_state=prediction.target_state if prediction is not None else None,
confidence=prediction.confidence if prediction is not None else None,
executed=executed,
blocked=blocked,
reason=(
prediction.execution_reason
if prediction is not None
else behavior.reason
),
blockers=safety_blockers if prediction is not None else ["Keine fällige Vorhersage."],
duration_ms=duration_ms,
)
updated = behavior.model_copy(
update={
"decision_timeline": [
*behavior.decision_timeline,
trace,
][-_MAX_DECISION_TRACES:],
}
)
if event_received_at is None:
return updated
return _append_latency_measurement(
updated,
trigger_entity_id=trigger_entity_id,
event_received_at=event_received_at,
event_to_decision_ms=duration_ms,
decision_to_service_ms=decision_to_service_ms,
executed=executed,
source=source,
)
def _append_latency_measurement(
behavior: BehaviorState,
*,
trigger_entity_id: str | None,
event_received_at: datetime | None,
event_to_decision_ms: int | None,
decision_to_service_ms: int | None,
executed: bool,
source: str,
) -> BehaviorState:
if event_received_at is None:
return behavior
now = datetime.now(timezone.utc)
event_to_done_ms = max(0, int((now - event_received_at).total_seconds() * 1000))
measurement = LatencyMeasurement(
measured_at=now,
trigger_entity_id=trigger_entity_id,
event_to_decision_ms=event_to_decision_ms,
decision_to_service_ms=decision_to_service_ms,
event_to_done_ms=event_to_done_ms,
executed=executed,
source=source,
)
return behavior.model_copy(
update={
"latency_measurements": [
*behavior.latency_measurements,
measurement,
][-_MAX_LATENCY_MEASUREMENTS:],
}
)
def _derive_actuator_groups(records: list[ActuatorRecord]) -> list[ActuatorGroup]:
by_area: dict[str, list[str]] = {}
for record in records:
area = _area_hint(record)
if area:
by_area.setdefault(area, []).append(record.actuator_entity_id)
return [
ActuatorGroup(
group_id=_slug(f"area_{area}"),
name=f"Raum {area}",
area_name=area,
member_entity_ids=sorted(entity_ids),
reason="Aktor-Gruppe aus gemeinsamer Raum-/Kontextzuordnung abgeleitet.",
)
for area, entity_ids in sorted(by_area.items())
if len(entity_ids) >= 2
]
def _derive_scene_suggestions(records: list[ActuatorRecord]) -> list[SceneSuggestion]:
scenes: list[SceneSuggestion] = []
by_context: dict[tuple[str, str], list[str]] = {}
for record in records:
for pattern in record.behavior.patterns:
for entity_id, state in pattern.context_states.items():
by_context.setdefault((entity_id, state), []).append(record.actuator_entity_id)
for (entity_id, state), members in sorted(by_context.items()):
unique_members = sorted(set(members))
if len(unique_members) < 2:
continue
scenes.append(
SceneSuggestion(
scene_id=_slug(f"{entity_id}_{state}"),
label=f"{entity_id} ist {state}",
member_entity_ids=unique_members,
confidence=min(1.0, len(members) / max(3, len(unique_members) * 2)),
reason="Mehrere Aktoren reagieren historisch auf denselben Kontext.",
last_seen_at=max(
(
pattern.observed_at
for record in records
for pattern in record.behavior.patterns
if pattern.context_states.get(entity_id) == state
),
default=None,
),
)
)
return scenes[-20:]
def _derive_agent_insights(records: list[ActuatorRecord]) -> dict[str, list[AgentInsight]]:
result: dict[str, list[AgentInsight]] = {}
for record in records:
insights: list[AgentInsight] = []
if record.behavior.automation_conflicts:
insights.append(
AgentInsight(
insight_id=f"{record.actuator_entity_id}.automation_conflict",
severity="warning",
title="Automation-Konflikt prüfen",
detail="Eine passende HA-Automation kann parallel zu SillyHome schalten.",
action="Automation pausieren oder SillyHome im Shadow-Modus lassen.",
)
)
if record.behavior.latency_measurements:
durations = [
item.event_to_done_ms
for item in record.behavior.latency_measurements
if item.event_to_done_ms is not None
]
if durations and max(durations) > 1500:
insights.append(
AgentInsight(
insight_id=f"{record.actuator_entity_id}.latency",
severity="warning",
title="Schalt-Latenz beobachten",
detail=f"Letzte maximale Event-Latenz: {max(durations)} ms.",
action="WebSocket-Status, HA-Servicezeit und Sensor-Routing pruefen.",
)
)
if record.behavior.incorrect_feedback_count > record.behavior.correct_feedback_count:
insights.append(
AgentInsight(
insight_id=f"{record.actuator_entity_id}.feedback",
severity="warning",
title="Viele negative Feedbacks",
detail="Das Modell trifft aktuell mehr falsche als richtige Entscheidungen.",
action="Kontextzuordnung, Gewichtung oder Modell-Rollback pruefen.",
)
)
result[record.actuator_entity_id] = insights[:5]
return result
def _area_hint(record: ActuatorRecord) -> str | None:
for candidate in [*record.context_candidates, *record.numeric_candidates]:
if candidate.area_name:
return candidate.area_name
return None
def _slug(value: str) -> str:
result = []
for char in value.lower():
if char.isalnum():
result.append(char)
elif char in {".", "_", "-", " "}:
result.append("_")
return "".join(result).strip("_")[:64] or "item"
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float: def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
if target_state == "on" and profile.min_confidence_on is not None: if target_state == "on" and profile.min_confidence_on is not None:
return profile.min_confidence_on return profile.min_confidence_on
@@ -1031,15 +1517,21 @@ def _decision_factors_for(
record: ActuatorRecord, record: ActuatorRecord,
current_context: dict[str, str | None], current_context: dict[str, str | None],
prediction: BehaviorPrediction | None, prediction: BehaviorPrediction | None,
*,
context_weights: dict[str, float] | None = None,
) -> list[DecisionFactor]: ) -> list[DecisionFactor]:
factors: list[DecisionFactor] = [] factors: list[DecisionFactor] = []
weights = context_weights or {}
candidates = { candidates = {
candidate.entity_id: candidate candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates] for candidate in [*record.numeric_candidates, *record.context_candidates]
} }
for entity_id, state in current_context.items(): for entity_id, state in current_context.items():
candidate = candidates.get(entity_id) candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0 weight = weights.get(
entity_id,
candidate.effective_weight if candidate is not None else 1.0,
)
relevance = candidate.confidence if candidate is not None else 0.5 relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4) contribution = round(min(1.0, weight * relevance), 4)
factors.append( factors.append(
@@ -1075,6 +1567,62 @@ def _decision_factors_for(
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12] return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
weights = {
candidate.entity_id: candidate.effective_weight
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
override = record.manual_override
if override is not None:
for entity_id, weight in override.sensor_weights.items():
weights[entity_id] = max(0.0, min(1.0, weight))
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
weights[entity_id] = max(0.0, min(1.0, group.weight))
return weights
def _simulation_contexts(
base_context: dict[str, str | None],
*,
sensor_states: dict[str, str],
state_options: dict[str, list[str]],
selected_context_ids: list[str],
include_current: bool,
) -> list[dict[str, str]]:
selected = set(selected_context_ids)
base = {
entity_id: state
for entity_id, state in base_context.items()
if entity_id in selected and state is not None
}
for entity_id, state in sensor_states.items():
if entity_id in selected:
base[entity_id] = state
option_items = [
(
entity_id,
list(dict.fromkeys(state for state in states if state))[:6],
)
for entity_id, states in state_options.items()
if entity_id in selected and states
][:6]
contexts: list[dict[str, str]] = []
if include_current or not option_items:
contexts.append(dict(base))
if option_items:
keys = [item[0] for item in option_items]
value_lists = [item[1] for item in option_items]
for values in product(*value_lists):
context = dict(base)
context.update(dict(zip(keys, values, strict=True)))
if context not in contexts:
contexts.append(context)
if len(contexts) >= 64:
break
return contexts
def _knowledge_lines( def _knowledge_lines(
record: ActuatorRecord, record: ActuatorRecord,
sample_count: int, sample_count: int,
@@ -1408,6 +1956,7 @@ def predict_behavior(
min_support: int, min_support: int,
window_minutes: int, window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None, current_context_changed_at: dict[str, datetime | None] | None = None,
context_weights: dict[str, float] | None = None,
causal_window_seconds: int = 120, causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin", timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None: ) -> BehaviorPrediction | None:
@@ -1417,6 +1966,7 @@ def predict_behavior(
minute_of_day = local.hour * 60 + local.minute minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {} changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {} by_state: dict[str, list[float]] = {}
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
causal_support_by_state: dict[str, int] = {} causal_support_by_state: dict[str, int] = {}
for pattern in patterns: for pattern in patterns:
if pattern.trigger_entity_id and pattern.trigger_to_state: if pattern.trigger_entity_id and pattern.trigger_to_state:
@@ -1430,7 +1980,11 @@ def predict_behavior(
current_context.get(pattern.trigger_entity_id) current_context.get(pattern.trigger_entity_id)
== pattern.trigger_to_state == pattern.trigger_to_state
and trigger_age is not None and trigger_age is not None
and 0 <= trigger_age <= causal_window_seconds and _trigger_age_matches(
trigger_age,
pattern.trigger_delay_seconds,
causal_window_seconds,
)
): ):
continue continue
comparable = [ comparable = [
@@ -1438,17 +1992,16 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items() for entity_id, expected in pattern.context_states.items()
if entity_id in current_context if entity_id in current_context
] ]
context_score = ( context_score = _weighted_context_score(
sum( comparable,
current_context[entity_id] == expected current_context,
for entity_id, expected in comparable context_weights or {},
)
/ len(comparable)
if comparable
else 0.5
) )
score = pattern.weight * (0.85 + 0.15 * context_score) score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score) by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
causal_support_by_state[pattern.target_state] = ( causal_support_by_state[pattern.target_state] = (
causal_support_by_state.get(pattern.target_state, 0) + 1 causal_support_by_state.get(pattern.target_state, 0) + 1
) )
@@ -1469,16 +2022,18 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items() for entity_id, expected in pattern.context_states.items()
if entity_id in current_context if entity_id in current_context
] ]
context_score = ( context_score = _weighted_context_score(
sum(current_context[entity_id] == expected for entity_id, expected in comparable) comparable,
/ len(comparable) current_context,
if comparable context_weights or {},
else 0.5
) )
score = pattern.weight * ( score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score 0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
) )
by_state.setdefault(pattern.target_state, []).append(score) by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
if not by_state: if not by_state:
return None return None
target_state, scores = max( target_state, scores = max(
@@ -1492,6 +2047,9 @@ def predict_behavior(
return None return None
return BehaviorPrediction( return BehaviorPrediction(
target_state=target_state, target_state=target_state,
target_attributes=_aggregate_target_attributes(
attributes_by_state.get(target_state, [])
),
confidence=round(confidence, 4), confidence=round(confidence, 4),
generated_at=now, generated_at=now,
matching_patterns=support, matching_patterns=support,
@@ -1506,9 +2064,106 @@ def predict_behavior(
) )
def _weighted_context_score(
comparable: list[tuple[str, str]],
current_context: dict[str, str | None],
context_weights: dict[str, float],
) -> float:
if not comparable:
return 0.5
total_weight = 0.0
matched_weight = 0.0
for entity_id, expected in comparable:
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
total_weight += weight
if current_context.get(entity_id) == expected:
matched_weight += weight
if total_weight <= 0:
return 0.5
return matched_weight / total_weight
def _trigger_age_matches(
trigger_age_seconds: float,
expected_delay_seconds: int | None,
causal_window_seconds: int,
) -> bool:
if trigger_age_seconds < 0:
return False
if expected_delay_seconds is None or expected_delay_seconds <= 10:
return trigger_age_seconds <= causal_window_seconds
tolerance = max(30, min(90, causal_window_seconds // 2))
return abs(trigger_age_seconds - expected_delay_seconds) <= tolerance
def _aggregate_target_attributes(
weighted_attributes: list[tuple[float, dict[str, object]]],
) -> dict[str, object]:
if not weighted_attributes:
return {}
result: dict[str, object] = {}
numeric_values: dict[str, list[tuple[float, float]]] = {}
categorical_values: dict[str, dict[str, float]] = {}
for score, attributes in weighted_attributes:
for key, value in attributes.items():
if key not in _LIGHT_TARGET_ATTRIBUTES:
continue
if isinstance(value, bool) or value is None:
continue
if isinstance(value, (int, float)):
numeric_values.setdefault(key, []).append((score, float(value)))
else:
categorical_values.setdefault(key, {}).setdefault(str(value), 0.0)
categorical_values[key][str(value)] += score
for key, values in numeric_values.items():
total_weight = sum(score for score, _ in values)
if total_weight <= 0:
continue
result[key] = round(sum(score * value for score, value in values) / total_weight)
for key, values in categorical_values.items():
if key in result:
continue
result[key] = max(values.items(), key=lambda item: (item[1], item[0]))[0]
return result
def _target_attributes_for(point: StateHistoryPoint) -> dict[str, object]:
if point.state != "on":
return {}
return {
key: value
for key, value in point.attributes.items()
if key in _LIGHT_TARGET_ATTRIBUTES and value is not None
}
def _service_data_for_prediction(
actuator_entity_id: str,
domain: str,
prediction: BehaviorPrediction,
) -> dict[str, object]:
data: dict[str, object] = {"entity_id": actuator_entity_id}
if domain == "light" and prediction.target_state == "on":
data.update(prediction.target_attributes)
return data
def _target_reached(
actuator_entity_id: str,
current_state: str,
prediction: BehaviorPrediction,
) -> bool:
domain = actuator_entity_id.split(".", 1)[0]
if domain == "light" and prediction.target_state == "on" and prediction.target_attributes:
return False
return current_state == prediction.target_state
def service_for_state(domain: str, target_state: str) -> str | None: def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}: if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state) return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain in {"button", "input_button"}:
return "press"
if domain == "scene": if domain == "scene":
return "turn_on" if target_state == "on" else None return "turn_on" if target_state == "on" else None
if domain == "cover": if domain == "cover":
@@ -1574,7 +2229,7 @@ def _recent_context_transition(
history: dict[str, StateHistorySeries], history: dict[str, StateHistorySeries],
context_ids: list[str], context_ids: list[str],
timestamp: datetime, timestamp: datetime,
) -> tuple[str, str, str] | None: ) -> tuple[timedelta, str, str, str] | None:
nearest: tuple[timedelta, str, str, str] | None = None nearest: tuple[timedelta, str, str, str] | None = None
for entity_id in context_ids: for entity_id in context_ids:
series = history.get(entity_id) series = history.get(entity_id)
@@ -1593,7 +2248,7 @@ def _recent_context_transition(
previous_state = point.state previous_state = point.state
if nearest is None: if nearest is None:
return None return None
return nearest[1], nearest[2], nearest[3] return nearest
def _circular_minute_distance(left: int, right: int) -> int: def _circular_minute_distance(left: int, right: int) -> int:

View File

@@ -78,7 +78,6 @@ class HaClient:
"filter_entity_id": ",".join(entity_ids), "filter_entity_id": ",".join(entity_ids),
"end_time": end_time.isoformat(), "end_time": end_time.isoformat(),
"minimal_response": "1", "minimal_response": "1",
"no_attributes": "1",
}, },
) )
if not isinstance(payload, list): if not isinstance(payload, list):

View File

@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
class StateHistoryPoint(BaseModel): class StateHistoryPoint(BaseModel):
timestamp: datetime timestamp: datetime
state: str state: str
attributes: dict[str, object] = {}
class StateHistorySeries(BaseModel): class StateHistorySeries(BaseModel):
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
timestamp = _parse_timestamp( timestamp = _parse_timestamp(
raw_entry.get("last_changed") or raw_entry.get("last_updated") raw_entry.get("last_changed") or raw_entry.get("last_updated")
) )
if not points or points[-1].state != raw_state: attributes = raw_entry.get("attributes")
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state)) if not isinstance(attributes, dict):
attributes = {}
if (
not points
or points[-1].state != raw_state
or _relevant_state_attributes(points[-1].attributes)
!= _relevant_state_attributes(attributes)
):
points.append(
StateHistoryPoint(
timestamp=timestamp,
state=raw_state,
attributes=_relevant_state_attributes(attributes),
)
)
if entity_id is not None and points: if entity_id is not None and points:
points.sort(key=lambda point: point.timestamp) points.sort(key=lambda point: point.timestamp)
normalized.append(StateHistorySeries(entity_id=entity_id, points=points)) normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
@@ -176,3 +191,16 @@ def _optional_string(value: object) -> str | None:
if value is None or value == "": if value is None or value == "":
return None return None
return str(value) return str(value)
def _relevant_state_attributes(attributes: dict[str, object]) -> dict[str, object]:
keys = {
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
}
return {key: attributes[key] for key in keys if key in attributes}

View File

@@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.6.1", version="1.7.4",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -255,6 +255,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket" ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token) auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
reconnect_delay = 1.0
relevant_entity_ids: set[str] = set()
relevant_loaded_at = 0.0
while True: while True:
if ws_status is not None: if ws_status is not None:
ws_status.status = "connecting" ws_status.status = "connecting"
@@ -283,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader) state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = asyncio.get_running_loop().time()
reconnect_delay = 1.0
if ws_status is not None: if ws_status is not None:
ws_status.status = "connected" ws_status.status = "connected"
ws_status.error = None ws_status.error = None
@@ -309,10 +315,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
entity_id = event_data.get("entity_id") entity_id = event_data.get("entity_id")
if not entity_id: if not entity_id:
continue continue
loop_time = asyncio.get_running_loop().time()
if loop_time - relevant_loaded_at >= 10:
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = loop_time
if entity_id not in relevant_entity_ids:
continue
new_state = event_data.get("new_state") new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, 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 # Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen # Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread( await asyncio.to_thread(
@@ -330,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
websockets.exceptions.InvalidStatus, websockets.exceptions.InvalidStatus,
OSError, OSError,
) as exc: ) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc) delay = reconnect_delay
logger.warning(
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
exc,
delay,
)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(1) await asyncio.sleep(delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
except Exception as exc: except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc) logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(1) await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt # Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -355,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
await asyncio.sleep( await asyncio.sleep(
app.state.settings.prediction_interval_seconds app.state.settings.prediction_interval_seconds
if websocket_connected if websocket_connected
else min(5, app.state.settings.prediction_interval_seconds) else max(30, app.state.settings.prediction_interval_seconds)
) )
# Nur ausführen, wenn WebSocket nicht verbunden ist # Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
@@ -391,15 +408,14 @@ def _update_ha_state_cache(
) )
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool: def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
result: set[str] = set()
for record in store.list(): for record in store.list():
if record.actuator_entity_id == entity_id: result.add(record.actuator_entity_id)
return True if record.assignment.selected_numeric_entity_id:
if record.assignment.selected_numeric_entity_id == entity_id: result.add(record.assignment.selected_numeric_entity_id)
return True result.update(record.assignment.selected_context_entity_ids)
if entity_id in record.assignment.selected_context_entity_ids: return result
return True
return False
def _ha_entity_from_event( def _ha_entity_from_event(

View File

@@ -65,10 +65,10 @@
.group-panel > summary::-webkit-details-marker { display:none; } .group-panel > summary::-webkit-details-marker { display:none; }
details.collapsible > summary::after, details.collapsible > summary::after,
.manual-context > summary::after, .manual-context > summary::after,
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; } .group-panel > summary::after { content:attr(data-closed-label); color:#9fb0be; font-weight:600; font-size:.86rem; }
details[open].collapsible > summary::after, details[open].collapsible > summary::after,
.manual-context[open] > summary::after, .manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:"zuklappen"; } .group-panel[open] > summary::after { content:attr(data-open-label); }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; } .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 { 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-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; }
@@ -281,12 +281,78 @@ let discoveryLoadPromise = null;
let overviewLoadPromise = null; let overviewLoadPromise = null;
let systemLoadPromise = null; let systemLoadPromise = null;
let currentSensorWeightGroups = []; let currentSensorWeightGroups = [];
let latestSimulationResults = new Map();
let visibleActuatorLimit = 24; let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000; const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 3000; const DASHBOARD_TIMEOUT_MS = 3000;
const I18N = { const I18N = {
de: { de: {
ui: {
tagline: "Geräte, Lernen, Freigaben und Systemzustand.",
menu: "Menü",
nav_status: "Startseite / System",
nav_learning: "Lernen",
nav_discovery: "Discovery & Einrichtung",
nav_settings: "Einstellungen",
page_ready: "Seite bereit, Status folgt ...",
discovery_title: "Discovery & Einrichtung",
entity_id: "Entity-ID",
actuator_placeholder: "z. B. light.licht_abstellraum",
type: "Typ",
all_actuators: "Alle steuerbaren Typen",
lights: "Lichter",
switches: "Schalter / Helper",
buttons: "Buttons",
helper_buttons: "Helper-Buttons",
helper_switches: "Helper-Schalter",
covers: "Rollläden / Cover",
climate: "Heizungen / Klima",
locks: "Schlösser",
fans: "Lüftung / Ventilatoren",
humidifiers: "Befeuchter / Entfeuchter",
media: "TV / Medien",
remotes: "Fernbedienungen",
scenes: "Szenen",
numbers: "Numerische Helper",
valves: "Ventile",
search_list: "Liste durchsuchen",
search_placeholder: "Raum, Gerät oder Entity",
device_list: "Geräteliste",
device_list_lazy: "Geräteliste bei Bedarf laden",
add_device: "Gerät hinzufügen und Beobachtung starten",
load_device_list: "Geräteliste laden",
ready: "Bereit.",
show_suggestions: "Vorschläge anzeigen",
load_suggestions: "Vorschläge laden",
observed_devices: "Beobachtete Geräte",
refresh: "Aktualisieren",
details: "Details",
back: "Zurück",
detail_empty: "Öffne bei einem beobachteten Gerät die Details.",
system_cache: "System & Cache",
check_status: "Status prüfen",
checking: "Prüfung läuft ...",
settings: "Einstellungen",
settings_hint: "Sprache und Standardwerte für die Bedienoberfläche.",
language: "Sprache",
no_prediction: "Keine fällige Aktion",
open: "offen",
no_area: "Ohne Bereich",
loading_start: "Startdaten laden ...",
loading_devices: "Beobachtete Geräte werden geladen ...",
delayed_start: "Startdaten verzögert",
unavailable_start: "Startdaten sind gerade nicht verfügbar.",
system_loading: "Systemübersicht lädt ...",
system_delayed: "Systemübersicht verzögert",
expand: "aufklappen",
collapse: "zuklappen",
context_detected: "Kontext erkannt",
active_approved: "aktiv freigegeben",
shadow_prediction: "Prüfmodus mit Vorhersage",
learning_blocked: "Lernen blockiert",
collecting_actions: "sammelt Handlungen",
},
safety_stage: { safety_stage: {
observe: "Nur beobachten", observe: "Nur beobachten",
suggest: "Vorschläge anzeigen", suggest: "Vorschläge anzeigen",
@@ -357,6 +423,71 @@ const I18N = {
}, },
}, },
en: { en: {
ui: {
tagline: "Devices, learning, approvals, and system health.",
menu: "Menu",
nav_status: "Home / System",
nav_learning: "Learning",
nav_discovery: "Discovery & setup",
nav_settings: "Settings",
page_ready: "Page ready, status pending ...",
discovery_title: "Discovery & setup",
entity_id: "Entity ID",
actuator_placeholder: "e.g. light.storage_room",
type: "Type",
all_actuators: "All controllable types",
lights: "Lights",
switches: "Switches / helpers",
buttons: "Buttons",
helper_buttons: "Helper buttons",
helper_switches: "Helper switches",
covers: "Shutters / covers",
climate: "Heating / climate",
locks: "Locks",
fans: "Ventilation / fans",
humidifiers: "Humidifiers / dehumidifiers",
media: "TV / media",
remotes: "Remotes",
scenes: "Scenes",
numbers: "Numeric helpers",
valves: "Valves",
search_list: "Search list",
search_placeholder: "Room, device, or entity",
device_list: "Device list",
device_list_lazy: "Load device list when needed",
add_device: "Add device and start observing",
load_device_list: "Load device list",
ready: "Ready.",
show_suggestions: "Show suggestions",
load_suggestions: "Load suggestions",
observed_devices: "Observed devices",
refresh: "Refresh",
details: "Details",
back: "Back",
detail_empty: "Open details from an observed device.",
system_cache: "System & cache",
check_status: "Check status",
checking: "Checking ...",
settings: "Settings",
settings_hint: "Language and UI defaults.",
language: "Language",
no_prediction: "No due action",
open: "open",
no_area: "No area",
loading_start: "Loading start data ...",
loading_devices: "Loading observed devices ...",
delayed_start: "Start data delayed",
unavailable_start: "Start data is currently unavailable.",
system_loading: "Loading system overview ...",
system_delayed: "System overview delayed",
expand: "expand",
collapse: "collapse",
context_detected: "Context detected",
active_approved: "actively approved",
shadow_prediction: "Review mode with prediction",
learning_blocked: "Learning blocked",
collecting_actions: "collecting actions",
},
safety_stage: { safety_stage: {
observe: "Observe only", observe: "Observe only",
suggest: "Show suggestions", suggest: "Show suggestions",
@@ -427,6 +558,182 @@ const I18N = {
}, },
}, },
}; };
const DE_TO_EN_TEXT = new Map(Object.entries({
"Aktiv / Prüfmodus": "Active / review mode",
"Aktive Gewichtung": "Active weight",
"Aktoren": "Actuators",
"Aktualisieren": "Refresh",
"Aktualisiert.": "Refreshed.",
"Aktuelle Blocker:": "Current blockers:",
"Aktuelle Situation auswerten": "Evaluate current situation",
"Aktuell ist kein gelerntes Handlungsmuster fällig.": "No learned action pattern is due right now.",
"Alle relevanten Vorschläge": "All relevant suggestions",
"Als Gruppe speichern": "Save as group",
"Anderes Gerät": "Another device",
"Annahmen": "Assumptions",
"Anomalien": "Anomalies",
"Anomalien offen": "anomalies open",
"Aktor-Simulation": "Actuator simulation",
"Aufgabenliste": "Task list",
"Automationen neu suchen": "Find automations again",
"Automatische Gewichtsanpassungen": "Automatic weight adjustments",
"Automatische Relevanz": "Automatic relevance",
"Bedienoberfläche.": "user interface.",
"Bereit.": "Ready.",
"Bestes Szenario": "Best scenario",
"Bestes Szenario berechnen": "Calculate best scenario",
"Betriebsart:": "Mode:",
"Beitragsfaktoren": "Contributing factors",
"Cache aktuell mit": "Cache current with",
"Cache wird nach Discovery aufgebaut": "Cache will be built after discovery",
"Cache-Zeitpunkt": "Cache time",
"Cooldown Sekunden": "Cooldown seconds",
"Dashboard bereit in": "Dashboard ready in",
"Dashboard bleibt bedienbar.": "The dashboard remains usable.",
"Dauer:": "Duration:",
"Davon eindeutig geregelt:": "Clearly regulated:",
"Details öffnen": "Open details",
"Diese Kontext-Auswahl speichern": "Save this context selection",
"Discovery-Gruppen": "Discovery groups",
"Entfernen": "Remove",
"Entscheidungsakte": "Decision record",
"Entity-IDs der Gruppe": "Group entity IDs",
"Entity-IDs manuell ergänzen": "Add entity IDs manually",
"Ergebnis:": "Result:",
"Erkannt:": "Detected:",
"Erkannte HA-Automationen:": "Detected HA automations:",
"Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.": "Try again: start the dashboard action again; the next run will create a new entry.",
"Freigabe": "Approval",
"Freigabebereit": "Ready for approval",
"Freigabestatus:": "Approval status:",
"Freigabestufe": "Approval stage",
"Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.": "Select context sensors before running a simulation.",
"Geladene Startdaten": "Loaded start data",
"Gelernte Handlungen": "Learned actions",
"Gelernt / Wartet": "Learned / waiting",
"Gerät auswählen": "Select device",
"Geräteliste konnte nicht geladen werden:": "Device list could not be loaded:",
"Geräteliste lädt im Hintergrund ...": "Device list loading in the background ...",
"Geräteliste wird geladen ...": "Device list is loading ...",
"Gewichtung": "Weight",
"Gewichtung korrigieren": "Adjust weight",
"Gewichtung übernehmen": "Apply weight",
"Gewichtung speichern": "Save weight",
"Gewichtungen speichern": "Save weights",
"Gruppen-Gewicht in %": "Group weight in %",
"Gruppen-Gewichtung": "Group weighting",
"Gruppenname": "Group name",
"HA-Automationen pausieren": "pause HA automations",
"HA-Automationen pausiert lassen": "leave HA automations paused",
"HA-Automationen fortsetzen": "resume HA automations",
"Handlungen": "Actions",
"Job p95": "Job p95",
"Jobs aktiv": "Active jobs",
"Kategorie": "Category",
"Kategorien": "categories",
"Keine Annahmen gespeichert.": "No assumptions stored.",
"Keine aktiven Automation-Konflikte erkannt.": "No active automation conflicts detected.",
"Keine eindeutig passende HA-Automation gefunden.": "No clearly matching HA automation found.",
"Keine gesicherten Punkte gespeichert.": "No confirmed points stored.",
"Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.": "No local safety blockers for the current prediction.",
"Keine offenen Anomalien fuer diesen Aktor.": "No open anomalies for this actuator.",
"Keine passenden Geräte gefunden": "No matching devices found",
"Keine passenden Vorschläge": "No matching suggestions",
"Keine Simulationsergebnisse.": "No simulation results.",
"Keine Unsicherheit gespeichert.": "No uncertainty stored.",
"Kein Kommentar": "No comment",
"Kein numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Keinen numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Kontext": "Context",
"Kontext selbst festlegen": "Set context manually",
"Kontext wird automatisch analysiert ...": "Context is being analyzed automatically ...",
"Kontext-Entity entfernt.": "Context entity removed.",
"Kontext-Entity ausgewählt.": "context entity selected.",
"Kontextzuordnung:": "Context assignment:",
"Kritisch": "Critical",
"Langsame Jobs": "Slow jobs",
"Langsam:": "Slow:",
"Lädt ...": "Loading ...",
"Lernbereit": "Ready to learn",
"Lernbereite Geräte": "Devices ready to learn",
"Lernen": "Learning",
"Lernfortschritt": "Learning progress",
"Lernsystem": "Learning system",
"Letzte automatische Prüfung:": "Last automatic check:",
"Letztes Training:": "Last training:",
"Manuelle Kontext-Auswahl gespeichert.": "Manual context selection saved.",
"Manuelle Sicherheitssperre aktiv": "Manual safety block active",
"Mindest-Sicherheit in %": "Minimum confidence in %",
"Noch keine Aktoren ausgewählt.": "No actuators selected yet.",
"Noch keine aktuelle Entscheidungsfaktoren berechnet.": "No current decision factors calculated yet.",
"Noch keine automatische Gewichtsanpassung.": "No automatic weight adjustment yet.",
"Noch keine Daten": "No data yet",
"Noch keine Gruppe gespeichert.": "No group saved yet.",
"Noch keine Kontext-Entity ausgewählt.": "No context entity selected yet.",
"Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.": "No sensors or states selected for weighting yet.",
"Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.": "No suitable context detected yet. SillyHome will check again when new HA data arrives.",
"Noch kein Modell-Snapshot gespeichert.": "No model snapshot saved yet.",
"Numerische Helper": "Numeric helpers",
"Optionaler Haupt-Messsensor": "Optional main measurement sensor",
"Passende Home-Assistant-Automationen": "Matching Home Assistant automations",
"Passende Home-Assistant-Automationen neu geprüft.": "Matching Home Assistant automations checked again.",
"Prüfen": "Review",
"Prüfung läuft ...": "Checking ...",
"Rollback möglich": "Rollback available",
"Sicherheit und manuelles Gegensteuern": "Safety and manual override",
"Sicherheitsprofil speichern": "Save safety profile",
"Sicherheitsprofil gespeichert.": "Safety profile saved.",
"SillyHome parallel aktivieren": "Activate SillyHome in parallel",
"SillyHome stoppen; HA-Automationen pausiert lassen": "Stop SillyHome; leave HA automations paused",
"SillyHome stoppen und pausierte HA-Automationen fortsetzen": "Stop SillyHome and resume paused HA automations",
"SillyHome übernehmen lassen": "Let SillyHome take over",
"SillyHome übernehmen lassen und passende HA-Automationen pausieren": "Let SillyHome take over and pause matching HA automations",
"Simulation läuft ...": "Simulation running ...",
"Simulation übernommen.": "Simulation applied.",
"Simulation übernommen und Dry-run gestartet.": "Simulation applied and dry-run started.",
"Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.": "Simulation result is no longer available. Please simulate again.",
"Simulierte Gewichtung in %": "Simulated weight in %",
"Simulierter Zustand": "Simulated state",
"Sensor-Gewichtung": "Sensor weighting",
"Sensor-Gewichtung gespeichert.": "Sensor weighting saved.",
"Start:": "Start:",
"Status teilweise verfügbar. Das Dashboard bleibt bedienbar.": "Status partially available. The dashboard remains usable.",
"Status wird geprüft ...": "Checking status ...",
"Statusgrund:": "Status reason:",
"System bereit": "System ready",
"Systemübersicht bereit in": "System overview ready in",
"Systemübersicht langsam:": "System overview slow:",
"Systemübersicht verzögert:": "System overview delayed:",
"Übernehmen + Dry-run starten": "Apply + start dry-run",
"Unsicherheit": "Uncertainty",
"Verwendete Sensoren/Zustände ändern": "Change used sensors/states",
"Vorhersage": "Prediction",
"Vorhersage korrekt": "Prediction correct",
"Vorhersage falsch": "Prediction wrong",
"Was SillyHome aktuell vorhersagt": "What SillyHome currently predicts",
"Wissen": "Knowledge",
"Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.": "Select a device from the loaded list or enter an entity ID.",
"Würde nach Sicherheitsprüfung schalten.": "Would switch after safety check.",
"Zeitprofile": "Time profiles",
"Zuordnung": "Assignment",
"Zuordnungssicherheit:": "Assignment confidence:",
"Zurück zur Übersicht": "Back to overview",
"Zustände": "States",
"Zustände vergleichen": "Compare states",
"aktiv": "active",
"aktiv freigegeben": "actively approved",
"automatisch erledigt": "automatic",
"bereit": "ready",
"keine": "none",
"keine Vorhersage": "no prediction",
"läuft/offen": "running/open",
"noch offen": "pending",
"offen": "open",
"pausiert": "paused",
"relevant": "relevant",
"statistisch relevanter Kandidat": "statistically relevant candidate",
"unbekannt": "unknown",
}));
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de"; let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
function jumpToSection(target) { function jumpToSection(target) {
@@ -467,11 +774,16 @@ function showView(viewId) {
function setLanguage(language) { function setLanguage(language) {
uiLang = I18N[language] ? language : "de"; uiLang = I18N[language] ? language : "de";
localStorage.setItem("sillyhome.ui.language", uiLang); localStorage.setItem("sillyhome.ui.language", uiLang);
document.documentElement.lang = uiLang;
cachedDetailHtml.clear();
applyStaticTranslations();
syncSettingsView(); syncSettingsView();
renderActuatorSelect();
if (cachedActuators) renderConfiguredActuators(); if (cachedActuators) renderConfiguredActuators();
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview); if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) { if (currentActuatorId) {
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId); cachedDetailHtml.delete(currentActuatorId);
void showActuator(currentActuatorId);
} }
} }
@@ -481,16 +793,161 @@ function syncSettingsView() {
} }
function translate(group, value, fallback = "") { function translate(group, value, fallback = "") {
if (value == null || value === "") return fallback || "offen"; if (value == null || value === "") return fallback || ui("open");
return I18N[uiLang]?.[group]?.[value] || fallback || String(value); return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
} }
function ui(key) {
return I18N[uiLang]?.ui?.[key] || I18N.de.ui[key] || key;
}
function setText(selector, key) {
const element = document.querySelector(selector);
if (element) element.textContent = ui(key);
}
function setPlaceholder(selector, key) {
const element = document.querySelector(selector);
if (element) element.placeholder = ui(key);
}
function translateText(value) {
if (uiLang !== "en") return value;
const text = String(value ?? "");
const direct = DE_TO_EN_TEXT.get(text.trim());
if (direct) {
return text.replace(text.trim(), direct);
}
let translated = text;
const replacements = [...DE_TO_EN_TEXT.entries()]
.filter(([source]) => source.length > 3)
.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of replacements) {
translated = translated.replaceAll(source, target);
}
translated = translated
.replace(/Bereit in (\d+) ms/g, "Ready in $1 ms")
.replace(/Weitere (\d+) Geräte anzeigen/g, "Show $1 more devices")
.replace(/(\d+) von (\d+); Suche oder Typ weiter eingrenzen/g, "$1 of $2; narrow search or type")
.replace(/Szenario (\d+)/g, "Scenario $1")
.replace(/Ø (\d+) %/g, "avg. $1 %")
.replace(/(\d+) Beispiele/g, "$1 examples")
.replace(/(\d+) eindeutig/g, "$1 clear")
.replace(/(\d+) % Beitrag/g, "$1 % contribution")
.replace(/(\d+) % Profilklarheit/g, "$1 % profile clarity")
.replace(/mit (\d+) % Sicherheit/g, "with $1 % confidence")
.replace(/Prüfung ([^:]+):/g, "Check $1:")
.replace(/Keine Zusammenfassung/g, "No summary")
.replace(/Dauer: läuft\/offen/g, "Duration: running/open");
return translated;
}
function localizeFragment(root = document) {
for (const summary of root.querySelectorAll?.("details.collapsible > summary, .manual-context > summary, .group-panel > summary") || []) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
if (uiLang !== "en") return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
node.nodeValue = translateText(node.nodeValue);
}
for (const input of root.querySelectorAll?.("input[placeholder], textarea[placeholder]") || []) {
input.placeholder = translateText(input.placeholder);
}
for (const optgroup of root.querySelectorAll?.("optgroup[label]") || []) {
optgroup.label = translateText(optgroup.label);
}
}
function applyStaticTranslations() {
document.body.dataset.closedLabel = ui("expand");
document.body.dataset.openLabel = ui("collapse");
for (const summary of document.querySelectorAll("details.collapsible > summary, .manual-context > summary, .group-panel > summary")) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
setText("header .brand p", "tagline");
setText("label[for='section-jump']", "menu");
const navOptions = document.querySelectorAll("#section-jump option");
[
"nav_status",
"nav_learning",
"nav_discovery",
"nav_settings",
].forEach((key, index) => {
if (navOptions[index]) navOptions[index].textContent = ui(key);
});
setText("#load-budget", "page_ready");
setText("#choose h2", "discovery_title");
setText("label[for='actuator-input']", "entity_id");
setPlaceholder("#actuator-input", "actuator_placeholder");
setText("label[for='actuator-domain-filter']", "type");
const domainOptions = document.querySelectorAll("#actuator-domain-filter option");
[
"all_actuators",
"lights",
"switches",
"buttons",
"helper_buttons",
"helper_switches",
"covers",
"climate",
"locks",
"fans",
"humidifiers",
"media",
"remotes",
"scenes",
"numbers",
"valves",
].forEach((key, index) => {
if (domainOptions[index]) domainOptions[index].textContent = ui(key);
});
setText("label[for='actuator-search']", "search_list");
setPlaceholder("#actuator-search", "search_placeholder");
setText("label[for='actuator-select']", "device_list");
const lazyOption = document.querySelector("#actuator-select option[value='']");
if (lazyOption) lazyOption.textContent = ui("device_list_lazy");
const chooseButtons = document.querySelectorAll("#choose > button");
if (chooseButtons[0]) chooseButtons[0].textContent = ui("add_device");
if (chooseButtons[1]) chooseButtons[1].textContent = ui("load_device_list");
setText("#actuator-config-result", "ready");
setText("#choose .manual-context summary", "show_suggestions");
const suggestionButton = document.querySelector("#choose .manual-context button");
if (suggestionButton) suggestionButton.textContent = ui("load_suggestions");
setText("#observed h2", "observed_devices");
const refreshButton = document.querySelector("#observed .panel-title button");
if (refreshButton) refreshButton.textContent = ui("refresh");
setText("#detail h2", "details");
const backButton = document.querySelector("#detail .panel-title button");
if (backButton) backButton.textContent = ui("back");
setText("#actuator-detail", "detail_empty");
setText("#status-section h2", "system_cache");
const statusButton = document.querySelector("#status-section .panel-title button");
if (statusButton) statusButton.textContent = ui("check_status");
setText("#settings h2", "settings");
setText("#settings .muted", "settings_hint");
setText("label[for='language-select']", "language");
localizeFragment(document);
}
function formatDateTime(value) { function formatDateTime(value) {
if (!value) return "noch offen"; if (!value) return ui("open");
const parsed = new Date(value); const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) return Number.isNaN(parsed.getTime())
? String(value) ? String(value)
: parsed.toLocaleString("de-DE"); : parsed.toLocaleString(uiLang === "en" ? "en-US" : "de-DE");
} }
function uniqueValues(values) { function uniqueValues(values) {
@@ -531,7 +988,7 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
function lifecycleLabel(record) { function lifecycleLabel(record) {
const behaviorStatus = record.behavior_status || record.behavior?.status; const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt"; if (behaviorStatus === "trained") return ui("context_detected");
const labels = { const labels = {
trained: translate("lifecycle_status", "trained"), trained: translate("lifecycle_status", "trained"),
pending_history: translate("lifecycle_status", "pending_history"), pending_history: translate("lifecycle_status", "pending_history"),
@@ -555,10 +1012,10 @@ function statusClass(record) {
function behaviorLabel(record) { function behaviorLabel(record) {
const mode = record.behavior_mode || record.behavior?.mode; const mode = record.behavior_mode || record.behavior?.mode;
const status = record.behavior_status || record.behavior?.status; const status = record.behavior_status || record.behavior?.status;
if (mode === "active") return "aktiv freigegeben"; if (mode === "active") return ui("active_approved");
if (status === "trained") return "Prüfmodus mit Vorhersage"; if (status === "trained") return ui("shadow_prediction");
if (status === "blocked") return "Lernen blockiert"; if (status === "blocked") return ui("learning_blocked");
return "sammelt Handlungen"; return ui("collecting_actions");
} }
function predictionLabel(record) { function predictionLabel(record) {
@@ -566,11 +1023,11 @@ function predictionLabel(record) {
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence; const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target return target
? `${target} (${Math.round(confidence * 100)} %)` ? `${target} (${Math.round(confidence * 100)} %)`
: "Keine fällige Aktion"; : ui("no_prediction");
} }
function entityLabel(entity) { function entityLabel(entity) {
const area = entity.area_name || "Ohne Bereich"; const area = entity.area_name || ui("no_area");
const name = entity.friendly_name || entity.entity_id; const name = entity.friendly_name || entity.entity_id;
return `${area} - ${name} (${entity.entity_id})`; return `${area} - ${name} (${entity.entity_id})`;
} }
@@ -654,9 +1111,9 @@ async function loadOverview() {
async function doLoadOverview() { async function doLoadOverview() {
const startedAt = performance.now(); const startedAt = performance.now();
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Startdaten laden ..."; if (budget) budget.textContent = ui("loading_start");
if (!cachedActuators) { if (!cachedActuators) {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>"; document.getElementById("configured-actuators").innerHTML = `<p class='muted'>${escapeHtml(ui("loading_devices"))}</p>`;
} }
try { try {
const dashboard = await api("v1/actuators/dashboard/start"); const dashboard = await api("v1/actuators/dashboard/start");
@@ -669,17 +1126,18 @@ async function doLoadOverview() {
if (budget) { if (budget) {
const loadMs = dashboard._load_elapsed_ms; const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Bereit in ${loadMs} ms` ? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`; : `Langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
} }
} catch (error) { } catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Startdaten verzögert"; if (budget) budget.textContent = ui("delayed_start");
try { try {
await loadSummaryData(); await loadSummaryData();
renderConfiguredActuators(); renderConfiguredActuators();
} catch (_) { } catch (_) {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>"; document.getElementById("configured-actuators").innerHTML = `<div class='empty-state'>${escapeHtml(ui("unavailable_start"))}</div>`;
} }
} }
scheduleDashboardExtras(); scheduleDashboardExtras();
@@ -696,7 +1154,7 @@ async function loadSystemOverview() {
async function doLoadSystemOverview() { async function doLoadSystemOverview() {
const startedAt = performance.now(); const startedAt = performance.now();
const budget = document.getElementById("load-budget"); const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ..."; if (budget) budget.textContent = ui("system_loading");
try { try {
const dashboard = await api("v1/actuators/dashboard/system"); const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
@@ -708,11 +1166,12 @@ async function doLoadSystemOverview() {
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Systemübersicht bereit in ${loadMs} ms` ? `Systemübersicht bereit in ${loadMs} ms`
: `Systemübersicht langsam: ${loadMs} ms`; : `Systemübersicht langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
} }
scheduleDashboardExtras(); scheduleDashboardExtras();
} catch (error) { } catch (error) {
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`; document.getElementById("status").innerHTML = `<p class="warn">${escapeHtml(translateText(`Systemübersicht verzögert: ${error.message}`))}</p>`;
if (budget) budget.textContent = "Systemübersicht verzögert"; if (budget) budget.textContent = ui("system_delayed");
} }
} }
@@ -755,7 +1214,7 @@ async function loadDashboardExtras() {
if (reconciliation.status === "fulfilled") { if (reconciliation.status === "fulfilled") {
const text = document.getElementById("reconciliation-status"); const text = document.getElementById("reconciliation-status");
if (text) { if (text) {
text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`; text.textContent = translateText(`Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`);
} }
} }
} catch (_) { } catch (_) {
@@ -792,6 +1251,8 @@ async function loadStatus() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
localizeFragment(status);
localizeFragment(chips);
} }
function renderDashboardStatus(dashboard) { function renderDashboardStatus(dashboard) {
@@ -856,6 +1317,9 @@ function renderDashboardStatus(dashboard) {
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`, `<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join(""); ].join("");
renderJobQueue(jobs); renderJobQueue(jobs);
localizeFragment(status);
localizeFragment(chips);
localizeFragment(stats);
} }
function renderJobQueue(jobs) { function renderJobQueue(jobs) {
@@ -876,6 +1340,7 @@ function renderJobQueue(jobs) {
</div> </div>
`).join("")} `).join("")}
` : ""; ` : "";
localizeFragment(jobsBox);
} }
async function loadSummaryData() { async function loadSummaryData() {
@@ -949,6 +1414,7 @@ function renderActuatorDiscovery() {
options.innerHTML = ""; options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`; select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
} }
localizeFragment(document.getElementById("choose"));
} }
async function loadActuatorSuggestions() { async function loadActuatorSuggestions() {
@@ -975,6 +1441,7 @@ async function loadActuatorSuggestions() {
} catch (_) { } catch (_) {
box.innerHTML = ""; box.innerHTML = "";
} }
localizeFragment(box);
} }
function actuatorGroupLabel(domain) { function actuatorGroupLabel(domain) {
@@ -1022,6 +1489,7 @@ function renderActuatorSelect() {
</optgroup> </optgroup>
`), `),
].join(""); ].join("");
localizeFragment(select);
} }
async function loadContextOptions(actuatorId) { async function loadContextOptions(actuatorId) {
@@ -1067,6 +1535,7 @@ function renderManualContextSelect() {
select.innerHTML = filtered.length select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow) ? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`; : `<option value="">Keine passenden Vorschläge</option>`;
localizeFragment(select);
} }
function parseEntityIds(value) { function parseEntityIds(value) {
@@ -1164,6 +1633,7 @@ function renderConfiguredActuators() {
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
localizeFragment(box);
} }
async function showActuator(actuatorId, evaluationMessage = "") { async function showActuator(actuatorId, evaluationMessage = "") {
@@ -1176,6 +1646,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) { if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId); box.innerHTML = cachedDetailHtml.get(actuatorId);
localizeFragment(box);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators(); renderConfiguredActuators();
return; return;
@@ -1252,6 +1723,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button> <button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
</details> </details>
`; `;
const simulationControls = weightedCandidates.length ? `
<details class="manual-context" open>
<summary>Aktor-Simulation</summary>
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten. Danach kannst du die beste Gewichtung übernehmen oder direkt in den Dry-run wechseln.</p>
<div class="card-list">
${weightedCandidates.map(candidate => {
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
const currentState = candidate.state || "";
const stateOptions = candidate.domain === "binary_sensor"
? "off,on"
: currentState;
return `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
</div>
<span class="chip">Simulation</span>
</div>
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
</article>
`;
}).join("")}
</div>
<div class="actions">
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
</div>
<div id="simulation-result" class="decision-list"></div>
</details>
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
const currentContextControls = contexts.length const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => ` ? `<ul>${contexts.map(entityId => `
<li> <li>
@@ -1510,17 +2017,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<h3>Sensor-Gewichtung</h3> <h3>Sensor-Gewichtung</h3>
${weightControls} ${weightControls}
${weightGroupControls} ${weightGroupControls}
${simulationControls}
<h3>Verwendete Sensoren/Zustände ändern</h3> <h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
box.innerHTML = detailHtml; box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml); localizeFragment(box);
cachedDetailHtml.set(actuatorId, box.innerHTML);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators(); renderConfiguredActuators();
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
localizeFragment(box);
} }
function renderActuatorDetailShell(actuatorId) { function renderActuatorDetailShell(actuatorId) {
@@ -1537,6 +2047,7 @@ function renderActuatorDetailShell(actuatorId) {
<div class="metric"><strong>Kontext</strong>...</div> <div class="metric"><strong>Kontext</strong>...</div>
</div> </div>
`; `;
localizeFragment(document.getElementById("actuator-detail"));
} }
async function hydrateContextOptions(record) { async function hydrateContextOptions(record) {
@@ -1564,6 +2075,8 @@ async function hydrateContextOptions(record) {
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")} ${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`; `;
renderManualContextSelect(); renderManualContextSelect();
localizeFragment(numericSelect);
localizeFragment(categorySelect);
} }
async function hydrateCurrentContextOptions(actuatorId) { async function hydrateCurrentContextOptions(actuatorId) {
@@ -1610,6 +2123,99 @@ async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
} }
} }
async function simulateActuator(actuatorId) {
const sensorStates = {};
const sensorWeights = {};
const stateOptions = {};
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
const value = input.value.trim();
if (value) sensorStates[input.dataset.simStateEntity] = value;
}
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
const value = Number(input.value);
if (Number.isFinite(value)) {
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
}
}
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
}
const box = document.getElementById("simulation-result");
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
try {
const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, {
method: "POST",
body: JSON.stringify({
sensor_states: sensorStates,
sensor_weights: sensorWeights,
state_options: stateOptions,
max_results: 6,
}),
});
latestSimulationResults.set(actuatorId, results);
box.innerHTML = results.length ? results.map((result, index) => {
const prediction = result.prediction;
const factors = result.decision_factors || [];
return `
<div class="decision-row">
<header>
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
</header>
<p>${escapeHtml(result.recommendation || "")}</p>
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
<div class="actions">
<button class="secondary compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', false)">Gewichtung übernehmen</button>
<button class="compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', true)">Übernehmen + Dry-run starten</button>
</div>
</div>
`;
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {
const result = (latestSimulationResults.get(actuatorId) || [])
.find(item => item.scenario_id === scenarioId);
if (!result) {
alert("Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.");
return;
}
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
method: "POST",
body: JSON.stringify({
sensor_weights: result.sensor_weights || {},
sensor_weight_groups: currentSensorWeightGroups,
note: `Aus Simulation ${scenarioId} übernommen`,
}),
});
if (startDryRun) {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/dry-run`, {
method: "POST",
body: JSON.stringify({enabled: true}),
});
}
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(
actuatorId,
startDryRun
? "Simulation übernommen und Dry-run gestartet."
: "Simulation übernommen.",
);
} catch (error) {
alert(error.message);
}
}
async function saveManualAssignment(actuatorId) { async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null; const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from( const selectedContextIds = Array.from(
@@ -1820,9 +2426,11 @@ async function removeActuator(actuatorId) {
} }
async function startDashboard() { async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>"; document.documentElement.lang = uiLang;
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>"; applyStaticTranslations();
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>"; document.getElementById("status").innerHTML = `<p class='muted'>${escapeHtml(uiLang === "en" ? "Status loading ..." : "Status lädt nach ...")}</p>`;
document.getElementById("configured-actuators").innerHTML = `<div class='empty-state'>${escapeHtml(uiLang === "en" ? "Open Learning to load devices." : "Öffne „Lernen“, um Geräte zu laden.")}</div>`;
document.getElementById("actuator-detail").innerHTML = `<div class='empty-state'>${escapeHtml(uiLang === "en" ? "Select a device from the overview later." : "Wähle später ein Gerät aus der Übersicht.")}</div>`;
syncSettingsView(); syncSettingsView();
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail" const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
? "observed" ? "observed"

View File

@@ -0,0 +1,45 @@
# SillyHome Next v1.7.0 Operating Guide
v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und
Planungshilfen.
## Diagnose
- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor.
- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`.
- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde
vorhergesagt, wurde geschaltet oder blockiert, und warum.
## Backup und Restore
- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status
und Job-Historie als JSON.
- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein.
- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben.
## Dry-run
- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus.
- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht
an Home Assistant gesendet.
## Feedback
Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen:
- `correct`
- `wrong`
- `too_early`
- `too_late`
- `never_automate`
`never_automate` setzt eine manuelle Sicherheitssperre am Aktor.
## Planung
`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise:
- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten
- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten
- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback

View File

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

View File

@@ -87,7 +87,7 @@ def _service(
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None: def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
entity_id="light.abstellkammer", entity_id="light.abstellkammer",
@@ -352,6 +352,100 @@ def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit" assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.lidl_kuche",
domain="light",
friendly_name="Lidl Küche",
),
HaEntitySummary(
entity_id="light.lidl_wohnzimmer",
domain="light",
friendly_name="Lidl Wohnzimmer",
),
HaEntitySummary(
entity_id="binary_sensor.pir_kuche_motion_detection",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Küche",
),
HaEntitySummary(
entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Wohnzimmer",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.lidl_kuche")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.pir_kuche_motion_detection"
]
def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="button.smart_mailbox_als_geleert_markieren",
domain="button",
friendly_name="Smart Mailbox Als geleert markieren",
),
HaEntitySummary(
entity_id="binary_sensor.schrank_strasse_open",
domain="binary_sensor",
device_class="door",
friendly_name="Schrank Straße",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.schrank_strasse_open"
]
assert record.assignment.review_required is False
def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="humidifier.gastewc_luftung",
domain="humidifier",
friendly_name="GästeWC Lüftung",
),
HaEntitySummary(
entity_id="sensor.pir_gastewc_humidity",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Gäste WC Luftfeuchtigkeit",
),
HaEntitySummary(
entity_id="input_boolean.gaste_wc_occupied",
domain="input_boolean",
friendly_name="gaste_wc_occupied",
),
]
service = _service(
tmp_path,
entities,
{"sensor.pir_gastewc_humidity": _points(8, start, 55.0)},
)
record = service.configure_actuator("humidifier.gastewc_luftung")
assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity"
assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from time import perf_counter from time import perf_counter
from zoneinfo import ZoneInfo
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -10,7 +11,7 @@ from fastapi.testclient import TestClient
from app.api.v1.actuators import _deduplicate_actuator_ids from app.api.v1.actuators import _deduplicate_actuator_ids
from app.actuators.cache_db import DashboardCache from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import JobStatus, ModelSnapshot from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
@@ -272,6 +273,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75 assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
def test_actuator_simulation_ranks_sensor_states_without_switching(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"],
},
)
store = app.state.actuator_store
record = store.get("light.abstellkammer")
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Europe/Berlin"))
local_minute = local.hour * 60 + local.minute
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "on",
},
source="user",
weight=1.0,
observed_at=now,
)
for _ in range(3)
]
patterns.extend(
[
BehaviorPattern(
target_state="off",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "off",
},
source="user",
weight=0.5,
observed_at=now,
)
for _ in range(3)
]
)
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"patterns": patterns,
"sample_count": len(patterns),
"high_confidence_sample_count": len(patterns),
"activation_ready": True,
"activation_reason": "Testfreigabe.",
}
)
}
)
)
response = client.post(
"/v1/actuators/light.abstellkammer/simulate",
json={
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
"sensor_weights": {
"binary_sensor.abstellkammer_motion": 1.0,
"sensor.abstellkammer_illuminance": 0.25,
},
"max_results": 2,
},
)
assert response.status_code == 200
payload = response.json()
assert len(payload) == 2
assert payload[0]["prediction"]["target_state"] == "on"
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
assert app.state.ha_reader.service_calls == []
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None: def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
@@ -354,6 +440,51 @@ def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -
assert rollback.json()["behavior"]["active_model_version"] == version_id assert rollback.json()["behavior"]["active_model_version"] == version_id
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "kind": "never_automate"},
)
assert feedback.status_code == 200
payload = feedback.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
backup = client.get("/v1/actuators/backup/export")
dry_run = client.post(
"/v1/actuators/light.abstellkammer/dry-run",
json={"enabled": True},
)
planning = client.post("/v1/actuators/planning/refresh")
restore = client.post(
"/v1/actuators/backup/restore",
json={"backup": backup.json(), "replace_existing": True},
)
assert backup.status_code == 200
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
assert dry_run.status_code == 200
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
assert planning.status_code == 200
assert "agent_insights" in planning.json()[0]["behavior"]
assert restore.status_code == 200
assert restore.json()["restored_records"] == 1
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None: def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)

View File

@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
) is None ) is None
def test_prediction_respects_learned_context_delay() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"input_boolean.gaste_wc_occupied": "on"},
trigger_entity_id="input_boolean.gaste_wc_occupied",
trigger_from_state="off",
trigger_to_state="on",
trigger_delay_seconds=180,
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
]
early = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=30)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
due = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=185)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
assert early is None
assert due is not None
assert due.target_state == "on"
def test_light_prediction_carries_brightness_attributes() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
target_attributes={"brightness": brightness},
minute_of_day=now.astimezone().hour * 60 + now.astimezone().minute,
weekday=now.astimezone().weekday(),
context_states={"binary_sensor.pir_kuche_motion_detection": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago, brightness in zip((3, 2, 1), (80, 90, 100), strict=True)
]
prediction = predict_behavior(
patterns,
current_context={"binary_sensor.pir_kuche_motion_detection": "on"},
now=now,
min_support=3,
window_minutes=30,
)
assert prediction is not None
assert prediction.target_attributes["brightness"] == 90
def test_state_change_uses_websocket_context_state_for_immediate_action( def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -778,3 +853,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
assert reader.service_calls == [ assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"}) ("light", "turn_on", {"entity_id": "light.storage"})
] ]
def test_event_evaluation_records_decision_timeline_and_latency(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="on",
last_changed=now,
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.evaluate(
"light.storage",
trigger_entity_id="binary_sensor.storage_door",
trigger_state="on",
event_received_at=now,
)
trace = result.behavior.decision_timeline[-1]
latency = result.behavior.latency_measurements[-1]
assert trace.trigger_entity_id == "binary_sensor.storage_door"
assert trace.target_state == "on"
assert trace.executed is True
assert latency.trigger_entity_id == "binary_sensor.storage_door"
assert latency.executed is True
def test_dry_run_records_without_calling_service(tmp_path: Path) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"dry_run_enabled": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="on",
last_changed=now,
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.evaluate("light.storage")
assert reader.service_calls == []
assert result.behavior.dry_run_sample_count == 1
assert result.behavior.decision_timeline[-1].executed is False

View File

@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
assert [point.state for point in result[0].points] == ["off", "on"] assert [point.state for point in result[0].points] == ["off", "on"]
def test_normalize_state_history_keeps_light_attribute_changes() -> None:
result = normalize_state_history_payload(
[
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {"brightness": 80, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:00:00+00:00",
},
{
"state": "on",
"attributes": {"brightness": 120, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:05:00+00:00",
},
]
]
)
assert [point.attributes["brightness"] for point in result[0].points] == [80, 120]
def test_normalize_logbook_preserves_action_origin() -> None: def test_normalize_logbook_preserves_action_origin() -> None:
result = normalize_logbook_payload( result = normalize_logbook_payload(
[ [

View File

@@ -126,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
assert mock_app.state.ws_status.error is None assert mock_app.state.ws_status.error is None
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
async def run_test() -> None:
fake_ws = _FakeWebSocket(
[
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
mock_app = MagicMock()
mock_app.state.settings = MagicMock()
mock_app.state.settings.ha_url = "http://homeassistant:8123"
mock_app.state.settings.ha_token = "test-token"
mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path)
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()
anyio.run(run_test)
assert mock_engine.state_changes == []
def test_lifespan_skips_event_listener_without_ha_config() -> None: def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI() app = FastAPI()
app.state.settings = MagicMock() app.state.settings = MagicMock()