Compare commits

...

3 Commits

Author SHA1 Message Date
8070a85b52 Add actuator simulation tuning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:06:47 +02:00
575211f0db Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 11:53:53 +02:00
d9dc186f9b Fix HA websocket keepalive regression
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 07:48:46 +02:00
14 changed files with 1190 additions and 32 deletions

View File

@@ -1,5 +1,22 @@
# Changelog
## 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

View File

@@ -31,6 +31,8 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
- 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

View File

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

View File

@@ -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
@@ -146,6 +154,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 +293,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 +352,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):

View File

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

View File

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

View File

@@ -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,6 +44,9 @@ _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)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
@@ -254,7 +266,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,6 +335,7 @@ 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,
@@ -344,6 +361,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 +401,47 @@ 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},
)
decision_to_service_ms = _elapsed_ms(service_started_perf)
except (HaClientError, ValueError) as exc:
logger.error(
"Predicted action failed for %s: %s",
@@ -400,7 +453,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 +501,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 +677,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 +708,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 +738,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 +768,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,
@@ -966,11 +1214,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 +1235,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 +1248,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 +1278,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 +1492,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 +1542,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 +1931,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:
@@ -1438,14 +1962,10 @@ 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)
@@ -1469,11 +1989,10 @@ 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
@@ -1506,6 +2025,25 @@ 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 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)

View File

@@ -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.1",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -259,11 +259,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(
ws_url,
ping_interval=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":
@@ -315,8 +311,6 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
continue
new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state)
if not _is_relevant_state_change(store, str(entity_id)):
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread(

View File

@@ -1252,6 +1252,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.</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 +1546,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<h3>Sensor-Gewichtung</h3>
${weightControls}
${weightGroupControls}
${simulationControls}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
@@ -1610,6 +1647,58 @@ 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,
}),
});
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>
`;
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
}
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from(

View File

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

View File

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

View File

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

View File

@@ -778,3 +778,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

View File

@@ -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"},