Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33cce32098 | |||
| 08e41b0198 | |||
| 1b9db62294 | |||
| 5ca0c53f6a | |||
| 8070a85b52 | |||
| 575211f0db | |||
| d9dc186f9b |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
|
||||
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
|
||||
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
|
||||
|
||||
## 1.6.0 - 2026-06-18
|
||||
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
|
||||
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
|
||||
|
||||
@@ -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)
|
||||
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
|
||||
[`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)
|
||||
|
||||
## Reifegrad
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "1.6.0"
|
||||
version: "1.7.4"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
|
||||
"entity",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"led",
|
||||
"lidl",
|
||||
"light",
|
||||
"licht",
|
||||
"lichtschalter",
|
||||
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
|
||||
"presence",
|
||||
"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:
|
||||
@@ -864,6 +893,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
|
||||
return True
|
||||
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
||||
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)
|
||||
return bool(
|
||||
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
||||
@@ -878,6 +919,18 @@ def _eligible_for_auto_context(
|
||||
device_class = candidate.device_class or ""
|
||||
if device_class in _AUTO_CONTEXT_CLASSES:
|
||||
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 (
|
||||
actuator.device_name
|
||||
and candidate.device_name
|
||||
@@ -899,6 +952,7 @@ def _score_candidate(
|
||||
score = 0.0
|
||||
actuator_tokens = _metadata_tokens(actuator)
|
||||
entity_tokens = _metadata_tokens(entity)
|
||||
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||
overlap = sorted(actuator_tokens.intersection(entity_tokens))
|
||||
if overlap:
|
||||
score += min(0.4, 0.1 * len(overlap))
|
||||
@@ -924,6 +978,31 @@ def _score_candidate(
|
||||
if entity.device_class in preferred_device_classes:
|
||||
score += 0.2
|
||||
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":
|
||||
score += 0.2
|
||||
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:
|
||||
if value is None:
|
||||
continue
|
||||
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
||||
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
|
||||
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 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:
|
||||
|
||||
@@ -52,6 +52,14 @@ class JobStatus(StrEnum):
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class FeedbackKind(StrEnum):
|
||||
CORRECT = "correct"
|
||||
WRONG = "wrong"
|
||||
TOO_EARLY = "too_early"
|
||||
TOO_LATE = "too_late"
|
||||
NEVER_AUTOMATE = "never_automate"
|
||||
|
||||
|
||||
class AssignmentCandidate(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
@@ -115,12 +123,14 @@ class ModelLifecycleState(BaseModel):
|
||||
|
||||
class BehaviorPattern(BaseModel):
|
||||
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)
|
||||
weekday: int = Field(ge=0, le=6)
|
||||
context_states: dict[str, str] = Field(default_factory=dict)
|
||||
trigger_entity_id: str | None = None
|
||||
trigger_from_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)
|
||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||
observed_at: datetime
|
||||
@@ -128,6 +138,7 @@ class BehaviorPattern(BaseModel):
|
||||
|
||||
class BehaviorPrediction(BaseModel):
|
||||
target_state: str
|
||||
target_attributes: dict[str, object] = Field(default_factory=dict)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
generated_at: datetime
|
||||
reason: str
|
||||
@@ -146,6 +157,43 @@ class DecisionFactor(BaseModel):
|
||||
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):
|
||||
entity_id: str
|
||||
previous_weight: float = Field(ge=0.0, le=1.0)
|
||||
@@ -248,6 +296,32 @@ class RelatedAutomation(BaseModel):
|
||||
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):
|
||||
mode: BehaviorMode = BehaviorMode.SHADOW
|
||||
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
||||
@@ -281,6 +355,16 @@ class BehaviorState(BaseModel):
|
||||
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
||||
time_profiles: list[TimeProfile] = 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):
|
||||
|
||||
@@ -100,6 +100,11 @@ class ActuatorStore:
|
||||
except ValueError as 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(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -10,7 +10,14 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.actuators.cache_db import DashboardCache
|
||||
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.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
@@ -52,9 +59,40 @@ class WeightOverrideRequest(BaseModel):
|
||||
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):
|
||||
correct: bool
|
||||
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):
|
||||
@@ -406,6 +444,48 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
|
||||
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])
|
||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||
return _service(request).list_configured()
|
||||
@@ -502,6 +582,28 @@ def evaluate_actuator(
|
||||
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)
|
||||
def record_feedback(
|
||||
actuator_entity_id: str,
|
||||
@@ -513,11 +615,24 @@ def record_feedback(
|
||||
actuator_entity_id,
|
||||
correct=payload.correct,
|
||||
expected_state=payload.expected_state,
|
||||
kind=payload.kind,
|
||||
)
|
||||
except KeyError as 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)
|
||||
def set_safety_profile(
|
||||
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}")
|
||||
|
||||
|
||||
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:
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
if not isinstance(store, ActuatorStore):
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from itertools import product
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from time import perf_counter
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.actuators.models import (
|
||||
ActuatorRecord,
|
||||
AdaptiveWeightUpdate,
|
||||
AgentInsight,
|
||||
AnomalyEvent,
|
||||
ActuatorGroup,
|
||||
AutomationConflict,
|
||||
BehaviorMode,
|
||||
BehaviorPattern,
|
||||
@@ -16,12 +20,17 @@ from app.actuators.models import (
|
||||
BehaviorState,
|
||||
BehaviorStatus,
|
||||
DecisionFactor,
|
||||
DecisionTrace,
|
||||
ExecutionEvent,
|
||||
FeedbackKind,
|
||||
LatencyMeasurement,
|
||||
ManualOverride,
|
||||
ModelSnapshot,
|
||||
RelatedAutomation,
|
||||
SafetyProfile,
|
||||
SafetyStage,
|
||||
SceneSuggestion,
|
||||
SimulationOutcome,
|
||||
TimeProfile,
|
||||
)
|
||||
from app.actuators.store import ActuatorStore
|
||||
@@ -35,11 +44,31 @@ _MAX_PATTERNS = 500
|
||||
_MAX_MODEL_SNAPSHOTS = 3
|
||||
_MAX_SNAPSHOT_PATTERNS = 120
|
||||
_MAX_EXECUTION_EVENTS = 100
|
||||
_MAX_DECISION_TRACES = 30
|
||||
_MAX_LATENCY_MEASUREMENTS = 50
|
||||
_MAX_FEEDBACK_LOG = 50
|
||||
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
|
||||
_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"})
|
||||
_LIGHT_TARGET_ATTRIBUTES = frozenset({
|
||||
"brightness",
|
||||
"color_temp",
|
||||
"color_temp_kelvin",
|
||||
"effect",
|
||||
"hs_color",
|
||||
"rgb_color",
|
||||
"xy_color",
|
||||
})
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -254,7 +283,11 @@ class BehaviorEngine:
|
||||
context_state_overrides: dict[str, str | None] | None = None,
|
||||
context_changed_at_overrides: dict[str, datetime | None] | 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:
|
||||
started_perf = perf_counter()
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if current_entities is None:
|
||||
@@ -319,10 +352,11 @@ class BehaviorEngine:
|
||||
record.behavior.patterns,
|
||||
current_context=current_context,
|
||||
current_context_changed_at=current_context_changed_at,
|
||||
context_weights=_context_weights_for(record),
|
||||
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,
|
||||
causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240),
|
||||
timezone_name=self._settings.timezone,
|
||||
)
|
||||
if prediction is not None:
|
||||
@@ -344,6 +378,7 @@ class BehaviorEngine:
|
||||
else:
|
||||
safety_allowed = False
|
||||
safety_blockers = ["Keine fällige Vorhersage."]
|
||||
decision_to_service_ms: int | None = None
|
||||
decision_factors = _decision_factors_for(record, current_context, prediction)
|
||||
behavior = record.behavior.model_copy(
|
||||
update={
|
||||
@@ -383,12 +418,51 @@ class BehaviorEngine:
|
||||
domain = actuator_entity_id.split(".", 1)[0]
|
||||
service = service_for_state(domain, prediction.target_state)
|
||||
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:
|
||||
service_started_perf = perf_counter()
|
||||
self._ha_reader.call_service(
|
||||
domain,
|
||||
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:
|
||||
logger.error(
|
||||
"Predicted action failed for %s: %s",
|
||||
@@ -400,7 +474,21 @@ class BehaviorEngine:
|
||||
"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(
|
||||
target_state=prediction.target_state,
|
||||
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)
|
||||
|
||||
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(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
*,
|
||||
correct: bool,
|
||||
expected_state: str | None = None,
|
||||
kind: FeedbackKind | None = None,
|
||||
) -> ActuatorRecord:
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -485,6 +698,7 @@ class BehaviorEngine:
|
||||
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
|
||||
correct_count = record.behavior.correct_feedback_count + 1
|
||||
incorrect_count = record.behavior.incorrect_feedback_count
|
||||
feedback_kind = kind or FeedbackKind.CORRECT
|
||||
else:
|
||||
target = prediction.target_state if prediction is not None else None
|
||||
if target:
|
||||
@@ -515,11 +729,24 @@ class BehaviorEngine:
|
||||
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
||||
correct_count = record.behavior.correct_feedback_count
|
||||
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
||||
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(
|
||||
record,
|
||||
current_context,
|
||||
correct=correct,
|
||||
)
|
||||
if correct and prediction is not None:
|
||||
safety = record.behavior.safety
|
||||
behavior = record.behavior.model_copy(
|
||||
update={
|
||||
"patterns": patterns[-_MAX_PATTERNS:],
|
||||
@@ -532,6 +759,11 @@ class BehaviorEngine:
|
||||
"last_trained_at": now,
|
||||
"correct_feedback_count": correct_count,
|
||||
"incorrect_feedback_count": incorrect_count,
|
||||
"feedback_log": [
|
||||
*record.behavior.feedback_log,
|
||||
feedback_kind,
|
||||
][-_MAX_FEEDBACK_LOG:],
|
||||
"safety": safety,
|
||||
"adaptive_weight_updates": [
|
||||
*record.behavior.adaptive_weight_updates,
|
||||
*adaptive_updates,
|
||||
@@ -557,6 +789,43 @@ class BehaviorEngine:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
@@ -859,7 +1128,7 @@ class BehaviorEngine:
|
||||
blockers.append(
|
||||
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.")
|
||||
if not self._cooldown_elapsed(
|
||||
record.behavior,
|
||||
@@ -902,12 +1171,16 @@ class BehaviorEngine:
|
||||
patterns.append(
|
||||
BehaviorPattern(
|
||||
target_state=point.state,
|
||||
target_attributes=_target_attributes_for(point),
|
||||
minute_of_day=local.hour * 60 + local.minute,
|
||||
weekday=local.weekday(),
|
||||
context_states=contexts,
|
||||
trigger_entity_id=trigger[0] if trigger else None,
|
||||
trigger_from_state=trigger[1] if trigger else None,
|
||||
trigger_to_state=trigger[2] if trigger else None,
|
||||
trigger_entity_id=trigger[1] if trigger else None,
|
||||
trigger_from_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,
|
||||
weight=weight,
|
||||
observed_at=point.timestamp,
|
||||
@@ -966,11 +1239,19 @@ class BehaviorEngine:
|
||||
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
||||
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
||||
"""
|
||||
event_received_at = datetime.now(timezone.utc)
|
||||
records = self._store.list()
|
||||
# Aktor direkt evaluieren
|
||||
for record in self._store.list():
|
||||
for record in records:
|
||||
if record.actuator_entity_id == entity_id:
|
||||
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:
|
||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
|
||||
return
|
||||
@@ -979,7 +1260,7 @@ class BehaviorEngine:
|
||||
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
|
||||
affected_actuators = [
|
||||
record.actuator_entity_id
|
||||
for record in self._store.list()
|
||||
for record in records
|
||||
if (
|
||||
record.assignment.selected_numeric_entity_id == entity_id
|
||||
or entity_id in record.assignment.selected_context_entity_ids
|
||||
@@ -992,6 +1273,9 @@ class BehaviorEngine:
|
||||
context_state_overrides={entity_id: event_state},
|
||||
context_changed_at_overrides={entity_id: event_changed_at},
|
||||
current_entities=current_entities,
|
||||
trigger_entity_id=entity_id,
|
||||
trigger_state=event_state,
|
||||
event_received_at=event_received_at,
|
||||
)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if target_state == "on" and profile.min_confidence_on is not None:
|
||||
return profile.min_confidence_on
|
||||
@@ -1031,15 +1517,21 @@ def _decision_factors_for(
|
||||
record: ActuatorRecord,
|
||||
current_context: dict[str, str | None],
|
||||
prediction: BehaviorPrediction | None,
|
||||
*,
|
||||
context_weights: dict[str, float] | None = None,
|
||||
) -> list[DecisionFactor]:
|
||||
factors: list[DecisionFactor] = []
|
||||
weights = context_weights or {}
|
||||
candidates = {
|
||||
candidate.entity_id: candidate
|
||||
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||
}
|
||||
for entity_id, state in current_context.items():
|
||||
candidate = candidates.get(entity_id)
|
||||
weight = candidate.effective_weight if candidate is not None else 1.0
|
||||
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
|
||||
contribution = round(min(1.0, weight * relevance), 4)
|
||||
factors.append(
|
||||
@@ -1075,6 +1567,62 @@ def _decision_factors_for(
|
||||
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(
|
||||
record: ActuatorRecord,
|
||||
sample_count: int,
|
||||
@@ -1408,6 +1956,7 @@ def predict_behavior(
|
||||
min_support: int,
|
||||
window_minutes: int,
|
||||
current_context_changed_at: dict[str, datetime | None] | None = None,
|
||||
context_weights: dict[str, float] | None = None,
|
||||
causal_window_seconds: int = 120,
|
||||
timezone_name: str = "Europe/Berlin",
|
||||
) -> BehaviorPrediction | None:
|
||||
@@ -1417,6 +1966,7 @@ def predict_behavior(
|
||||
minute_of_day = local.hour * 60 + local.minute
|
||||
changed_at = current_context_changed_at or {}
|
||||
by_state: dict[str, list[float]] = {}
|
||||
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
|
||||
causal_support_by_state: dict[str, int] = {}
|
||||
for pattern in patterns:
|
||||
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
||||
@@ -1430,7 +1980,11 @@ def predict_behavior(
|
||||
current_context.get(pattern.trigger_entity_id)
|
||||
== pattern.trigger_to_state
|
||||
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
|
||||
comparable = [
|
||||
@@ -1438,17 +1992,16 @@ def predict_behavior(
|
||||
for entity_id, expected in pattern.context_states.items()
|
||||
if entity_id in current_context
|
||||
]
|
||||
context_score = (
|
||||
sum(
|
||||
current_context[entity_id] == expected
|
||||
for entity_id, expected in comparable
|
||||
)
|
||||
/ len(comparable)
|
||||
if comparable
|
||||
else 0.5
|
||||
context_score = _weighted_context_score(
|
||||
comparable,
|
||||
current_context,
|
||||
context_weights or {},
|
||||
)
|
||||
score = pattern.weight * (0.85 + 0.15 * context_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.get(pattern.target_state, 0) + 1
|
||||
)
|
||||
@@ -1469,16 +2022,18 @@ def predict_behavior(
|
||||
for entity_id, expected in pattern.context_states.items()
|
||||
if entity_id in current_context
|
||||
]
|
||||
context_score = (
|
||||
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
|
||||
/ len(comparable)
|
||||
if comparable
|
||||
else 0.5
|
||||
context_score = _weighted_context_score(
|
||||
comparable,
|
||||
current_context,
|
||||
context_weights or {},
|
||||
)
|
||||
score = pattern.weight * (
|
||||
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_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:
|
||||
return None
|
||||
target_state, scores = max(
|
||||
@@ -1492,6 +2047,9 @@ def predict_behavior(
|
||||
return None
|
||||
return BehaviorPrediction(
|
||||
target_state=target_state,
|
||||
target_attributes=_aggregate_target_attributes(
|
||||
attributes_by_state.get(target_state, [])
|
||||
),
|
||||
confidence=round(confidence, 4),
|
||||
generated_at=now,
|
||||
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:
|
||||
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
|
||||
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
|
||||
if domain in {"button", "input_button"}:
|
||||
return "press"
|
||||
if domain == "scene":
|
||||
return "turn_on" if target_state == "on" else None
|
||||
if domain == "cover":
|
||||
@@ -1574,7 +2229,7 @@ def _recent_context_transition(
|
||||
history: dict[str, StateHistorySeries],
|
||||
context_ids: list[str],
|
||||
timestamp: datetime,
|
||||
) -> tuple[str, str, str] | None:
|
||||
) -> tuple[timedelta, str, str, str] | None:
|
||||
nearest: tuple[timedelta, str, str, str] | None = None
|
||||
for entity_id in context_ids:
|
||||
series = history.get(entity_id)
|
||||
@@ -1593,7 +2248,7 @@ def _recent_context_transition(
|
||||
previous_state = point.state
|
||||
if nearest is None:
|
||||
return None
|
||||
return nearest[1], nearest[2], nearest[3]
|
||||
return nearest
|
||||
|
||||
|
||||
def _circular_minute_distance(left: int, right: int) -> int:
|
||||
|
||||
@@ -78,7 +78,6 @@ class HaClient:
|
||||
"filter_entity_id": ",".join(entity_ids),
|
||||
"end_time": end_time.isoformat(),
|
||||
"minimal_response": "1",
|
||||
"no_attributes": "1",
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, list):
|
||||
|
||||
@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
|
||||
class StateHistoryPoint(BaseModel):
|
||||
timestamp: datetime
|
||||
state: str
|
||||
attributes: dict[str, object] = {}
|
||||
|
||||
|
||||
class StateHistorySeries(BaseModel):
|
||||
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
|
||||
timestamp = _parse_timestamp(
|
||||
raw_entry.get("last_changed") or raw_entry.get("last_updated")
|
||||
)
|
||||
if not points or points[-1].state != raw_state:
|
||||
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state))
|
||||
attributes = raw_entry.get("attributes")
|
||||
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:
|
||||
points.sort(key=lambda point: point.timestamp)
|
||||
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 == "":
|
||||
return None
|
||||
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}
|
||||
|
||||
52
app/main.py
52
app/main.py
@@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Next API",
|
||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||
version="1.6.0",
|
||||
version="1.7.4",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
@@ -255,15 +255,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||
auth_token = cast(str, settings.ha_token)
|
||||
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:
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connecting"
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
ping_interval=30,
|
||||
ping_timeout=30,
|
||||
) as websocket:
|
||||
async with websockets.connect(ws_url, ping_interval=None) as websocket:
|
||||
auth_required_msg = await websocket.recv()
|
||||
auth_required_data = json.loads(auth_required_msg)
|
||||
if auth_required_data.get("type") != "auth_required":
|
||||
@@ -287,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
|
||||
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
||||
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:
|
||||
ws_status.status = "connected"
|
||||
ws_status.error = None
|
||||
@@ -313,10 +315,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
entity_id = event_data.get("entity_id")
|
||||
if not entity_id:
|
||||
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")
|
||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||
if not _is_relevant_state_change(store, str(entity_id)):
|
||||
continue
|
||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||
await asyncio.to_thread(
|
||||
@@ -334,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
websockets.exceptions.InvalidStatus,
|
||||
OSError,
|
||||
) 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:
|
||||
ws_status.status = "reconnecting"
|
||||
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:
|
||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||
|
||||
|
||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||
@@ -359,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
||||
await asyncio.sleep(
|
||||
app.state.settings.prediction_interval_seconds
|
||||
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
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
@@ -395,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():
|
||||
if record.actuator_entity_id == entity_id:
|
||||
return True
|
||||
if record.assignment.selected_numeric_entity_id == entity_id:
|
||||
return True
|
||||
if entity_id in record.assignment.selected_context_entity_ids:
|
||||
return True
|
||||
return False
|
||||
result.add(record.actuator_entity_id)
|
||||
if record.assignment.selected_numeric_entity_id:
|
||||
result.add(record.assignment.selected_numeric_entity_id)
|
||||
result.update(record.assignment.selected_context_entity_ids)
|
||||
return result
|
||||
|
||||
|
||||
def _ha_entity_from_event(
|
||||
|
||||
@@ -281,12 +281,76 @@ let discoveryLoadPromise = null;
|
||||
let overviewLoadPromise = null;
|
||||
let systemLoadPromise = null;
|
||||
let currentSensorWeightGroups = [];
|
||||
let latestSimulationResults = new Map();
|
||||
let visibleActuatorLimit = 24;
|
||||
const ACTUATOR_RESULT_LIMIT = 50;
|
||||
const STATUS_TIMEOUT_MS = 2000;
|
||||
const DASHBOARD_TIMEOUT_MS = 3000;
|
||||
const I18N = {
|
||||
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",
|
||||
context_detected: "Kontext erkannt",
|
||||
active_approved: "aktiv freigegeben",
|
||||
shadow_prediction: "Prüfmodus mit Vorhersage",
|
||||
learning_blocked: "Lernen blockiert",
|
||||
collecting_actions: "sammelt Handlungen",
|
||||
},
|
||||
safety_stage: {
|
||||
observe: "Nur beobachten",
|
||||
suggest: "Vorschläge anzeigen",
|
||||
@@ -357,6 +421,69 @@ const I18N = {
|
||||
},
|
||||
},
|
||||
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",
|
||||
context_detected: "Context detected",
|
||||
active_approved: "actively approved",
|
||||
shadow_prediction: "Review mode with prediction",
|
||||
learning_blocked: "Learning blocked",
|
||||
collecting_actions: "collecting actions",
|
||||
},
|
||||
safety_stage: {
|
||||
observe: "Observe only",
|
||||
suggest: "Show suggestions",
|
||||
@@ -467,11 +594,15 @@ function showView(viewId) {
|
||||
function setLanguage(language) {
|
||||
uiLang = I18N[language] ? language : "de";
|
||||
localStorage.setItem("sillyhome.ui.language", uiLang);
|
||||
document.documentElement.lang = uiLang;
|
||||
applyStaticTranslations();
|
||||
syncSettingsView();
|
||||
renderActuatorSelect();
|
||||
if (cachedActuators) renderConfiguredActuators();
|
||||
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
|
||||
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
|
||||
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
|
||||
if (currentActuatorId) {
|
||||
cachedDetailHtml.delete(currentActuatorId);
|
||||
void showActuator(currentActuatorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,16 +612,95 @@ function syncSettingsView() {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 applyStaticTranslations() {
|
||||
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");
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "noch offen";
|
||||
if (!value) return ui("open");
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime())
|
||||
? String(value)
|
||||
: parsed.toLocaleString("de-DE");
|
||||
: parsed.toLocaleString(uiLang === "en" ? "en-US" : "de-DE");
|
||||
}
|
||||
|
||||
function uniqueValues(values) {
|
||||
@@ -531,7 +741,7 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
|
||||
function lifecycleLabel(record) {
|
||||
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||
if (behaviorStatus === "trained") return "Kontext erkannt";
|
||||
if (behaviorStatus === "trained") return ui("context_detected");
|
||||
const labels = {
|
||||
trained: translate("lifecycle_status", "trained"),
|
||||
pending_history: translate("lifecycle_status", "pending_history"),
|
||||
@@ -555,10 +765,10 @@ function statusClass(record) {
|
||||
function behaviorLabel(record) {
|
||||
const mode = record.behavior_mode || record.behavior?.mode;
|
||||
const status = record.behavior_status || record.behavior?.status;
|
||||
if (mode === "active") return "aktiv freigegeben";
|
||||
if (status === "trained") return "Prüfmodus mit Vorhersage";
|
||||
if (status === "blocked") return "Lernen blockiert";
|
||||
return "sammelt Handlungen";
|
||||
if (mode === "active") return ui("active_approved");
|
||||
if (status === "trained") return ui("shadow_prediction");
|
||||
if (status === "blocked") return ui("learning_blocked");
|
||||
return ui("collecting_actions");
|
||||
}
|
||||
|
||||
function predictionLabel(record) {
|
||||
@@ -566,11 +776,11 @@ function predictionLabel(record) {
|
||||
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
|
||||
return target
|
||||
? `${target} (${Math.round(confidence * 100)} %)`
|
||||
: "Keine fällige Aktion";
|
||||
: ui("no_prediction");
|
||||
}
|
||||
|
||||
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;
|
||||
return `${area} - ${name} (${entity.entity_id})`;
|
||||
}
|
||||
@@ -654,9 +864,9 @@ async function loadOverview() {
|
||||
async function doLoadOverview() {
|
||||
const startedAt = performance.now();
|
||||
const budget = document.getElementById("load-budget");
|
||||
if (budget) budget.textContent = "Startdaten laden ...";
|
||||
if (budget) budget.textContent = ui("loading_start");
|
||||
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 {
|
||||
const dashboard = await api("v1/actuators/dashboard/start");
|
||||
@@ -674,12 +884,12 @@ async function doLoadOverview() {
|
||||
}
|
||||
} catch (error) {
|
||||
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 {
|
||||
await loadSummaryData();
|
||||
renderConfiguredActuators();
|
||||
} 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();
|
||||
@@ -696,7 +906,7 @@ async function loadSystemOverview() {
|
||||
async function doLoadSystemOverview() {
|
||||
const startedAt = performance.now();
|
||||
const budget = document.getElementById("load-budget");
|
||||
if (budget) budget.textContent = "Systemübersicht lädt ...";
|
||||
if (budget) budget.textContent = ui("system_loading");
|
||||
try {
|
||||
const dashboard = await api("v1/actuators/dashboard/system");
|
||||
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
||||
@@ -712,7 +922,7 @@ async function doLoadSystemOverview() {
|
||||
scheduleDashboardExtras();
|
||||
} catch (error) {
|
||||
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
|
||||
if (budget) budget.textContent = "Systemübersicht verzögert";
|
||||
if (budget) budget.textContent = ui("system_delayed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,6 +1462,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
||||
</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
|
||||
? `<ul>${contexts.map(entityId => `
|
||||
<li>
|
||||
@@ -1510,6 +1756,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<h3>Sensor-Gewichtung</h3>
|
||||
${weightControls}
|
||||
${weightGroupControls}
|
||||
${simulationControls}
|
||||
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
||||
${currentContextControls}
|
||||
${manualAssignment}
|
||||
@@ -1610,6 +1857,98 @@ 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||
const selectedContextIds = Array.from(
|
||||
@@ -1820,9 +2159,11 @@ async function removeActuator(actuatorId) {
|
||||
}
|
||||
|
||||
async function startDashboard() {
|
||||
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
|
||||
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
|
||||
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
|
||||
document.documentElement.lang = uiLang;
|
||||
applyStaticTranslations();
|
||||
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();
|
||||
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
|
||||
? "observed"
|
||||
|
||||
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal 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
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "1.6.0"
|
||||
version = "1.7.4"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -87,7 +87,7 @@ def _service(
|
||||
|
||||
|
||||
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 = [
|
||||
HaEntitySummary(
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
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.actuators.cache_db import DashboardCache
|
||||
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.behavior.engine import BehaviorEngine
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
with TestClient(app) as client:
|
||||
_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
|
||||
|
||||
|
||||
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:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> 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(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -778,3 +853,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
|
||||
assert reader.service_calls == [
|
||||
("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
|
||||
|
||||
@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
|
||||
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:
|
||||
result = normalize_logbook_payload(
|
||||
[
|
||||
|
||||
@@ -94,8 +94,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
||||
|
||||
connect.assert_called_once_with(
|
||||
"ws://homeassistant:8123/api/websocket",
|
||||
ping_interval=30,
|
||||
ping_timeout=30,
|
||||
ping_interval=None,
|
||||
)
|
||||
assert fake_ws.sent == [
|
||||
{"type": "auth", "access_token": "test-token"},
|
||||
@@ -127,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> 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:
|
||||
app = FastAPI()
|
||||
app.state.settings = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user