Compare commits

..

6 Commits

Author SHA1 Message Date
1b9db62294 Add simulation apply workflow
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 20:10:53 +02:00
5ca0c53f6a Reduce websocket reconnect load
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:17:50 +02:00
8070a85b52 Add actuator simulation tuning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:06:47 +02:00
575211f0db Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 11:53:53 +02:00
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
214b384b70 Release v1.6.0 dashboard architecture cleanup
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 01:02:29 +02:00
17 changed files with 1496 additions and 123 deletions

View File

@@ -1,5 +1,35 @@
# 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
materialisieren.
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
der Aktor-IDs.
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
Versions-Chip im Einrichtungsbereich.
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
anderen beobachteten Geraets.
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
## 1.5.4 - 2026-06-18
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.

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.5.4"
version: "1.7.3"
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

@@ -33,6 +33,38 @@ class DashboardCache:
"entities": [json.loads(row[1]) for row in rows],
}
def load_status(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
entity_count = connection.execute("select count(*) from ha_entities").fetchone()[0]
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entity_count": int(entity_count or 0),
}
def load_entity_map(self, entity_ids: set[str]) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
placeholders = ",".join("?" for _ in entity_ids)
with self._lock, self._connect() as connection:
rows = connection.execute(
f"select entity_id, payload from ha_entities where entity_id in ({placeholders})",
tuple(sorted(entity_ids)),
).fetchall()
result: dict[str, HaEntitySummary] = {}
for entity_id, payload in rows:
try:
result[str(entity_id)] = HaEntitySummary.model_validate(json.loads(payload))
except (TypeError, ValueError):
continue
return result
def save_entities_payload(
self,
*,

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):
@@ -326,13 +364,10 @@ def _dashboard_overview(
include_background: bool,
include_actuators: bool,
) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
cache_status = _load_entity_cache_status(request)
raw_updated_at = cache_status.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
raw_groups = cache_payload.get("discovery_groups", [])
raw_groups = cache_status.get("discovery_groups", [])
cached_groups = [
DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups
@@ -350,6 +385,9 @@ def _dashboard_overview(
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
anomaly_count = sum(record.anomaly_count for record in actuators)
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
entity_count = cache_status.get("entity_count")
if not isinstance(entity_count, int):
entity_count = 0
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -373,9 +411,9 @@ def _dashboard_overview(
critical_anomaly_count=critical_anomaly_count,
),
cache=EntityCacheStatus(
available=bool(raw_entities),
available=bool(entity_count),
updated_at=updated_at,
entity_count=len(raw_entities),
entity_count=entity_count,
),
actuators=actuators,
discovery_groups=cached_groups,
@@ -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):
@@ -846,6 +977,11 @@ def _load_cached_entity_map(
) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
cached_result = cache.load_entity_map(entity_ids)
if cached_result:
return cached_result
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
@@ -864,6 +1000,29 @@ def _load_cached_entity_map(
return result
def _load_entity_cache_status(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
status_payload = cache.load_status()
if status_payload.get("entity_count"):
return status_payload
path = _entity_cache_path(request)
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError):
return {}
if not isinstance(payload, dict):
return {}
raw_entities = payload.get("entities", [])
return {
"updated_at": payload.get("updated_at"),
"discovery_groups": payload.get("discovery_groups", []),
"entity_count": len(raw_entities) if isinstance(raw_entities, list) else 0,
}
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):

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.5.4",
version="1.7.3",
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(

View File

@@ -52,6 +52,8 @@
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; }
.toolbar button { margin-top:0; }
.detail-tools { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:8px; margin:10px 0; align-items:end; }
.detail-tools button { margin-top:0; }
details.collapsible > summary,
.manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
@@ -144,10 +146,9 @@
<div class="brand">
<div class="brand-row">
<span class="brand-mark">SH</span>
<h1>SillyHome Next</h1>
<h1>SillyHome</h1>
</div>
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
<p>Geräte, Lernen, Freigaben und Systemzustand.</p>
</div>
<div class="header-actions">
<label for="section-jump">Menü</label>
@@ -156,7 +157,6 @@
<option value="observed">Lernen</option>
<option value="choose">Discovery & Einrichtung</option>
<option value="settings">Einstellungen</option>
<option value="guide">Ablauf</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
@@ -165,11 +165,9 @@
<main>
<section class="control-panel app-view" id="choose">
<div class="panel-title">
<h2>Steuerung</h2>
<span class="chip">v1</span>
<h2>Discovery & Einrichtung</h2>
</div>
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
<label for="actuator-input">Entity-ID</label>
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
<datalist id="actuator-options"></datalist>
<div class="inline-controls">
@@ -199,16 +197,15 @@
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
</div>
</div>
<label for="actuator-select">Oder aus Liste wählen</label>
<label for="actuator-select">Geräteliste</label>
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
<option value="">Geräteliste bei Bedarf laden</option>
</select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<p id="actuator-config-result" class="muted">Bereit.</p>
<details class="manual-context">
<summary>Vorschläge anzeigen</summary>
<p class="muted">Vorschläge können Home Assistant stark abfragen und werden deshalb nicht beim Start geladen.</p>
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
</details>
<div id="actuator-suggestions" class="card-list"></div>
@@ -218,7 +215,6 @@
<div class="panel-title">
<div>
<h2>Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
</div>
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
</div>
@@ -226,8 +222,10 @@
</section>
<section class="detail-panel app-view" id="detail">
<h2>Lernfortschritt und Freigabe</h2>
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
<div class="panel-title">
<h2>Details</h2>
<button class="secondary compact" onclick="showView('observed')">Zurück</button>
</div>
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section>
@@ -235,7 +233,6 @@
<div class="panel-title">
<div>
<h2>System & Cache</h2>
<p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt erst bei Bedarf.</p>
</div>
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
</div>
@@ -259,40 +256,9 @@
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
<p class="muted">Die API speichert stabile technische Werte. Die Oberfläche übersetzt sie in die gewählte Sprache.</p>
</div>
<div>
<h3>Performance-Standard</h3>
<p>Startansichten dürfen maximal 3 Sekunden brauchen. Schwere Daten werden nur nach Menüwechsel oder bei Bearbeitung geladen.</p>
</div>
</div>
</section>
<section class="guide-panel app-view" id="guide">
<details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Links im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
</div>
</details>
</section>
</main>
<script>
const escapeHtml = value => String(value ?? "")
@@ -315,6 +281,7 @@ 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;
@@ -1071,6 +1038,20 @@ function selectActuatorFromList() {
if (value) document.getElementById("actuator-input").value = value;
}
function selectDetailActuator() {
const value = document.getElementById("detail-actuator-select")?.value;
if (value) void showActuator(value);
}
function detailActuatorOptions(selectedId) {
const rows = cachedActuators || [];
return rows.map(record => `
<option value="${escapeHtml(record.actuator_entity_id)}" ${record.actuator_entity_id === selectedId ? "selected" : ""}>
${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}
</option>
`).join("");
}
function renderManualContextSelect() {
const select = document.getElementById("manual-context-select");
if (!select) return;
@@ -1272,6 +1253,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>
@@ -1426,7 +1443,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const manualAssignment = `
<details class="manual-context">
<summary>Kontext selbst festlegen</summary>
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select">
<option value="">Keinen numerischen Hauptsensor verwenden</option>
@@ -1477,9 +1493,17 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<div class="detail-header">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
</div>
<button class="secondary compact" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Details aktualisieren</button>
</div>
<div class="detail-tools">
<button class="secondary" onclick="showView('observed')">Zurück zur Übersicht</button>
<div>
<label for="detail-actuator-select">Anderes Gerät</label>
<select id="detail-actuator-select" onchange="selectDetailActuator()">
${detailActuatorOptions(record.actuator_entity_id)}
</select>
</div>
<button class="secondary" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Aktualisieren</button>
</div>
<div class="grid-two">
<div>
@@ -1487,7 +1511,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div>
<div>
@@ -1497,11 +1520,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
<p><strong>Statusgrund:</strong> ${escapeHtml(record.behavior.reason)}</p>
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
<div class="actions">${activationButton}</div>
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
</div>
</div>
@@ -1518,15 +1540,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${adaptivePanel}
${anomalyPanel}
<h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Sensor-Gewichtung</h3>
<p class="muted">Automatische Relevanz kommt aus der Zuordnung. Die aktive Gewichtung kannst du korrigieren; Gruppen bündeln mehrere Sensoren/Zustände.</p>
${weightControls}
${weightGroupControls}
${simulationControls}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
@@ -1545,13 +1566,13 @@ function renderActuatorDetailShell(actuatorId) {
<div class="detail-header">
<div>
<h3>${escapeHtml(actuatorId)}</h3>
<p class="muted">Basisdaten werden geladen ...</p>
<p class="muted">Lädt ...</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
<div class="metric"><strong>Status</strong>...</div>
<div class="metric"><strong>Lernen</strong>...</div>
<div class="metric"><strong>Kontext</strong>...</div>
</div>
`;
}
@@ -1627,6 +1648,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(

View File

@@ -0,0 +1,36 @@
# SillyHome Next v1.6.0 Operating Guide
v1.6.0 trennt Startansicht, Aktoruebersicht, Discovery und Detaildaten staerker.
## API-Pfade
- `GET /v1/actuators/dashboard/system`
- nur System- und Cache-Metadaten
- keine Aktorenliste
- kein vollstaendiges Entity-Payload aus SQLite
- `GET /v1/actuators/dashboard/start`
- Aktor-Summaries
- Entity-Metadaten nur fuer konfigurierte Aktoren
- keine Discovery-Gruppen und keine Jobliste
- `GET /v1/actuators/discovery`
- steuerbare HA-Entities
- nutzt SQLite-Cache, liest HA nur bei Cache-Miss oder `refresh=true`
- `GET /v1/actuators/{id}/detail`
- genau ein ausgewaehlter Aktor
- kompakte Modell-/Kontextdaten
## Dashboard
- Frontend zeigt Daten an und loest gezielte Aktionen aus.
- Backend liefert schlanke View-Daten.
- Worker aktualisieren HA-Entity-/Discovery-Cache beim Start und danach
stündlich.
- Die Detailansicht gehoert zu einem Aktor und hat eigene Navigation:
Zurueck, anderes Geraet, Aktualisieren.
## Erwartete Wirkung
- Systemstart muss ohne Entity-Materialisierung reagieren.
- Lernen und Details laden nur ihren eigenen Datenkern.
- Discovery bleibt ein eigener Bedarfspfad.
- Texte im Dashboard sind kurz und handlungsnah.

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.5.3"
version = "1.7.3"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
dependencies = [

View File

@@ -1,18 +1,20 @@
from __future__ import annotations
from time import perf_counter
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
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
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities
from app.ha.history import (
@@ -271,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)
@@ -353,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)
@@ -467,6 +599,30 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
assert anomalies_response.json()
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
raise AssertionError("full entity payload must not be loaded")
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
system_response = client.get("/v1/actuators/dashboard/system")
start_response = client.get("/v1/actuators/dashboard/start")
assert system_response.status_code == 200
assert system_response.json()["actuators"] == []
assert system_response.json()["cache"]["entity_count"] == 4
assert start_response.status_code == 200
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_actuator_detail_uses_compact_payload(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

@@ -9,18 +9,18 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200
assert "SillyHome Next" in response.text
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
assert "So gehst du vor" in response.text
assert "Steuerung" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Oder aus Liste wählen" in response.text
assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
assert "So gehst du vor" not in response.text
assert "Discovery & Einrichtung" in response.text
assert "Entity-ID" in response.text
assert "Geräteliste" in response.text
assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text
assert "Vorschläge können Home Assistant stark abfragen" not in response.text
assert '<option value="detail">Details</option>' not in response.text
assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
assert "Wie gewohnt bedienen" not in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
assert "Freigabestatus" in response.text
assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text
@@ -29,10 +29,12 @@ def test_dashboard_is_served_at_root() -> None:
assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text
assert "Zurück zur Übersicht" in response.text
assert "Anderes Gerät" in response.text
assert "manual-context-freeform" in response.text
assert "Diese Kontext-Auswahl speichern" in response.text
assert "manual-context-select" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" not in response.text
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text
assert "record.behavior.activation_ready" in response.text
@@ -41,7 +43,7 @@ def test_dashboard_is_served_at_root() -> None:
assert 'api("v1/actuators")' not in response.text
assert 'api("v1/actuators/summary")' in response.text
assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' in response.text
assert 'details class="collapsible"' not in response.text
assert 'class="group-panel"' in response.text
assert "cachedDetailHtml" in response.text
assert "refreshOverviewInBackground" in response.text

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