Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 575211f0db | |||
| d9dc186f9b | |||
| 214b384b70 | |||
| 6323b93f23 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
|
/.actuator_store/
|
||||||
|
/MagicMock/
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# 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`.
|
||||||
|
- Dashboard behält bereits geladene System-, Lern- und Discovery-Daten beim
|
||||||
|
Wechseln der Ansichten und aktualisiert sie nur im Hintergrund.
|
||||||
|
- Details sind kein eigener Menüpunkt mehr, sondern gehören zum ausgewählten
|
||||||
|
Aktor aus der Lernübersicht. Bereits geöffnete Details bleiben sichtbar und
|
||||||
|
laden nur bei expliziter Aktualisierung neu.
|
||||||
|
|
||||||
## 1.2.0 - 2026-06-17
|
## 1.2.0 - 2026-06-17
|
||||||
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
|
||||||
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ nach einer ausdrücklichen Freigabe ausführen.
|
|||||||
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
|
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
|
||||||
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
|
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
|
||||||
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
|
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
|
||||||
|
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
|
||||||
|
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
|
||||||
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
|
||||||
|
|
||||||
## Reifegrad
|
## Reifegrad
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.5.3"
|
version: "1.7.0"
|
||||||
slug: sillyhome_next
|
slug: sillyhome_next
|
||||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||||
|
|||||||
@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
|
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
|
||||||
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
|
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000
|
||||||
--proxy-headers --forwarded-allow-ips='*'
|
|
||||||
|
|||||||
@@ -33,6 +33,38 @@ class DashboardCache:
|
|||||||
"entities": [json.loads(row[1]) for row in rows],
|
"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(
|
def save_entities_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ class JobStatus(StrEnum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackKind(StrEnum):
|
||||||
|
CORRECT = "correct"
|
||||||
|
WRONG = "wrong"
|
||||||
|
TOO_EARLY = "too_early"
|
||||||
|
TOO_LATE = "too_late"
|
||||||
|
NEVER_AUTOMATE = "never_automate"
|
||||||
|
|
||||||
|
|
||||||
class AssignmentCandidate(BaseModel):
|
class AssignmentCandidate(BaseModel):
|
||||||
entity_id: str
|
entity_id: str
|
||||||
domain: str
|
domain: str
|
||||||
@@ -146,6 +154,30 @@ class DecisionFactor(BaseModel):
|
|||||||
evidence: list[str] = Field(default_factory=list)
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionTrace(BaseModel):
|
||||||
|
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
trigger_entity_id: str | None = None
|
||||||
|
trigger_state: str | None = None
|
||||||
|
target_state: str | None = None
|
||||||
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
executed: bool = False
|
||||||
|
blocked: bool = False
|
||||||
|
reason: str = Field(default="", max_length=700)
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LatencyMeasurement(BaseModel):
|
||||||
|
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
trigger_entity_id: str | None = None
|
||||||
|
event_to_decision_ms: int | None = Field(default=None, ge=0)
|
||||||
|
decision_to_service_ms: int | None = Field(default=None, ge=0)
|
||||||
|
event_to_done_ms: int | None = Field(default=None, ge=0)
|
||||||
|
executed: bool = False
|
||||||
|
source: str = Field(default="manual", max_length=40)
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveWeightUpdate(BaseModel):
|
class AdaptiveWeightUpdate(BaseModel):
|
||||||
entity_id: str
|
entity_id: str
|
||||||
previous_weight: float = Field(ge=0.0, le=1.0)
|
previous_weight: float = Field(ge=0.0, le=1.0)
|
||||||
@@ -248,6 +280,32 @@ class RelatedAutomation(BaseModel):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ActuatorGroup(BaseModel):
|
||||||
|
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
area_name: str | None = Field(default=None, max_length=120)
|
||||||
|
member_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
reason: str = Field(default="", max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneSuggestion(BaseModel):
|
||||||
|
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
label: str = Field(min_length=1, max_length=120)
|
||||||
|
member_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
reason: str = Field(default="", max_length=500)
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentInsight(BaseModel):
|
||||||
|
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
severity: str = Field(default="info", max_length=20)
|
||||||
|
title: str = Field(min_length=1, max_length=160)
|
||||||
|
detail: str = Field(min_length=1, max_length=700)
|
||||||
|
action: str | None = Field(default=None, max_length=300)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class BehaviorState(BaseModel):
|
class BehaviorState(BaseModel):
|
||||||
mode: BehaviorMode = BehaviorMode.SHADOW
|
mode: BehaviorMode = BehaviorMode.SHADOW
|
||||||
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
status: BehaviorStatus = BehaviorStatus.COLLECTING
|
||||||
@@ -281,6 +339,16 @@ class BehaviorState(BaseModel):
|
|||||||
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
|
||||||
time_profiles: list[TimeProfile] = Field(default_factory=list)
|
time_profiles: list[TimeProfile] = Field(default_factory=list)
|
||||||
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
anomalies: list[AnomalyEvent] = Field(default_factory=list)
|
||||||
|
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
|
||||||
|
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
|
||||||
|
feedback_log: list[FeedbackKind] = Field(default_factory=list)
|
||||||
|
dry_run_enabled: bool = False
|
||||||
|
dry_run_started_at: datetime | None = None
|
||||||
|
dry_run_sample_count: int = Field(default=0, ge=0)
|
||||||
|
dry_run_hit_count: int = Field(default=0, ge=0)
|
||||||
|
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
|
||||||
|
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
|
||||||
|
agent_insights: list[AgentInsight] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ActuatorRecord(BaseModel):
|
class ActuatorRecord(BaseModel):
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class ActuatorStore:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("Ungültiger Job-Queue-Status.") from exc
|
raise ValueError("Ungültiger Job-Queue-Status.") from exc
|
||||||
|
|
||||||
|
def save_job_queue(self, queue: JobQueueState) -> JobQueueState:
|
||||||
|
with self._lock:
|
||||||
|
self._persist_job_queue(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
def start_job(
|
def start_job(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.actuators.cache_db import DashboardCache
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup
|
from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup
|
||||||
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
@@ -55,6 +55,29 @@ class WeightOverrideRequest(BaseModel):
|
|||||||
class FeedbackRequest(BaseModel):
|
class FeedbackRequest(BaseModel):
|
||||||
correct: bool
|
correct: bool
|
||||||
expected_state: str | None = Field(default=None, max_length=100)
|
expected_state: str | None = Field(default=None, max_length=100)
|
||||||
|
kind: FeedbackKind | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPayload(BaseModel):
|
||||||
|
exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
records: list[ActuatorRecord] = Field(default_factory=list)
|
||||||
|
reconciliation: ReconciliationState = Field(default_factory=ReconciliationState)
|
||||||
|
jobs: JobQueueState = Field(default_factory=JobQueueState)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
backup: BackupPayload
|
||||||
|
replace_existing: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreResult(BaseModel):
|
||||||
|
restored_records: int = 0
|
||||||
|
skipped_existing: int = 0
|
||||||
|
restored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class SafetyProfileRequest(BaseModel):
|
class SafetyProfileRequest(BaseModel):
|
||||||
@@ -326,13 +349,10 @@ def _dashboard_overview(
|
|||||||
include_background: bool,
|
include_background: bool,
|
||||||
include_actuators: bool,
|
include_actuators: bool,
|
||||||
) -> DashboardOverview:
|
) -> DashboardOverview:
|
||||||
cache_payload = _load_entity_cache_payload(request)
|
cache_status = _load_entity_cache_status(request)
|
||||||
raw_entities = cache_payload.get("entities", [])
|
raw_updated_at = cache_status.get("updated_at")
|
||||||
if not isinstance(raw_entities, list):
|
|
||||||
raw_entities = []
|
|
||||||
raw_updated_at = cache_payload.get("updated_at")
|
|
||||||
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
|
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 = [
|
cached_groups = [
|
||||||
DashboardDiscoveryGroup.model_validate(group)
|
DashboardDiscoveryGroup.model_validate(group)
|
||||||
for group in raw_groups
|
for group in raw_groups
|
||||||
@@ -350,6 +370,9 @@ def _dashboard_overview(
|
|||||||
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
|
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
|
||||||
anomaly_count = sum(record.anomaly_count for record in actuators)
|
anomaly_count = sum(record.anomaly_count for record in actuators)
|
||||||
critical_anomaly_count = sum(record.critical_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(
|
return DashboardOverview(
|
||||||
system=DashboardSystemStatus(
|
system=DashboardSystemStatus(
|
||||||
websocket_status=getattr(ws_status, "status", "unavailable"),
|
websocket_status=getattr(ws_status, "status", "unavailable"),
|
||||||
@@ -373,9 +396,9 @@ def _dashboard_overview(
|
|||||||
critical_anomaly_count=critical_anomaly_count,
|
critical_anomaly_count=critical_anomaly_count,
|
||||||
),
|
),
|
||||||
cache=EntityCacheStatus(
|
cache=EntityCacheStatus(
|
||||||
available=bool(raw_entities),
|
available=bool(entity_count),
|
||||||
updated_at=updated_at,
|
updated_at=updated_at,
|
||||||
entity_count=len(raw_entities),
|
entity_count=entity_count,
|
||||||
),
|
),
|
||||||
actuators=actuators,
|
actuators=actuators,
|
||||||
discovery_groups=cached_groups,
|
discovery_groups=cached_groups,
|
||||||
@@ -406,6 +429,48 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
|
|||||||
return overview
|
return overview
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backup/export", response_model=BackupPayload)
|
||||||
|
def export_backup(request: Request) -> BackupPayload:
|
||||||
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
|
if not isinstance(store, ActuatorStore):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Actuator Store nicht initialisiert.",
|
||||||
|
)
|
||||||
|
return BackupPayload(
|
||||||
|
records=store.list(),
|
||||||
|
reconciliation=store.load_reconciliation_state(),
|
||||||
|
jobs=store.load_job_queue(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backup/restore", response_model=RestoreResult)
|
||||||
|
def restore_backup(payload: RestoreRequest, request: Request) -> RestoreResult:
|
||||||
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
|
if not isinstance(store, ActuatorStore):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Actuator Store nicht initialisiert.",
|
||||||
|
)
|
||||||
|
existing_ids = {record.actuator_entity_id for record in store.list()}
|
||||||
|
restored = 0
|
||||||
|
skipped = 0
|
||||||
|
for record in payload.backup.records:
|
||||||
|
if record.actuator_entity_id in existing_ids and not payload.replace_existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
store.upsert(record)
|
||||||
|
restored += 1
|
||||||
|
store.save_reconciliation_state(payload.backup.reconciliation)
|
||||||
|
store.save_job_queue(payload.backup.jobs)
|
||||||
|
return RestoreResult(restored_records=restored, skipped_existing=skipped)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/planning/refresh", response_model=list[ActuatorRecord])
|
||||||
|
def refresh_planning_insights(request: Request) -> list[ActuatorRecord]:
|
||||||
|
return _behavior(request).refresh_planning_insights()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ActuatorRecord])
|
@router.get("", response_model=list[ActuatorRecord])
|
||||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||||
return _service(request).list_configured()
|
return _service(request).list_configured()
|
||||||
@@ -513,11 +578,24 @@ def record_feedback(
|
|||||||
actuator_entity_id,
|
actuator_entity_id,
|
||||||
correct=payload.correct,
|
correct=payload.correct,
|
||||||
expected_state=payload.expected_state,
|
expected_state=payload.expected_state,
|
||||||
|
kind=payload.kind,
|
||||||
)
|
)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/dry-run", response_model=ActuatorRecord)
|
||||||
|
def set_dry_run(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: DryRunRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
return _behavior(request).set_dry_run(actuator_entity_id, enabled=payload.enabled)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
|
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
|
||||||
def set_safety_profile(
|
def set_safety_profile(
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -846,6 +924,11 @@ def _load_cached_entity_map(
|
|||||||
) -> dict[str, HaEntitySummary]:
|
) -> dict[str, HaEntitySummary]:
|
||||||
if not entity_ids:
|
if not entity_ids:
|
||||||
return {}
|
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)
|
payload = _load_entity_cache_payload(request)
|
||||||
raw_entities = payload.get("entities", [])
|
raw_entities = payload.get("entities", [])
|
||||||
if not isinstance(raw_entities, list):
|
if not isinstance(raw_entities, list):
|
||||||
@@ -864,6 +947,29 @@ def _load_cached_entity_map(
|
|||||||
return result
|
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]:
|
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
|
||||||
cache = getattr(request.app.state, "dashboard_cache", None)
|
cache = getattr(request.app.state, "dashboard_cache", None)
|
||||||
if isinstance(cache, DashboardCache):
|
if isinstance(cache, DashboardCache):
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from time import perf_counter
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from app.actuators.models import (
|
from app.actuators.models import (
|
||||||
ActuatorRecord,
|
ActuatorRecord,
|
||||||
AdaptiveWeightUpdate,
|
AdaptiveWeightUpdate,
|
||||||
|
AgentInsight,
|
||||||
AnomalyEvent,
|
AnomalyEvent,
|
||||||
|
ActuatorGroup,
|
||||||
AutomationConflict,
|
AutomationConflict,
|
||||||
BehaviorMode,
|
BehaviorMode,
|
||||||
BehaviorPattern,
|
BehaviorPattern,
|
||||||
@@ -16,12 +19,16 @@ from app.actuators.models import (
|
|||||||
BehaviorState,
|
BehaviorState,
|
||||||
BehaviorStatus,
|
BehaviorStatus,
|
||||||
DecisionFactor,
|
DecisionFactor,
|
||||||
|
DecisionTrace,
|
||||||
ExecutionEvent,
|
ExecutionEvent,
|
||||||
|
FeedbackKind,
|
||||||
|
LatencyMeasurement,
|
||||||
ManualOverride,
|
ManualOverride,
|
||||||
ModelSnapshot,
|
ModelSnapshot,
|
||||||
RelatedAutomation,
|
RelatedAutomation,
|
||||||
SafetyProfile,
|
SafetyProfile,
|
||||||
SafetyStage,
|
SafetyStage,
|
||||||
|
SceneSuggestion,
|
||||||
TimeProfile,
|
TimeProfile,
|
||||||
)
|
)
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
@@ -35,6 +42,9 @@ _MAX_PATTERNS = 500
|
|||||||
_MAX_MODEL_SNAPSHOTS = 3
|
_MAX_MODEL_SNAPSHOTS = 3
|
||||||
_MAX_SNAPSHOT_PATTERNS = 120
|
_MAX_SNAPSHOT_PATTERNS = 120
|
||||||
_MAX_EXECUTION_EVENTS = 100
|
_MAX_EXECUTION_EVENTS = 100
|
||||||
|
_MAX_DECISION_TRACES = 30
|
||||||
|
_MAX_LATENCY_MEASUREMENTS = 50
|
||||||
|
_MAX_FEEDBACK_LOG = 50
|
||||||
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
||||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
||||||
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
||||||
@@ -254,7 +264,11 @@ class BehaviorEngine:
|
|||||||
context_state_overrides: dict[str, str | None] | None = None,
|
context_state_overrides: dict[str, str | None] | None = None,
|
||||||
context_changed_at_overrides: dict[str, datetime | None] | None = None,
|
context_changed_at_overrides: dict[str, datetime | None] | None = None,
|
||||||
current_entities: Sequence[HaEntitySummary] | None = None,
|
current_entities: Sequence[HaEntitySummary] | None = None,
|
||||||
|
trigger_entity_id: str | None = None,
|
||||||
|
trigger_state: str | None = None,
|
||||||
|
event_received_at: datetime | None = None,
|
||||||
) -> ActuatorRecord:
|
) -> ActuatorRecord:
|
||||||
|
started_perf = perf_counter()
|
||||||
record = self._store.get(actuator_entity_id)
|
record = self._store.get(actuator_entity_id)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if current_entities is None:
|
if current_entities is None:
|
||||||
@@ -344,6 +358,7 @@ class BehaviorEngine:
|
|||||||
else:
|
else:
|
||||||
safety_allowed = False
|
safety_allowed = False
|
||||||
safety_blockers = ["Keine fällige Vorhersage."]
|
safety_blockers = ["Keine fällige Vorhersage."]
|
||||||
|
decision_to_service_ms: int | None = None
|
||||||
decision_factors = _decision_factors_for(record, current_context, prediction)
|
decision_factors = _decision_factors_for(record, current_context, prediction)
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={
|
update={
|
||||||
@@ -383,12 +398,47 @@ class BehaviorEngine:
|
|||||||
domain = actuator_entity_id.split(".", 1)[0]
|
domain = actuator_entity_id.split(".", 1)[0]
|
||||||
service = service_for_state(domain, prediction.target_state)
|
service = service_for_state(domain, prediction.target_state)
|
||||||
if service is not None:
|
if service is not None:
|
||||||
|
if record.behavior.dry_run_enabled:
|
||||||
|
behavior = behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"prediction": prediction.model_copy(
|
||||||
|
update={
|
||||||
|
"executed": False,
|
||||||
|
"execution_reason": (
|
||||||
|
"Dry-run: Aktion wäre ausgeführt worden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"dry_run_sample_count": record.behavior.dry_run_sample_count + 1,
|
||||||
|
"reason": (
|
||||||
|
f"Dry-run hätte {prediction.target_state!r} mit "
|
||||||
|
f"{prediction.confidence:.0%} Sicherheit ausgeführt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._save_behavior(
|
||||||
|
record,
|
||||||
|
_append_decision_trace(
|
||||||
|
behavior,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
prediction=prediction,
|
||||||
|
safety_blockers=safety_blockers,
|
||||||
|
duration_ms=_elapsed_ms(started_perf),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
decision_to_service_ms=None,
|
||||||
|
executed=False,
|
||||||
|
source="event" if event_received_at is not None else "manual",
|
||||||
|
),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
service_started_perf = perf_counter()
|
||||||
self._ha_reader.call_service(
|
self._ha_reader.call_service(
|
||||||
domain,
|
domain,
|
||||||
service,
|
service,
|
||||||
{"entity_id": actuator_entity_id},
|
{"entity_id": actuator_entity_id},
|
||||||
)
|
)
|
||||||
|
decision_to_service_ms = _elapsed_ms(service_started_perf)
|
||||||
except (HaClientError, ValueError) as exc:
|
except (HaClientError, ValueError) as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Predicted action failed for %s: %s",
|
"Predicted action failed for %s: %s",
|
||||||
@@ -400,7 +450,21 @@ class BehaviorEngine:
|
|||||||
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
|
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(
|
||||||
|
record,
|
||||||
|
_append_decision_trace(
|
||||||
|
behavior,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
prediction=prediction,
|
||||||
|
safety_blockers=[str(exc)],
|
||||||
|
duration_ms=_elapsed_ms(started_perf),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
decision_to_service_ms=None,
|
||||||
|
executed=False,
|
||||||
|
source="event" if event_received_at is not None else "manual",
|
||||||
|
),
|
||||||
|
)
|
||||||
event = ExecutionEvent(
|
event = ExecutionEvent(
|
||||||
target_state=prediction.target_state,
|
target_state=prediction.target_state,
|
||||||
executed_at=now,
|
executed_at=now,
|
||||||
@@ -434,6 +498,20 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
behavior = _append_decision_trace(
|
||||||
|
behavior,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
prediction=prediction,
|
||||||
|
safety_blockers=safety_blockers,
|
||||||
|
duration_ms=_elapsed_ms(started_perf),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
decision_to_service_ms=(
|
||||||
|
decision_to_service_ms
|
||||||
|
),
|
||||||
|
executed=bool(prediction is not None and behavior.prediction is not None and behavior.prediction.executed),
|
||||||
|
source="event" if event_received_at is not None else "manual",
|
||||||
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(record, behavior)
|
||||||
|
|
||||||
def record_feedback(
|
def record_feedback(
|
||||||
@@ -442,6 +520,7 @@ class BehaviorEngine:
|
|||||||
*,
|
*,
|
||||||
correct: bool,
|
correct: bool,
|
||||||
expected_state: str | None = None,
|
expected_state: str | None = None,
|
||||||
|
kind: FeedbackKind | None = None,
|
||||||
) -> ActuatorRecord:
|
) -> ActuatorRecord:
|
||||||
record = self._store.get(actuator_entity_id)
|
record = self._store.get(actuator_entity_id)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -485,6 +564,7 @@ class BehaviorEngine:
|
|||||||
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
|
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
|
||||||
correct_count = record.behavior.correct_feedback_count + 1
|
correct_count = record.behavior.correct_feedback_count + 1
|
||||||
incorrect_count = record.behavior.incorrect_feedback_count
|
incorrect_count = record.behavior.incorrect_feedback_count
|
||||||
|
feedback_kind = kind or FeedbackKind.CORRECT
|
||||||
else:
|
else:
|
||||||
target = prediction.target_state if prediction is not None else None
|
target = prediction.target_state if prediction is not None else None
|
||||||
if target:
|
if target:
|
||||||
@@ -515,11 +595,24 @@ class BehaviorEngine:
|
|||||||
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
|
||||||
correct_count = record.behavior.correct_feedback_count
|
correct_count = record.behavior.correct_feedback_count
|
||||||
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
incorrect_count = record.behavior.incorrect_feedback_count + 1
|
||||||
|
feedback_kind = kind or FeedbackKind.WRONG
|
||||||
|
if feedback_kind is FeedbackKind.NEVER_AUTOMATE:
|
||||||
|
safety = record.behavior.safety.model_copy(
|
||||||
|
update={
|
||||||
|
"manual_block": True,
|
||||||
|
"updated_at": now,
|
||||||
|
"note": "Durch Nutzerfeedback dauerhaft blockiert.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
safety = record.behavior.safety
|
||||||
adaptive_updates, manual_override = _adapt_sensor_weights(
|
adaptive_updates, manual_override = _adapt_sensor_weights(
|
||||||
record,
|
record,
|
||||||
current_context,
|
current_context,
|
||||||
correct=correct,
|
correct=correct,
|
||||||
)
|
)
|
||||||
|
if correct and prediction is not None:
|
||||||
|
safety = record.behavior.safety
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
update={
|
update={
|
||||||
"patterns": patterns[-_MAX_PATTERNS:],
|
"patterns": patterns[-_MAX_PATTERNS:],
|
||||||
@@ -532,6 +625,11 @@ class BehaviorEngine:
|
|||||||
"last_trained_at": now,
|
"last_trained_at": now,
|
||||||
"correct_feedback_count": correct_count,
|
"correct_feedback_count": correct_count,
|
||||||
"incorrect_feedback_count": incorrect_count,
|
"incorrect_feedback_count": incorrect_count,
|
||||||
|
"feedback_log": [
|
||||||
|
*record.behavior.feedback_log,
|
||||||
|
feedback_kind,
|
||||||
|
][-_MAX_FEEDBACK_LOG:],
|
||||||
|
"safety": safety,
|
||||||
"adaptive_weight_updates": [
|
"adaptive_weight_updates": [
|
||||||
*record.behavior.adaptive_weight_updates,
|
*record.behavior.adaptive_weight_updates,
|
||||||
*adaptive_updates,
|
*adaptive_updates,
|
||||||
@@ -557,6 +655,43 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
return self._save_behavior(record_for_save, behavior)
|
return self._save_behavior(record_for_save, behavior)
|
||||||
|
|
||||||
|
def set_dry_run(self, actuator_entity_id: str, *, enabled: bool) -> ActuatorRecord:
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
behavior = record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"dry_run_enabled": enabled,
|
||||||
|
"dry_run_started_at": now if enabled else record.behavior.dry_run_started_at,
|
||||||
|
"reason": (
|
||||||
|
"Dry-run aktiv; freigegebene Aktionen werden protokolliert, aber nicht geschaltet."
|
||||||
|
if enabled
|
||||||
|
else "Dry-run beendet."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._save_behavior(record, behavior)
|
||||||
|
|
||||||
|
def refresh_planning_insights(self) -> list[ActuatorRecord]:
|
||||||
|
records = self._store.list()
|
||||||
|
groups = _derive_actuator_groups(records)
|
||||||
|
scenes = _derive_scene_suggestions(records)
|
||||||
|
insights_by_actuator = _derive_agent_insights(records)
|
||||||
|
updated: list[ActuatorRecord] = []
|
||||||
|
for record in records:
|
||||||
|
behavior = record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"actuator_groups": [
|
||||||
|
group for group in groups if record.actuator_entity_id in group.member_entity_ids
|
||||||
|
],
|
||||||
|
"scene_suggestions": [
|
||||||
|
scene for scene in scenes if record.actuator_entity_id in scene.member_entity_ids
|
||||||
|
],
|
||||||
|
"agent_insights": insights_by_actuator.get(record.actuator_entity_id, []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
updated.append(self._save_behavior(record, behavior))
|
||||||
|
return updated
|
||||||
|
|
||||||
def rollback_model(
|
def rollback_model(
|
||||||
self,
|
self,
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -966,11 +1101,19 @@ class BehaviorEngine:
|
|||||||
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
||||||
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
||||||
"""
|
"""
|
||||||
|
event_received_at = datetime.now(timezone.utc)
|
||||||
|
records = self._store.list()
|
||||||
# Aktor direkt evaluieren
|
# Aktor direkt evaluieren
|
||||||
for record in self._store.list():
|
for record in records:
|
||||||
if record.actuator_entity_id == entity_id:
|
if record.actuator_entity_id == entity_id:
|
||||||
try:
|
try:
|
||||||
self.evaluate(record.actuator_entity_id, current_entities=current_entities)
|
self.evaluate(
|
||||||
|
record.actuator_entity_id,
|
||||||
|
current_entities=current_entities,
|
||||||
|
trigger_entity_id=entity_id,
|
||||||
|
trigger_state=_event_state(new_state),
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
|
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
|
||||||
return
|
return
|
||||||
@@ -979,7 +1122,7 @@ class BehaviorEngine:
|
|||||||
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
|
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
|
||||||
affected_actuators = [
|
affected_actuators = [
|
||||||
record.actuator_entity_id
|
record.actuator_entity_id
|
||||||
for record in self._store.list()
|
for record in records
|
||||||
if (
|
if (
|
||||||
record.assignment.selected_numeric_entity_id == entity_id
|
record.assignment.selected_numeric_entity_id == entity_id
|
||||||
or entity_id in record.assignment.selected_context_entity_ids
|
or entity_id in record.assignment.selected_context_entity_ids
|
||||||
@@ -992,6 +1135,9 @@ class BehaviorEngine:
|
|||||||
context_state_overrides={entity_id: event_state},
|
context_state_overrides={entity_id: event_state},
|
||||||
context_changed_at_overrides={entity_id: event_changed_at},
|
context_changed_at_overrides={entity_id: event_changed_at},
|
||||||
current_entities=current_entities,
|
current_entities=current_entities,
|
||||||
|
trigger_entity_id=entity_id,
|
||||||
|
trigger_state=event_state,
|
||||||
|
event_received_at=event_received_at,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
|
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
|
||||||
@@ -1019,6 +1165,208 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_ms(started_perf: float) -> int:
|
||||||
|
return max(0, int((perf_counter() - started_perf) * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def _append_decision_trace(
|
||||||
|
behavior: BehaviorState,
|
||||||
|
*,
|
||||||
|
trigger_entity_id: str | None,
|
||||||
|
trigger_state: str | None,
|
||||||
|
prediction: BehaviorPrediction | None,
|
||||||
|
safety_blockers: list[str],
|
||||||
|
duration_ms: int,
|
||||||
|
event_received_at: datetime | None,
|
||||||
|
decision_to_service_ms: int | None,
|
||||||
|
executed: bool,
|
||||||
|
source: str,
|
||||||
|
) -> BehaviorState:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
blocked = prediction is None or bool(safety_blockers)
|
||||||
|
trace = DecisionTrace(
|
||||||
|
trace_id=f"{now.strftime('%Y%m%d%H%M%S%f')}.{trigger_entity_id or 'manual'}",
|
||||||
|
created_at=now,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
trigger_state=trigger_state,
|
||||||
|
target_state=prediction.target_state if prediction is not None else None,
|
||||||
|
confidence=prediction.confidence if prediction is not None else None,
|
||||||
|
executed=executed,
|
||||||
|
blocked=blocked,
|
||||||
|
reason=(
|
||||||
|
prediction.execution_reason
|
||||||
|
if prediction is not None
|
||||||
|
else behavior.reason
|
||||||
|
),
|
||||||
|
blockers=safety_blockers if prediction is not None else ["Keine fällige Vorhersage."],
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
updated = behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"decision_timeline": [
|
||||||
|
*behavior.decision_timeline,
|
||||||
|
trace,
|
||||||
|
][-_MAX_DECISION_TRACES:],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if event_received_at is None:
|
||||||
|
return updated
|
||||||
|
return _append_latency_measurement(
|
||||||
|
updated,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
event_received_at=event_received_at,
|
||||||
|
event_to_decision_ms=duration_ms,
|
||||||
|
decision_to_service_ms=decision_to_service_ms,
|
||||||
|
executed=executed,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_latency_measurement(
|
||||||
|
behavior: BehaviorState,
|
||||||
|
*,
|
||||||
|
trigger_entity_id: str | None,
|
||||||
|
event_received_at: datetime | None,
|
||||||
|
event_to_decision_ms: int | None,
|
||||||
|
decision_to_service_ms: int | None,
|
||||||
|
executed: bool,
|
||||||
|
source: str,
|
||||||
|
) -> BehaviorState:
|
||||||
|
if event_received_at is None:
|
||||||
|
return behavior
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event_to_done_ms = max(0, int((now - event_received_at).total_seconds() * 1000))
|
||||||
|
measurement = LatencyMeasurement(
|
||||||
|
measured_at=now,
|
||||||
|
trigger_entity_id=trigger_entity_id,
|
||||||
|
event_to_decision_ms=event_to_decision_ms,
|
||||||
|
decision_to_service_ms=decision_to_service_ms,
|
||||||
|
event_to_done_ms=event_to_done_ms,
|
||||||
|
executed=executed,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
return behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"latency_measurements": [
|
||||||
|
*behavior.latency_measurements,
|
||||||
|
measurement,
|
||||||
|
][-_MAX_LATENCY_MEASUREMENTS:],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_actuator_groups(records: list[ActuatorRecord]) -> list[ActuatorGroup]:
|
||||||
|
by_area: dict[str, list[str]] = {}
|
||||||
|
for record in records:
|
||||||
|
area = _area_hint(record)
|
||||||
|
if area:
|
||||||
|
by_area.setdefault(area, []).append(record.actuator_entity_id)
|
||||||
|
return [
|
||||||
|
ActuatorGroup(
|
||||||
|
group_id=_slug(f"area_{area}"),
|
||||||
|
name=f"Raum {area}",
|
||||||
|
area_name=area,
|
||||||
|
member_entity_ids=sorted(entity_ids),
|
||||||
|
reason="Aktor-Gruppe aus gemeinsamer Raum-/Kontextzuordnung abgeleitet.",
|
||||||
|
)
|
||||||
|
for area, entity_ids in sorted(by_area.items())
|
||||||
|
if len(entity_ids) >= 2
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_scene_suggestions(records: list[ActuatorRecord]) -> list[SceneSuggestion]:
|
||||||
|
scenes: list[SceneSuggestion] = []
|
||||||
|
by_context: dict[tuple[str, str], list[str]] = {}
|
||||||
|
for record in records:
|
||||||
|
for pattern in record.behavior.patterns:
|
||||||
|
for entity_id, state in pattern.context_states.items():
|
||||||
|
by_context.setdefault((entity_id, state), []).append(record.actuator_entity_id)
|
||||||
|
for (entity_id, state), members in sorted(by_context.items()):
|
||||||
|
unique_members = sorted(set(members))
|
||||||
|
if len(unique_members) < 2:
|
||||||
|
continue
|
||||||
|
scenes.append(
|
||||||
|
SceneSuggestion(
|
||||||
|
scene_id=_slug(f"{entity_id}_{state}"),
|
||||||
|
label=f"{entity_id} ist {state}",
|
||||||
|
member_entity_ids=unique_members,
|
||||||
|
confidence=min(1.0, len(members) / max(3, len(unique_members) * 2)),
|
||||||
|
reason="Mehrere Aktoren reagieren historisch auf denselben Kontext.",
|
||||||
|
last_seen_at=max(
|
||||||
|
(
|
||||||
|
pattern.observed_at
|
||||||
|
for record in records
|
||||||
|
for pattern in record.behavior.patterns
|
||||||
|
if pattern.context_states.get(entity_id) == state
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return scenes[-20:]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_agent_insights(records: list[ActuatorRecord]) -> dict[str, list[AgentInsight]]:
|
||||||
|
result: dict[str, list[AgentInsight]] = {}
|
||||||
|
for record in records:
|
||||||
|
insights: list[AgentInsight] = []
|
||||||
|
if record.behavior.automation_conflicts:
|
||||||
|
insights.append(
|
||||||
|
AgentInsight(
|
||||||
|
insight_id=f"{record.actuator_entity_id}.automation_conflict",
|
||||||
|
severity="warning",
|
||||||
|
title="Automation-Konflikt prüfen",
|
||||||
|
detail="Eine passende HA-Automation kann parallel zu SillyHome schalten.",
|
||||||
|
action="Automation pausieren oder SillyHome im Shadow-Modus lassen.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if record.behavior.latency_measurements:
|
||||||
|
durations = [
|
||||||
|
item.event_to_done_ms
|
||||||
|
for item in record.behavior.latency_measurements
|
||||||
|
if item.event_to_done_ms is not None
|
||||||
|
]
|
||||||
|
if durations and max(durations) > 1500:
|
||||||
|
insights.append(
|
||||||
|
AgentInsight(
|
||||||
|
insight_id=f"{record.actuator_entity_id}.latency",
|
||||||
|
severity="warning",
|
||||||
|
title="Schalt-Latenz beobachten",
|
||||||
|
detail=f"Letzte maximale Event-Latenz: {max(durations)} ms.",
|
||||||
|
action="WebSocket-Status, HA-Servicezeit und Sensor-Routing pruefen.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if record.behavior.incorrect_feedback_count > record.behavior.correct_feedback_count:
|
||||||
|
insights.append(
|
||||||
|
AgentInsight(
|
||||||
|
insight_id=f"{record.actuator_entity_id}.feedback",
|
||||||
|
severity="warning",
|
||||||
|
title="Viele negative Feedbacks",
|
||||||
|
detail="Das Modell trifft aktuell mehr falsche als richtige Entscheidungen.",
|
||||||
|
action="Kontextzuordnung, Gewichtung oder Modell-Rollback pruefen.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result[record.actuator_entity_id] = insights[:5]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _area_hint(record: ActuatorRecord) -> str | None:
|
||||||
|
for candidate in [*record.context_candidates, *record.numeric_candidates]:
|
||||||
|
if candidate.area_name:
|
||||||
|
return candidate.area_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(value: str) -> str:
|
||||||
|
result = []
|
||||||
|
for char in value.lower():
|
||||||
|
if char.isalnum():
|
||||||
|
result.append(char)
|
||||||
|
elif char in {".", "_", "-", " "}:
|
||||||
|
result.append("_")
|
||||||
|
return "".join(result).strip("_")[:64] or "item"
|
||||||
|
|
||||||
|
|
||||||
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
|
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
|
||||||
if target_state == "on" and profile.min_confidence_on is not None:
|
if target_state == "on" and profile.min_confidence_on is not None:
|
||||||
return profile.min_confidence_on
|
return profile.min_confidence_on
|
||||||
|
|||||||
10
app/main.py
10
app/main.py
@@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SillyHome Next API",
|
title="SillyHome Next API",
|
||||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||||
version="1.5.3",
|
version="1.7.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
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:
|
if ws_status is not None:
|
||||||
ws_status.status = "connecting"
|
ws_status.status = "connecting"
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
async with websockets.connect(ws_url, ping_interval=None) as websocket:
|
||||||
ws_url,
|
|
||||||
ping_interval=30,
|
|
||||||
ping_timeout=30,
|
|
||||||
) as websocket:
|
|
||||||
auth_required_msg = await websocket.recv()
|
auth_required_msg = await websocket.recv()
|
||||||
auth_required_data = json.loads(auth_required_msg)
|
auth_required_data = json.loads(auth_required_msg)
|
||||||
if auth_required_data.get("type") != "auth_required":
|
if auth_required_data.get("type") != "auth_required":
|
||||||
@@ -315,8 +311,6 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
continue
|
continue
|
||||||
new_state = event_data.get("new_state")
|
new_state = event_data.get("new_state")
|
||||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||||
if not _is_relevant_state_change(store, str(entity_id)):
|
|
||||||
continue
|
|
||||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
|
.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 { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; }
|
||||||
.toolbar button { margin-top: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,
|
details.collapsible > summary,
|
||||||
.manual-context > summary,
|
.manual-context > summary,
|
||||||
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
|
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
|
||||||
@@ -144,20 +146,17 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<span class="brand-mark">SH</span>
|
<span class="brand-mark">SH</span>
|
||||||
<h1>SillyHome Next</h1>
|
<h1>SillyHome</h1>
|
||||||
</div>
|
</div>
|
||||||
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
|
<p>Geräte, Lernen, Freigaben und Systemzustand.</p>
|
||||||
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<label for="section-jump">Menü</label>
|
<label for="section-jump">Menü</label>
|
||||||
<select id="section-jump" onchange="showView(this.value)">
|
<select id="section-jump" onchange="showView(this.value)">
|
||||||
<option value="status-section">Startseite / System</option>
|
<option value="status-section">Startseite / System</option>
|
||||||
<option value="observed">Lernen</option>
|
<option value="observed">Lernen</option>
|
||||||
<option value="detail">Details</option>
|
|
||||||
<option value="choose">Discovery & Einrichtung</option>
|
<option value="choose">Discovery & Einrichtung</option>
|
||||||
<option value="settings">Einstellungen</option>
|
<option value="settings">Einstellungen</option>
|
||||||
<option value="guide">Ablauf</option>
|
|
||||||
</select>
|
</select>
|
||||||
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
|
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,11 +165,9 @@
|
|||||||
<main>
|
<main>
|
||||||
<section class="control-panel app-view" id="choose">
|
<section class="control-panel app-view" id="choose">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<h2>Steuerung</h2>
|
<h2>Discovery & Einrichtung</h2>
|
||||||
<span class="chip">v1</span>
|
|
||||||
</div>
|
</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">Entity-ID</label>
|
||||||
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
|
||||||
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
||||||
<datalist id="actuator-options"></datalist>
|
<datalist id="actuator-options"></datalist>
|
||||||
<div class="inline-controls">
|
<div class="inline-controls">
|
||||||
@@ -200,16 +197,15 @@
|
|||||||
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
|
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</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()">
|
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
|
||||||
<option value="">Geräteliste bei Bedarf laden</option>
|
<option value="">Geräteliste bei Bedarf laden</option>
|
||||||
</select>
|
</select>
|
||||||
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
||||||
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</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">
|
<details class="manual-context">
|
||||||
<summary>Vorschläge anzeigen</summary>
|
<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>
|
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
|
||||||
</details>
|
</details>
|
||||||
<div id="actuator-suggestions" class="card-list"></div>
|
<div id="actuator-suggestions" class="card-list"></div>
|
||||||
@@ -219,7 +215,6 @@
|
|||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<div>
|
<div>
|
||||||
<h2>Beobachtete Geräte</h2>
|
<h2>Beobachtete Geräte</h2>
|
||||||
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
|
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,8 +222,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="detail-panel app-view" id="detail">
|
<section class="detail-panel app-view" id="detail">
|
||||||
<h2>Lernfortschritt und Freigabe</h2>
|
<div class="panel-title">
|
||||||
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
|
<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>
|
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -236,7 +233,6 @@
|
|||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<div>
|
<div>
|
||||||
<h2>System & Cache</h2>
|
<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>
|
</div>
|
||||||
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
|
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,40 +256,9 @@
|
|||||||
<option value="de">Deutsch</option>
|
<option value="de">Deutsch</option>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const escapeHtml = value => String(value ?? "")
|
const escapeHtml = value => String(value ?? "")
|
||||||
@@ -309,7 +274,12 @@ let manualContextState = {options: [], selected: new Set()};
|
|||||||
let cachedActuators = null;
|
let cachedActuators = null;
|
||||||
let cachedEntities = null;
|
let cachedEntities = null;
|
||||||
let cachedDiscovery = null;
|
let cachedDiscovery = null;
|
||||||
|
let cachedSystemOverview = null;
|
||||||
|
let cachedDashboardOverview = null;
|
||||||
|
let cachedDetailHtml = new Map();
|
||||||
let discoveryLoadPromise = null;
|
let discoveryLoadPromise = null;
|
||||||
|
let overviewLoadPromise = null;
|
||||||
|
let systemLoadPromise = null;
|
||||||
let currentSensorWeightGroups = [];
|
let currentSensorWeightGroups = [];
|
||||||
let visibleActuatorLimit = 24;
|
let visibleActuatorLimit = 24;
|
||||||
const ACTUATOR_RESULT_LIMIT = 50;
|
const ACTUATOR_RESULT_LIMIT = 50;
|
||||||
@@ -465,14 +435,29 @@ function jumpToSection(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showView(viewId) {
|
function showView(viewId) {
|
||||||
|
if (viewId === "detail" && !currentActuatorId) {
|
||||||
|
viewId = "observed";
|
||||||
|
}
|
||||||
for (const section of document.querySelectorAll(".app-view")) {
|
for (const section of document.querySelectorAll(".app-view")) {
|
||||||
section.classList.toggle("active", section.id === viewId);
|
section.classList.toggle("active", section.id === viewId);
|
||||||
}
|
}
|
||||||
localStorage.setItem("sillyhome.ui.view", viewId);
|
localStorage.setItem("sillyhome.ui.view", viewId);
|
||||||
if (viewId === "status-section") {
|
if (viewId === "status-section") {
|
||||||
void loadSystemOverview();
|
if (cachedSystemOverview) {
|
||||||
|
renderDashboardStatus(cachedSystemOverview);
|
||||||
|
refreshSystemOverviewInBackground();
|
||||||
|
} else {
|
||||||
|
void loadSystemOverview();
|
||||||
|
}
|
||||||
} else if (viewId === "observed") {
|
} else if (viewId === "observed") {
|
||||||
void loadOverview();
|
if (cachedActuators) {
|
||||||
|
renderConfiguredActuators();
|
||||||
|
refreshOverviewInBackground();
|
||||||
|
} else {
|
||||||
|
void loadOverview();
|
||||||
|
}
|
||||||
|
} else if (viewId === "choose") {
|
||||||
|
if (cachedDiscovery) renderActuatorDiscovery();
|
||||||
} else if (viewId === "settings") {
|
} else if (viewId === "settings") {
|
||||||
syncSettingsView();
|
syncSettingsView();
|
||||||
}
|
}
|
||||||
@@ -484,8 +469,10 @@ function setLanguage(language) {
|
|||||||
localStorage.setItem("sillyhome.ui.language", uiLang);
|
localStorage.setItem("sillyhome.ui.language", uiLang);
|
||||||
syncSettingsView();
|
syncSettingsView();
|
||||||
if (cachedActuators) renderConfiguredActuators();
|
if (cachedActuators) renderConfiguredActuators();
|
||||||
void loadSystemOverview();
|
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
|
||||||
if (currentActuatorId) void showActuator(currentActuatorId);
|
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
|
||||||
|
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSettingsView() {
|
function syncSettingsView() {
|
||||||
@@ -514,6 +501,9 @@ function invalidateDashboardCache() {
|
|||||||
cachedActuators = null;
|
cachedActuators = null;
|
||||||
cachedEntities = null;
|
cachedEntities = null;
|
||||||
cachedDiscovery = null;
|
cachedDiscovery = null;
|
||||||
|
cachedDashboardOverview = null;
|
||||||
|
cachedSystemOverview = null;
|
||||||
|
cachedDetailHtml.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -654,13 +644,24 @@ function optionGroups(entities, selectedIds = new Set()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
|
if (overviewLoadPromise) return overviewLoadPromise;
|
||||||
|
overviewLoadPromise = doLoadOverview().finally(() => {
|
||||||
|
overviewLoadPromise = null;
|
||||||
|
});
|
||||||
|
return overviewLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLoadOverview() {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const budget = document.getElementById("load-budget");
|
const budget = document.getElementById("load-budget");
|
||||||
if (budget) budget.textContent = "Startdaten laden ...";
|
if (budget) budget.textContent = "Startdaten laden ...";
|
||||||
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
if (!cachedActuators) {
|
||||||
|
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const dashboard = await api("v1/actuators/dashboard/start");
|
const dashboard = await api("v1/actuators/dashboard/start");
|
||||||
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
||||||
|
cachedDashboardOverview = dashboard;
|
||||||
cachedActuators = dashboard.actuators || [];
|
cachedActuators = dashboard.actuators || [];
|
||||||
cachedEntities = [];
|
cachedEntities = [];
|
||||||
renderDashboardStatus(dashboard);
|
renderDashboardStatus(dashboard);
|
||||||
@@ -685,12 +686,21 @@ async function loadOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSystemOverview() {
|
async function loadSystemOverview() {
|
||||||
|
if (systemLoadPromise) return systemLoadPromise;
|
||||||
|
systemLoadPromise = doLoadSystemOverview().finally(() => {
|
||||||
|
systemLoadPromise = null;
|
||||||
|
});
|
||||||
|
return systemLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLoadSystemOverview() {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const budget = document.getElementById("load-budget");
|
const budget = document.getElementById("load-budget");
|
||||||
if (budget) budget.textContent = "Systemübersicht lädt ...";
|
if (budget) budget.textContent = "Systemübersicht lädt ...";
|
||||||
try {
|
try {
|
||||||
const dashboard = await api("v1/actuators/dashboard/system");
|
const dashboard = await api("v1/actuators/dashboard/system");
|
||||||
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
||||||
|
cachedSystemOverview = dashboard;
|
||||||
cachedActuators = dashboard.actuators || cachedActuators;
|
cachedActuators = dashboard.actuators || cachedActuators;
|
||||||
renderDashboardStatus(dashboard);
|
renderDashboardStatus(dashboard);
|
||||||
if (budget) {
|
if (budget) {
|
||||||
@@ -706,6 +716,22 @@ async function loadSystemOverview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshOverviewInBackground() {
|
||||||
|
if (!overviewLoadPromise) {
|
||||||
|
overviewLoadPromise = doLoadOverview().finally(() => {
|
||||||
|
overviewLoadPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSystemOverviewInBackground() {
|
||||||
|
if (!systemLoadPromise) {
|
||||||
|
systemLoadPromise = doLoadSystemOverview().finally(() => {
|
||||||
|
systemLoadPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleDashboardExtras() {
|
function scheduleDashboardExtras() {
|
||||||
const run = () => {
|
const run = () => {
|
||||||
void loadDashboardExtras();
|
void loadDashboardExtras();
|
||||||
@@ -1011,6 +1037,20 @@ function selectActuatorFromList() {
|
|||||||
if (value) document.getElementById("actuator-input").value = value;
|
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() {
|
function renderManualContextSelect() {
|
||||||
const select = document.getElementById("manual-context-select");
|
const select = document.getElementById("manual-context-select");
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@@ -1131,10 +1171,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
for (const section of document.querySelectorAll(".app-view")) {
|
for (const section of document.querySelectorAll(".app-view")) {
|
||||||
section.classList.toggle("active", section.id === "detail");
|
section.classList.toggle("active", section.id === "detail");
|
||||||
}
|
}
|
||||||
document.getElementById("section-jump").value = "detail";
|
document.getElementById("section-jump").value = "observed";
|
||||||
localStorage.setItem("sillyhome.ui.view", "detail");
|
localStorage.setItem("sillyhome.ui.view", "detail");
|
||||||
const box = document.getElementById("actuator-detail");
|
const box = document.getElementById("actuator-detail");
|
||||||
renderActuatorDetailShell(actuatorId);
|
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
|
||||||
|
box.innerHTML = cachedDetailHtml.get(actuatorId);
|
||||||
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||||
|
renderConfiguredActuators();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cachedDetailHtml.has(actuatorId)) {
|
||||||
|
box.innerHTML = cachedDetailHtml.get(actuatorId);
|
||||||
|
} else {
|
||||||
|
renderActuatorDetailShell(actuatorId);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
|
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
|
||||||
contextOptions = [];
|
contextOptions = [];
|
||||||
@@ -1356,7 +1406,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
const manualAssignment = `
|
const manualAssignment = `
|
||||||
<details class="manual-context">
|
<details class="manual-context">
|
||||||
<summary>Kontext selbst festlegen</summary>
|
<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>
|
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
||||||
<select id="manual-numeric-select">
|
<select id="manual-numeric-select">
|
||||||
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
||||||
@@ -1403,13 +1452,21 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
||||||
</li>`).join("")}</ul>`
|
</li>`).join("")}</ul>`
|
||||||
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
||||||
box.innerHTML = `
|
const detailHtml = `
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
||||||
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary compact" onclick="loadOverview()">Alles 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>
|
||||||
<div class="grid-two">
|
<div class="grid-two">
|
||||||
<div>
|
<div>
|
||||||
@@ -1417,7 +1474,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
||||||
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
||||||
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</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>
|
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1427,11 +1483,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
|
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
|
||||||
<p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</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>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>
|
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
||||||
<div class="actions">${activationButton}</div>
|
<div class="actions">${activationButton}</div>
|
||||||
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
<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>` : ""}
|
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1448,20 +1503,21 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
${adaptivePanel}
|
${adaptivePanel}
|
||||||
${anomalyPanel}
|
${anomalyPanel}
|
||||||
<h3>Passende Home-Assistant-Automationen</h3>
|
<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>
|
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
||||||
${automationControls}
|
${automationControls}
|
||||||
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
<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>"}
|
${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>
|
<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}
|
${weightControls}
|
||||||
${weightGroupControls}
|
${weightGroupControls}
|
||||||
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
||||||
${currentContextControls}
|
${currentContextControls}
|
||||||
${manualAssignment}
|
${manualAssignment}
|
||||||
`;
|
`;
|
||||||
|
box.innerHTML = detailHtml;
|
||||||
|
cachedDetailHtml.set(actuatorId, detailHtml);
|
||||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||||
|
renderConfiguredActuators();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.textContent = error.message;
|
box.textContent = error.message;
|
||||||
}
|
}
|
||||||
@@ -1472,13 +1528,13 @@ function renderActuatorDetailShell(actuatorId) {
|
|||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(actuatorId)}</h3>
|
<h3>${escapeHtml(actuatorId)}</h3>
|
||||||
<p class="muted">Basisdaten werden geladen ...</p>
|
<p class="muted">Lädt ...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric"><strong>Phase 1</strong>Aktuelle Einstellung</div>
|
<div class="metric"><strong>Status</strong>...</div>
|
||||||
<div class="metric"><strong>Phase 2</strong>Lernstand</div>
|
<div class="metric"><strong>Lernen</strong>...</div>
|
||||||
<div class="metric"><strong>Phase 3</strong>Kontextvorschläge</div>
|
<div class="metric"><strong>Kontext</strong>...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1768,8 +1824,11 @@ async function startDashboard() {
|
|||||||
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
|
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
|
||||||
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
|
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
|
||||||
syncSettingsView();
|
syncSettingsView();
|
||||||
document.getElementById("section-jump").value = "status-section";
|
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
|
||||||
showView("status-section");
|
? "observed"
|
||||||
|
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
|
||||||
|
document.getElementById("section-jump").value = initialView;
|
||||||
|
showView(initialView);
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
setTimeout(() => void loadStatus(), 100);
|
setTimeout(() => void loadStatus(), 100);
|
||||||
}
|
}
|
||||||
|
|||||||
35
docs/V1_5_4_OPERATING_GUIDE.md
Normal file
35
docs/V1_5_4_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# SillyHome Next v1.5.4 Operating Guide
|
||||||
|
|
||||||
|
Diese Version korrigiert Ingress-Logging und Dashboard-Navigation.
|
||||||
|
|
||||||
|
## Ingress-/Access-Logs
|
||||||
|
|
||||||
|
- Das Add-on startet Uvicorn ohne `--proxy-headers` und ohne
|
||||||
|
`--forwarded-allow-ips='*'`.
|
||||||
|
- Vorher konnte Uvicorn LAN-Adressen aus `X-Forwarded-For` anzeigen. Diese
|
||||||
|
Adresse war dann der urspruengliche Client oder Home-Assistant-Proxy, nicht
|
||||||
|
der direkte Container-Peer.
|
||||||
|
- Nach dem Update sollten Access-Logs den direkten Docker-/Ingress-Peer zeigen.
|
||||||
|
`GET ... HTTP/1.1` bleibt normal und ist kein Hinweis auf fehlendes Streaming.
|
||||||
|
|
||||||
|
## Dashboard-Verhalten
|
||||||
|
|
||||||
|
- Die Startseite nutzt weiter `/v1/actuators/dashboard/system`.
|
||||||
|
- Die Lernuebersicht nutzt weiter `/v1/actuators/dashboard/start`.
|
||||||
|
- Bereits geladene System-, Lern- und Discovery-Daten bleiben beim Wechseln der
|
||||||
|
Ansichten im Browser erhalten und werden nur im Hintergrund aufgefrischt.
|
||||||
|
- Details sind kein eigener Menuepunkt mehr. Sie werden nur ueber ein
|
||||||
|
ausgewaehltes beobachtetes Geraet geoeffnet.
|
||||||
|
- Ein bereits geoeffneter Aktor zeigt seine Detaildaten sofort aus dem
|
||||||
|
Browser-Cache. Neue Detaildaten werden erst ueber `Details aktualisieren`
|
||||||
|
oder nach einer Speichern-/Schaltaktion geladen.
|
||||||
|
|
||||||
|
## Pruefung
|
||||||
|
|
||||||
|
1. Add-on aktualisieren und neu starten.
|
||||||
|
2. Ingress hart neu laden.
|
||||||
|
3. Zwischen Startseite, Lernen und Discovery wechseln.
|
||||||
|
4. Erwartung: Bereits geladene Inhalte bleiben sichtbar; keine volle
|
||||||
|
Neuladung bei jedem Ansichtswechsel.
|
||||||
|
5. Details eines Aktors oeffnen, wegwechseln und wieder Details oeffnen.
|
||||||
|
Erwartung: Die zuletzt geladene Detailansicht steht sofort wieder da.
|
||||||
36
docs/V1_6_0_OPERATING_GUIDE.md
Normal file
36
docs/V1_6_0_OPERATING_GUIDE.md
Normal 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.
|
||||||
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
45
docs/V1_7_0_OPERATING_GUIDE.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# SillyHome Next v1.7.0 Operating Guide
|
||||||
|
|
||||||
|
v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und
|
||||||
|
Planungshilfen.
|
||||||
|
|
||||||
|
## Diagnose
|
||||||
|
|
||||||
|
- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor.
|
||||||
|
- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`.
|
||||||
|
- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde
|
||||||
|
vorhergesagt, wurde geschaltet oder blockiert, und warum.
|
||||||
|
|
||||||
|
## Backup und Restore
|
||||||
|
|
||||||
|
- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status
|
||||||
|
und Job-Historie als JSON.
|
||||||
|
- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein.
|
||||||
|
- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben.
|
||||||
|
|
||||||
|
## Dry-run
|
||||||
|
|
||||||
|
- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus.
|
||||||
|
- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht
|
||||||
|
an Home Assistant gesendet.
|
||||||
|
|
||||||
|
## Feedback
|
||||||
|
|
||||||
|
Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen:
|
||||||
|
|
||||||
|
- `correct`
|
||||||
|
- `wrong`
|
||||||
|
- `too_early`
|
||||||
|
- `too_late`
|
||||||
|
- `never_automate`
|
||||||
|
|
||||||
|
`never_automate` setzt eine manuelle Sicherheitssperre am Aktor.
|
||||||
|
|
||||||
|
## Planung
|
||||||
|
|
||||||
|
`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise:
|
||||||
|
|
||||||
|
- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten
|
||||||
|
- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten
|
||||||
|
- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "1.5.3"
|
version = "1.7.0"
|
||||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from time import perf_counter
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1.actuators import _deduplicate_actuator_ids
|
||||||
from app.actuators.cache_db import DashboardCache
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import JobStatus, ModelSnapshot
|
from app.actuators.models import JobStatus, ModelSnapshot
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.api.v1.actuators import _deduplicate_actuator_ids
|
|
||||||
from app.ha.discovery import DiscoveredEntity
|
from app.ha.discovery import DiscoveredEntity
|
||||||
from app.ha.discovery import discover_entities
|
from app.ha.discovery import discover_entities
|
||||||
from app.ha.history import (
|
from app.ha.history import (
|
||||||
@@ -353,6 +354,51 @@ def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -
|
|||||||
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
assert rollback.json()["behavior"]["active_model_version"] == version_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators",
|
||||||
|
json={"actuator_entity_id": "light.abstellkammer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/feedback",
|
||||||
|
json={"correct": False, "kind": "never_automate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feedback.status_code == 200
|
||||||
|
payload = feedback.json()
|
||||||
|
assert payload["behavior"]["safety"]["manual_block"] is True
|
||||||
|
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
|
||||||
|
backup = client.get("/v1/actuators/backup/export")
|
||||||
|
dry_run = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/dry-run",
|
||||||
|
json={"enabled": True},
|
||||||
|
)
|
||||||
|
planning = client.post("/v1/actuators/planning/refresh")
|
||||||
|
restore = client.post(
|
||||||
|
"/v1/actuators/backup/restore",
|
||||||
|
json={"backup": backup.json(), "replace_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert backup.status_code == 200
|
||||||
|
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
|
||||||
|
assert dry_run.status_code == 200
|
||||||
|
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
|
||||||
|
assert planning.status_code == 200
|
||||||
|
assert "agent_insights" in planning.json()[0]["behavior"]
|
||||||
|
assert restore.status_code == 200
|
||||||
|
assert restore.json()["restored_records"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
@@ -467,6 +513,30 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
|
|||||||
assert anomalies_response.json()
|
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:
|
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
|
|||||||
@@ -778,3 +778,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
|
|||||||
assert reader.service_calls == [
|
assert reader.service_calls == [
|
||||||
("light", "turn_on", {"entity_id": "light.storage"})
|
("light", "turn_on", {"entity_id": "light.storage"})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_evaluation_records_decision_timeline_and_latency(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
store = ActuatorStore(settings.actuator_store)
|
||||||
|
record = store.configure("light.storage")
|
||||||
|
record = record.model_copy(
|
||||||
|
update={
|
||||||
|
"assignment": record.assignment.model_copy(
|
||||||
|
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
|
||||||
|
),
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"mode": BehaviorMode.ACTIVE,
|
||||||
|
"status": BehaviorStatus.TRAINED,
|
||||||
|
"activation_ready": True,
|
||||||
|
"patterns": [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"binary_sensor.storage_door": "on"},
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
source="automation",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago in (3, 2, 1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.upsert(record)
|
||||||
|
reader = FakeBehaviorReader(
|
||||||
|
entities=[
|
||||||
|
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.storage_door",
|
||||||
|
domain="binary_sensor",
|
||||||
|
state="on",
|
||||||
|
last_changed=now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
history=[],
|
||||||
|
logbook=[],
|
||||||
|
)
|
||||||
|
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||||
|
|
||||||
|
result = engine.evaluate(
|
||||||
|
"light.storage",
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_state="on",
|
||||||
|
event_received_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = result.behavior.decision_timeline[-1]
|
||||||
|
latency = result.behavior.latency_measurements[-1]
|
||||||
|
assert trace.trigger_entity_id == "binary_sensor.storage_door"
|
||||||
|
assert trace.target_state == "on"
|
||||||
|
assert trace.executed is True
|
||||||
|
assert latency.trigger_entity_id == "binary_sensor.storage_door"
|
||||||
|
assert latency.executed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_records_without_calling_service(tmp_path: Path) -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
store = ActuatorStore(settings.actuator_store)
|
||||||
|
record = store.configure("light.storage")
|
||||||
|
record = record.model_copy(
|
||||||
|
update={
|
||||||
|
"assignment": record.assignment.model_copy(
|
||||||
|
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
|
||||||
|
),
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"mode": BehaviorMode.ACTIVE,
|
||||||
|
"status": BehaviorStatus.TRAINED,
|
||||||
|
"activation_ready": True,
|
||||||
|
"dry_run_enabled": True,
|
||||||
|
"patterns": [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"binary_sensor.storage_door": "on"},
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
source="automation",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago in (3, 2, 1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.upsert(record)
|
||||||
|
reader = FakeBehaviorReader(
|
||||||
|
entities=[
|
||||||
|
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.storage_door",
|
||||||
|
domain="binary_sensor",
|
||||||
|
state="on",
|
||||||
|
last_changed=now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
history=[],
|
||||||
|
logbook=[],
|
||||||
|
)
|
||||||
|
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||||
|
|
||||||
|
result = engine.evaluate("light.storage")
|
||||||
|
|
||||||
|
assert reader.service_calls == []
|
||||||
|
assert result.behavior.dry_run_sample_count == 1
|
||||||
|
assert result.behavior.decision_timeline[-1].executed is False
|
||||||
|
|||||||
@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
|
|||||||
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
|
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
|
||||||
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
|
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
|
||||||
assert config_copy < repository_clone
|
assert config_copy < repository_clone
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_does_not_trust_forwarded_lan_ips() -> None:
|
||||||
|
run_script = Path("addon/run.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "--proxy-headers" not in run_script
|
||||||
|
assert "--forwarded-allow-ips" not in run_script
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "SillyHome Next" in response.text
|
assert "SillyHome Next" in response.text
|
||||||
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
|
assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
|
||||||
assert "So gehst du vor" in response.text
|
assert "So gehst du vor" not in response.text
|
||||||
assert "Steuerung" in response.text
|
assert "Discovery & Einrichtung" in response.text
|
||||||
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
|
assert "Entity-ID" in response.text
|
||||||
assert "Oder aus Liste wählen" in response.text
|
assert "Geräteliste" in response.text
|
||||||
assert "Liste durchsuchen" in response.text
|
assert "Liste durchsuchen" in response.text
|
||||||
assert "Geräteliste bei Bedarf laden" 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 "Wie gewohnt bedienen" in response.text
|
assert '<option value="detail">Details</option>' not in response.text
|
||||||
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
|
assert "Wie gewohnt bedienen" not in response.text
|
||||||
assert "Du wählst keine Sensoren und erstellst keine Regeln" 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 "Freigabestatus" in response.text
|
||||||
assert "SillyHome übernehmen lassen" in response.text
|
assert "SillyHome übernehmen lassen" in response.text
|
||||||
assert "Passende Home-Assistant-Automationen" in response.text
|
assert "Passende Home-Assistant-Automationen" in response.text
|
||||||
@@ -28,10 +29,12 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
assert "Aktuelle Situation auswerten" in response.text
|
assert "Aktuelle Situation auswerten" in response.text
|
||||||
assert "Kontext selbst festlegen" in response.text
|
assert "Kontext selbst festlegen" in response.text
|
||||||
assert "Entity-IDs manuell ergänzen" 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 "manual-context-freeform" in response.text
|
||||||
assert "Diese Kontext-Auswahl speichern" in response.text
|
assert "Diese Kontext-Auswahl speichern" in response.text
|
||||||
assert "manual-context-select" 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 "Kein frischer passender Sensorwechsel erkannt" in response.text
|
||||||
assert "Vorhersage jetzt prüfen" not in response.text
|
assert "Vorhersage jetzt prüfen" not in response.text
|
||||||
assert "record.behavior.activation_ready" in response.text
|
assert "record.behavior.activation_ready" in response.text
|
||||||
@@ -40,7 +43,9 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
assert 'api("v1/actuators")' not in response.text
|
assert 'api("v1/actuators")' not in response.text
|
||||||
assert 'api("v1/actuators/summary")' in response.text
|
assert 'api("v1/actuators/summary")' in response.text
|
||||||
assert 'api("v1/entities")' not 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 'class="group-panel"' in response.text
|
||||||
|
assert "cachedDetailHtml" in response.text
|
||||||
|
assert "refreshOverviewInBackground" in response.text
|
||||||
assert "Automation-Entwurf" not in response.text
|
assert "Automation-Entwurf" not in response.text
|
||||||
assert "Manuelle Overrides" not in response.text
|
assert "Manuelle Overrides" not in response.text
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
connect.assert_called_once_with(
|
connect.assert_called_once_with(
|
||||||
"ws://homeassistant:8123/api/websocket",
|
"ws://homeassistant:8123/api/websocket",
|
||||||
ping_interval=30,
|
ping_interval=None,
|
||||||
ping_timeout=30,
|
|
||||||
)
|
)
|
||||||
assert fake_ws.sent == [
|
assert fake_ws.sent == [
|
||||||
{"type": "auth", "access_token": "test-token"},
|
{"type": "auth", "access_token": "test-token"},
|
||||||
|
|||||||
Reference in New Issue
Block a user