Compare commits

...

5 Commits

Author SHA1 Message Date
6323b93f23 Fix ingress logging and dashboard cache navigation
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:30:12 +02:00
1d176cce45 Add SQLite dashboard cache
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 00:07:47 +02:00
bd087728e1 Limit rollback snapshots and relax HA timeouts
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:44:21 +02:00
10f9113547 Stabilize dashboard loading hotfix
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:19:22 +02:00
47fa8eb0ce Split dashboard views and compact detail loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 22:34:38 +02:00
21 changed files with 763 additions and 56 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ __pycache__/
.env
.env.local
.env.*
/.actuator_store/
/MagicMock/

View File

@@ -1,5 +1,14 @@
# Changelog
## 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
- Automatische Sensor-Gewichtungsanpassung aus Nutzerfeedback:
korrektes Feedback staerkt aktuelle Kontextsignale leicht, falsches Feedback

View File

@@ -23,6 +23,14 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
- Version 1.5.2 Rollback-Speicher und HA-Timeouts:
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad

View File

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

View File

@@ -21,5 +21,4 @@ if [ -f /data/options.json ]; then
fi
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 \
--proxy-headers --forwarded-allow-ips='*'
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000

98
app/actuators/cache_db.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from threading import RLock
from app.ha.models import HaEntitySummary
class DashboardCache:
def __init__(self, path: str | Path) -> None:
self._path = Path(path).resolve()
self._path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._init()
def load_entities_payload(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"select entity_id, payload from ha_entities order by entity_id"
).fetchall()
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entities": [json.loads(row[1]) for row in rows],
}
def save_entities_payload(
self,
*,
entities: list[HaEntitySummary],
discovery_groups: list[dict[str, object]],
) -> None:
now = datetime.now(timezone.utc).isoformat()
rows = [
(entity.entity_id, entity.model_dump_json())
for entity in entities
]
with self._lock, self._connect() as connection:
connection.execute("delete from ha_entities")
connection.executemany(
"insert into ha_entities(entity_id, payload) values (?, ?)",
rows,
)
self._set_meta(connection, "ha_entities_updated_at", now)
self._set_meta(
connection,
"discovery_groups",
json.dumps(discovery_groups, ensure_ascii=True, sort_keys=True),
)
def _init(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists ha_entities (
entity_id text primary key,
payload text not null
)
"""
)
connection.execute(
"""
create table if not exists cache_meta (
key text primary key,
value text
)
"""
)
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self._path, timeout=30)
@staticmethod
def _get_meta(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"select value from cache_meta where key = ?",
(key,),
).fetchone()
return str(row[0]) if row is not None and row[0] is not None else None
@staticmethod
def _set_meta(connection: sqlite3.Connection, key: str, value: str) -> None:
connection.execute(
"""
insert into cache_meta(key, value) values (?, ?)
on conflict(key) do update set value = excluded.value
""",
(key, value),
)

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
@@ -306,15 +307,25 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
@router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=True)
return _dashboard_overview(request, include_background=True, include_actuators=True)
@router.get("/dashboard/start", response_model=DashboardOverview)
def dashboard_start(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=False)
return _dashboard_overview(request, include_background=False, include_actuators=True)
def _dashboard_overview(request: Request, *, include_background: bool) -> DashboardOverview:
@router.get("/dashboard/system", response_model=DashboardOverview)
def dashboard_system(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=False, include_actuators=False)
def _dashboard_overview(
request: Request,
*,
include_background: bool,
include_actuators: bool,
) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
@@ -329,7 +340,7 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
] if include_background and isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
actuators = list_configured_summary(request) if include_actuators else []
store = getattr(request.app.state, "actuator_store", None)
jobs = (
store.load_job_queue()
@@ -348,7 +359,11 @@ def _dashboard_overview(request: Request, *, include_background: bool) -> Dashbo
if reconciliation.last_completed_at is not None
else None
),
configured_actuators=len(actuators),
configured_actuators=(
len(actuators)
if include_actuators
else reconciliation.configured_actuators
),
trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required,
job_p95_duration_ms=job_p95_duration_ms,
@@ -417,6 +432,47 @@ def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{actuator_entity_id}/detail", response_model=ActuatorRecord)
def get_actuator_detail(actuator_entity_id: str, request: Request) -> ActuatorRecord:
try:
record = _service(request).get_actuator(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
compact_snapshots = [
snapshot.model_copy(update={"patterns": []})
for snapshot in record.behavior.model_snapshots[-3:]
]
compact_behavior = record.behavior.model_copy(
update={
"patterns": [],
"model_snapshots": compact_snapshots,
}
)
return record.model_copy(
update={
"behavior": compact_behavior,
"numeric_candidates": [
candidate
for candidate in record.numeric_candidates
if candidate.entity_id in selected_ids
],
"context_candidates": [
candidate
for candidate in record.context_candidates
if candidate.entity_id in selected_ids
],
}
)
@router.delete("/{actuator_entity_id}", status_code=204)
def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id)
@@ -809,6 +865,11 @@ def _load_cached_entity_map(
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
payload = cache.load_entities_payload()
if payload.get("entities"):
return payload
path = _entity_cache_path(request)
if not path.exists():
return {}
@@ -820,18 +881,18 @@ def _load_entity_cache_payload(request: Request) -> dict[str, object]:
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
group_payload = _discovery_group_payload(entities)
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
cache.save_entities_payload(
entities=entities,
discovery_groups=group_payload,
)
path = _entity_cache_path(request)
path.parent.mkdir(parents=True, exist_ok=True)
group_counts: dict[tuple[str, str], int] = {}
for entity in discover_entities(entities):
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
payload = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"discovery_groups": [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
],
"discovery_groups": group_payload,
"entities": [entity.model_dump(mode="json") for entity in entities],
}
temporary = path.with_suffix(".json.tmp")
@@ -842,6 +903,17 @@ def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) ->
os.replace(temporary, path)
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
group_counts: dict[tuple[str, str], int] = {}
for entity in discover_entities(entities):
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
return [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
]
def _deduplicate_actuator_ids(
discovered: list[tuple[str, str]],
entities: dict[str, HaEntitySummary],

View File

@@ -32,6 +32,8 @@ from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
_MAX_PATTERNS = 500
_MAX_MODEL_SNAPSHOTS = 3
_MAX_SNAPSHOT_PATTERNS = 120
_MAX_EXECUTION_EVENTS = 100
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
@@ -937,6 +939,11 @@ class BehaviorEngine:
record: ActuatorRecord,
behavior: BehaviorState,
) -> ActuatorRecord:
behavior = behavior.model_copy(
update={
"model_snapshots": _compact_model_snapshots(behavior.model_snapshots),
}
)
updated = record.model_copy(
update={
"behavior": behavior,
@@ -1132,10 +1139,19 @@ def _next_model_snapshots(
high_confidence_sample_count=trusted_actions,
average_confidence=round(average_confidence, 4),
incorrect_feedback_count=incorrect_feedback_count,
patterns=patterns,
patterns=patterns[-_MAX_SNAPSHOT_PATTERNS:],
reason=reason,
)
return [*existing, snapshot][-10:]
return _compact_model_snapshots([*existing, snapshot])
def _compact_model_snapshots(existing: list[ModelSnapshot]) -> list[ModelSnapshot]:
return [
snapshot.model_copy(
update={"patterns": snapshot.patterns[-_MAX_SNAPSHOT_PATTERNS:]}
)
for snapshot in existing[-_MAX_MODEL_SNAPSHOTS:]
]
def _average(values: list[float]) -> float:

View File

@@ -21,6 +21,8 @@ class Settings:
prediction_interval_seconds: int = 60
execution_cooldown_seconds: int = 900
timezone: str = "Europe/Berlin"
ha_timeout_seconds: int = 25
dashboard_cache_refresh_seconds: int = 3600
@property
def ha_configured(self) -> bool:
@@ -55,4 +57,8 @@ def load_settings() -> Settings:
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
),
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
ha_timeout_seconds=max(5, int(os.getenv("SILLYHOME_HA_TIMEOUT_SECONDS", "25"))),
dashboard_cache_refresh_seconds=max(
300, int(os.getenv("SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS", "3600"))
),
)

View File

@@ -12,6 +12,7 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router
@@ -20,6 +21,7 @@ from app.behavior.engine import BehaviorEngine
from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.discovery import discover_entities
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
@@ -47,8 +49,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task: asyncio.Task[None] | None = None
event_listener_task: asyncio.Task[None] | None = None
fallback_task: asyncio.Task[None] | None = None
cache_refresh_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store)
app.state.actuator_store = ActuatorStore(settings.actuator_store)
app.state.dashboard_cache = DashboardCache(
Path(settings.actuator_store).resolve() / "dashboard_cache.sqlite3"
)
if hasattr(app.state, "ha_reader"):
del app.state.ha_reader
if hasattr(app.state, "actuator_service"):
@@ -60,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=HaClientSettings(
url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token),
timeout_seconds=settings.ha_timeout_seconds,
)
)
app.state.ha_reader = HaReader(client=client)
@@ -79,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
fallback_task = asyncio.create_task(_fallback_prediction(app))
cache_refresh_task = asyncio.create_task(_periodic_dashboard_cache_refresh(app))
try:
yield
finally:
@@ -98,6 +106,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
fallback_task.cancel()
with suppress(asyncio.CancelledError):
await fallback_task
if cache_refresh_task is not None:
cache_refresh_task.cancel()
with suppress(asyncio.CancelledError):
await cache_refresh_task
if client is not None:
client.close()
@@ -105,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.4.0",
version="1.5.4",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -159,6 +171,42 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
async def _periodic_dashboard_cache_refresh(app: FastAPI) -> None:
await asyncio.sleep(2)
while True:
await _refresh_dashboard_cache(app, trigger="scheduled")
await asyncio.sleep(app.state.settings.dashboard_cache_refresh_seconds)
async def _refresh_dashboard_cache(app: FastAPI, *, trigger: str) -> None:
ha_reader = getattr(app.state, "ha_reader", None)
cache = getattr(app.state, "dashboard_cache", None)
if not isinstance(ha_reader, HaReader) or not isinstance(cache, DashboardCache):
return
try:
entities = await asyncio.to_thread(ha_reader.read_entities)
groups = _discovery_group_payload(list(entities))
await asyncio.to_thread(
cache.save_entities_payload,
entities=list(entities),
discovery_groups=groups,
)
logger.info("Dashboard-Cache aktualisiert (%s): %d Entities", trigger, len(entities))
except Exception as exc:
logger.warning("Dashboard-Cache konnte nicht aktualisiert werden (%s): %s", trigger, exc)
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
group_counts: dict[tuple[str, str], int] = {}
for entity in discover_entities(entities):
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
return [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
]
async def _startup_reconciliation(app: FastAPI) -> None:
delay_seconds = 5
while True:
@@ -213,8 +261,8 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
try:
async with websockets.connect(
ws_url,
ping_interval=20,
ping_timeout=10,
ping_interval=30,
ping_timeout=30,
) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)

View File

@@ -39,8 +39,10 @@
.header-actions label { margin:0; font-size:.82rem; }
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); }
main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; }
main { display:block; padding:12px; max-width:1480px; margin:0 auto; }
section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; }
.app-view { display:none; }
.app-view.active { display:block; }
section:target { outline:2px solid var(--complement); outline-offset:2px; }
.control-panel { grid-area:control; align-self:start; position:sticky; top:58px; }
.board-panel { grid-area:board; }
@@ -54,6 +56,8 @@
.manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
.manual-context > summary,
.group-panel > summary { display:flex; justify-content:space-between; gap:10px; align-items:center; }
details.collapsible > summary::-webkit-details-marker,
.manual-context > summary::-webkit-details-marker,
.group-panel > summary::-webkit-details-marker { display:none; }
@@ -118,7 +122,7 @@
.topbar { display:grid; }
.header-actions { min-width:0; }
.status-pill { width:max-content; max-width:100%; white-space:normal; }
main { display:block; padding:8px; }
main { padding:8px; }
.control-panel { position:static; }
section { margin-bottom:10px; padding:10px; border-radius:8px; }
.steps { grid-template-columns:1fr; }
@@ -147,19 +151,19 @@
</div>
<div class="header-actions">
<label for="section-jump">Menü</label>
<select id="section-jump" onchange="jumpToSection(this.value)">
<option value="#choose">Steuerung</option>
<option value="#observed">Geräte</option>
<option value="#detail">Freigabe</option>
<option value="#status-section">System</option>
<option value="#guide">Ablauf</option>
<select id="section-jump" onchange="showView(this.value)">
<option value="status-section">Startseite / System</option>
<option value="observed">Lernen</option>
<option value="choose">Discovery & Einrichtung</option>
<option value="settings">Einstellungen</option>
<option value="guide">Ablauf</option>
</select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div>
</div>
</header>
<main>
<section class="control-panel" id="choose">
<section class="control-panel app-view" id="choose">
<div class="panel-title">
<h2>Steuerung</h2>
<span class="chip">v1</span>
@@ -210,7 +214,7 @@
<div id="actuator-suggestions" class="card-list"></div>
</section>
<section class="board-panel" id="observed">
<section class="board-panel app-view" id="observed">
<div class="panel-title">
<div>
<h2>Beobachtete Geräte</h2>
@@ -221,13 +225,13 @@
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section class="detail-panel" id="detail">
<section class="detail-panel app-view" id="detail">
<h2>Lernfortschritt und Freigabe</h2>
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section>
<section class="status-panel" id="status-section">
<section class="status-panel app-view active" id="status-section">
<div class="panel-title">
<div>
<h2>System & Cache</h2>
@@ -241,7 +245,30 @@
<div id="job-queue" class="decision-list"></div>
</section>
<section class="guide-panel" id="guide">
<section class="guide-panel app-view" id="settings">
<div class="panel-title">
<div>
<h2>Einstellungen</h2>
<p class="muted">Sprache und Standardwerte für die Bedienoberfläche.</p>
</div>
</div>
<div class="grid-two">
<div>
<label for="language-select">Sprache</label>
<select id="language-select" onchange="setLanguage(this.value)">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
<p class="muted">Die API speichert stabile technische Werte. Die Oberfläche übersetzt sie in die gewählte Sprache.</p>
</div>
<div>
<h3>Performance-Standard</h3>
<p>Startansichten dürfen maximal 3 Sekunden brauchen. Schwere Daten werden nur nach Menüwechsel oder bei Bearbeitung geladen.</p>
</div>
</div>
</section>
<section class="guide-panel app-view" id="guide">
<details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps">
@@ -281,7 +308,12 @@ let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null;
let overviewLoadPromise = null;
let systemLoadPromise = null;
let currentSensorWeightGroups = [];
let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50;
@@ -358,14 +390,130 @@ const I18N = {
error: "Fehler",
},
},
en: {
safety_stage: {
observe: "Observe only",
suggest: "Show suggestions",
shadow: "Review mode without switching",
partial: "Partial approval",
active: "Active approval",
},
behavior_mode: {
shadow: "Review mode",
active: "Active",
paused: "Paused",
},
behavior_status: {
collecting: "Collecting examples",
trained: "Learned",
blocked: "Blocked",
},
lifecycle_status: {
trained: "learned",
pending_history: "collecting history",
pending_assignment: "finding context",
review_required: "review required",
archived: "waiting",
orphaned: "actuator missing",
stale: "training stale",
invalid: "invalid",
},
job_status: {
pending: "waiting",
running: "running",
completed: "completed",
failed: "failed",
},
job_kind: {
discovery: "Discovery",
reconciliation: "Reconciliation",
training: "Training",
evaluation: "Evaluation",
automation_refresh: "Automation check",
},
severity: {
info: "Info",
warning: "Warning",
critical: "Critical",
},
anomaly_category: {
missing_context: "missing context",
low_samples: "not enough samples",
unclear_sources: "unclear sources",
stale_training: "stale training",
low_confidence_prediction: "low confidence",
manual_block: "manual block",
safety_blockers: "safety blockers",
automation_conflict: "automation conflict",
feedback_error_rate: "feedback error rate",
},
performance_status: {
ok: "fast",
slow: "too slow",
running: "running",
unknown: "unknown",
},
connection_status: {
connected: "connected",
disconnected: "disconnected",
unavailable: "unavailable",
error: "error",
},
},
};
const uiLang = "de";
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
function jumpToSection(target) {
if (!target) return;
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function showView(viewId) {
if (viewId === "detail" && !currentActuatorId) {
viewId = "observed";
}
for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === viewId);
}
localStorage.setItem("sillyhome.ui.view", viewId);
if (viewId === "status-section") {
if (cachedSystemOverview) {
renderDashboardStatus(cachedSystemOverview);
refreshSystemOverviewInBackground();
} else {
void loadSystemOverview();
}
} else if (viewId === "observed") {
if (cachedActuators) {
renderConfiguredActuators();
refreshOverviewInBackground();
} else {
void loadOverview();
}
} else if (viewId === "choose") {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") {
syncSettingsView();
}
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function setLanguage(language) {
uiLang = I18N[language] ? language : "de";
localStorage.setItem("sillyhome.ui.language", uiLang);
syncSettingsView();
if (cachedActuators) renderConfiguredActuators();
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
}
}
function syncSettingsView() {
const select = document.getElementById("language-select");
if (select) select.value = uiLang;
}
function translate(group, value, fallback = "") {
if (value == null || value === "") return fallback || "offen";
return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
@@ -387,6 +535,9 @@ function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedDetailHtml.clear();
}
async function api(path, options = {}) {
@@ -401,6 +552,11 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await api(path, {signal: controller.signal});
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("Zeitlimit erreicht; Daten laden im Hintergrund weiter.");
}
throw error;
} finally {
clearTimeout(timeout);
}
@@ -522,13 +678,24 @@ function optionGroups(entities, selectedIds = new Set()) {
}
async function loadOverview() {
if (overviewLoadPromise) return overviewLoadPromise;
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
return overviewLoadPromise;
}
async function doLoadOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
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 {
const dashboard = await apiWithTimeout("v1/actuators/dashboard/start", DASHBOARD_TIMEOUT_MS);
const dashboard = await api("v1/actuators/dashboard/start");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedDashboardOverview = dashboard;
cachedActuators = dashboard.actuators || [];
cachedEntities = [];
renderDashboardStatus(dashboard);
@@ -552,6 +719,53 @@ async function loadOverview() {
scheduleDashboardExtras();
}
async function loadSystemOverview() {
if (systemLoadPromise) return systemLoadPromise;
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
return systemLoadPromise;
}
async function doLoadSystemOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ...";
try {
const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedSystemOverview = dashboard;
cachedActuators = dashboard.actuators || cachedActuators;
renderDashboardStatus(dashboard);
if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Systemübersicht bereit in ${loadMs} ms`
: `Systemübersicht langsam: ${loadMs} ms`;
}
scheduleDashboardExtras();
} catch (error) {
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Systemübersicht verzögert";
}
}
function refreshOverviewInBackground() {
if (!overviewLoadPromise) {
overviewLoadPromise = doLoadOverview().finally(() => {
overviewLoadPromise = null;
});
}
}
function refreshSystemOverviewInBackground() {
if (!systemLoadPromise) {
systemLoadPromise = doLoadSystemOverview().finally(() => {
systemLoadPromise = null;
});
}
}
function scheduleDashboardExtras() {
const run = () => {
void loadDashboardExtras();
@@ -974,10 +1188,25 @@ function renderConfiguredActuators() {
async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId;
for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === "detail");
}
document.getElementById("section-jump").value = "observed";
localStorage.setItem("sillyhome.ui.view", "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 {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
contextOptions = [];
const contexts = [
record.assignment.selected_numeric_entity_id,
@@ -1177,9 +1406,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
</div>
</details>
`;
const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation",
).length;
const learnedAutomationActions = "wird bei Bedarf im Training ausgewertet";
const relatedAutomations = record.behavior.related_automations || [];
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
@@ -1246,13 +1473,13 @@ 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>
</li>`).join("")}</ul>`
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
box.innerHTML = `
const detailHtml = `
<div class="detail-header">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
</div>
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button>
<button class="secondary compact" onclick="showActuator('${escapeHtml(record.actuator_entity_id)}', 'Aktualisiert.')">Details aktualisieren</button>
</div>
<div class="grid-two">
<div>
@@ -1268,7 +1495,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Davon erkannte HA-Automationen:</strong> ${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>Was noch passiert:</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>
@@ -1304,8 +1531,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${currentContextControls}
${manualAssignment}
`;
void hydrateContextOptions(record);
box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) {
box.textContent = error.message;
}
@@ -1609,11 +1838,16 @@ async function removeActuator(actuatorId) {
async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Geräte werden geladen.</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>";
syncSettingsView();
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
? "observed"
: (localStorage.getItem("sillyhome.ui.view") || "status-section");
document.getElementById("section-jump").value = initialView;
showView(initialView);
await new Promise(resolve => requestAnimationFrame(resolve));
await loadOverview();
void loadStatus();
setTimeout(() => void loadStatus(), 100);
}
void startDashboard();

View File

@@ -0,0 +1,47 @@
# SillyHome Next v1.5.0 Operating Guide
v1.5.0 trennt Dashboard-Ansichten, Datenabruf und Detaildaten weiter auf. Ziel
ist, dass die Seite auf mobiler Datenverbindung schneller nutzbar wird und keine
schweren Lern-, Discovery- oder Detaildaten beim Start lädt.
## Menüstruktur
- Startseite / System: Systemübersicht, Cache, Performance, Status.
- Lernen: konfigurierte Aktoren und Lernstand.
- Details: genau ein ausgewählter Aktor.
- Discovery & Einrichtung: Geräteliste, Vorschläge und neue Aktoren.
- Einstellungen: Sprache und Standardverhalten.
- Ablauf: Bedienhinweise.
Beim Öffnen der Seite wird immer nur die Startseite geladen. Andere Ansichten
laden erst beim Öffnen.
## Kompakte Detaildaten
Neuer Endpunkt:
```text
GET /v1/actuators/{actuator_entity_id}/detail
```
Dieser Endpunkt entfernt große Musterlisten und Snapshot-Muster aus dem ersten
Detailabruf. Geladen werden nur die Werte, die für die erste Detailansicht
benötigt werden. Kontextvorschläge bleiben ein separater Abruf und laufen erst
auf Nutzeraktion.
## Sprache
Die Sprache kann unter `Einstellungen` gewählt werden. Deutsch ist Standard.
Technische API-Werte bleiben stabil, werden aber im Dashboard über die
Sprachschicht angezeigt.
## Performance-Regeln
- Kein Discovery beim Start.
- Keine Aufgabenliste beim Start.
- Keine Kontextvorschläge beim Öffnen eines Aktors.
- Keine Musterlisten im ersten Detailabruf.
- Geräteübersicht rendert begrenzt und lädt weitere Karten per Button nach.
Die Angabe „bereit in X ms“ beschreibt nur den jeweiligen API-/Ansichtsabruf.
Sie ist nicht gleichzusetzen mit der kompletten HA/Ingress-Navigationszeit.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.1 Operating Guide
v1.5.1 ist ein Stabilisierungshotfix für die nach v1.2.0 entstandenen
Dashboard-Änderungen. Fachlich gehört diese Arbeit zur v1.2.x-Patchlinie; die
höhere technische Versionsnummer ist nur nötig, weil Home Assistant bereits
v1.5.0 installiert hat und Add-on-Updates monoton nach oben laufen.
## Korrekturen
- Die System-Startseite nutzt `GET /v1/actuators/dashboard/system` und lädt
keine Aktorenliste.
- Sichtbare 3-Sekunden-Abbrüche mit Browsertexten wie `signal is aborted
without reason` wurden entfernt.
- Startdaten und Detaildaten werden ohne künstlichen Frontend-Abbruch geladen.
- Timeout-Meldungen werden deutsch und verständlich angezeigt, wenn sie bei
Nebenprüfungen auftreten.
- `summary`-Zeilen wie `anzeigenaufklappen` haben jetzt Abstand und Layout.
## Ladeverhalten
- Statische Seite wird sofort gerendert.
- Systemdaten laden im Hintergrund.
- Lernen/Geräte laden nur im Menü `Lernen`.
- Discovery lädt nur im Menü `Discovery & Einrichtung`.
- Aktorwerte laden erst beim Öffnen der Detailansicht.
- Kontextvorschläge laden erst auf Nutzeraktion.
## Hinweis zur Performance-Anzeige
Die App zeigt keine echte HA/Ingress-Navigationszeit an. Gemessen werden nur
einzelne interne Abrufe nach Start der Seite. Aussagen zur gesamten Ladezeit
müssen über Browser/Ingress oder HA-Messung geprüft werden.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.2 Operating Guide
v1.5.2 begrenzt den Rollback-Speicher und entschärft Home-Assistant-Timeouts,
die in den Add-on-Logs sichtbar wurden.
## Rollback-Speicher
- Pro Aktor bleiben maximal 3 Modell-Snapshots erhalten.
- Pro Snapshot bleiben maximal 120 Muster erhalten.
- Beim Speichern eines Aktors werden ältere oder zu große Snapshots automatisch
gekappt.
- Der kompakte Detail-Endpunkt liefert ebenfalls maximal 3 Rollback-Snapshots
und keine Musterlisten.
Damit bleibt Rollback nutzbar, ohne dass die JSON-Dateien mit alten Modellen
stark wachsen.
## Home-Assistant-Zugriffe
- REST-Zugriffe auf Home Assistant haben jetzt standardmäßig 25 Sekunden
Timeout statt 10 Sekunden.
- Der Wert ist über `SILLYHOME_HA_TIMEOUT_SECONDS` konfigurierbar.
- WebSocket-Keepalive wurde auf 30 Sekunden Ping-Intervall und 30 Sekunden
Ping-Timeout entschärft.
## Log-Einordnung
- `GET ... HTTP/1.1` ist bei Uvicorn/HA-Ingress normal und kein Fehler.
- `Zeitüberschreitung beim Zugriff auf Home Assistant` bedeutet, dass HA selbst
zu langsam geantwortet hat oder der Ingress/Netzpfad verzögert war.
- `keepalive ping timeout` bedeutet, dass die HA-WebSocket-Verbindung nicht
rechtzeitig geantwortet hat. SillyHome reconnectet automatisch.

View File

@@ -0,0 +1,37 @@
# SillyHome Next v1.5.3 Operating Guide
v1.5.3 führt eine SQLite-Cache-Schicht für Ingress-Dashboarddaten ein.
## Ziel
Die Ingress-Seite soll nicht bei jedem Aufruf live Home Assistant abfragen.
Home-Assistant-Daten werden geplant aktualisiert und lokal gelesen.
## SQLite-Cache
- Cache-Datei: `<actuator_store>/dashboard_cache.sqlite3`
- Tabelle `ha_entities`: aktuelle HA-Entity-Summaries als JSON
- Tabelle `cache_meta`: Aktualisierungszeitpunkt und Discovery-Gruppen
Dashboard-APIs lesen bevorzugt aus SQLite. Der alte JSON-Cache bleibt als
Fallback erhalten.
## Aktualisierung
- Beim App-Start läuft ein Hintergrund-Refresh nach kurzer Verzögerung.
- Danach läuft der Refresh stündlich.
- Konfiguration: `SILLYHOME_DASHBOARD_CACHE_REFRESH_SECONDS`
- Mindestwert: 300 Sekunden.
- Explizite Discovery aktualisiert SQLite und JSON-Fallback.
## Schaltpfad
Das direkte Schalten bleibt unverändert: Safety prüft lokale Daten, danach geht
der Home-Assistant-Service-Call direkt raus. Der Dashboard-Cache liegt nicht im
Schaltpfad.
## Noch offen
Diese Version verschiebt Entity-/Discovery-Daten in SQLite. Die vollständige
Migration aller Aktor-Konfigurationen und Workflows aus JSON in relationale
Tabellen ist ein größerer Folgeschritt und muss mit Migrationsplan erfolgen.

View 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.

View File

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

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from fastapi.testclient import TestClient
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore
@@ -146,6 +147,7 @@ def _install_service(tmp_path: Path) -> None:
)
app.state.registry = ModelRegistry(tmp_path / "models")
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
app.state.dashboard_cache = DashboardCache(tmp_path / "actuators" / "dashboard_cache.sqlite3")
app.state.ha_reader = FakeHaReader(
entities,
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
@@ -446,14 +448,17 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
dashboard_response = client.get("/v1/actuators/dashboard")
start_response = client.get("/v1/actuators/dashboard/start")
system_response = client.get("/v1/actuators/dashboard/system")
anomalies_response = client.get("/v1/actuators/anomalies")
assert dashboard_response.status_code == 200
assert start_response.status_code == 200
assert system_response.status_code == 200
system = dashboard_response.json()["system"]
start_payload = start_response.json()
assert start_payload["jobs"]["jobs"] == []
assert start_payload["discovery_groups"] == []
assert system_response.json()["actuators"] == []
assert system["performance_budget_ms"] == 3000
assert system["slow_job_count"] == 1
assert system["performance_status"] == "slow"
@@ -462,6 +467,23 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
assert anomalies_response.json()
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> 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"})
response = client.get("/v1/actuators/light.abstellkammer/detail")
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["patterns"] == []
assert all(
snapshot["patterns"] == []
for snapshot in payload["behavior"]["model_snapshots"]
)
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)

View File

@@ -16,3 +16,10 @@ def test_addon_version_invalidates_application_build_layer() -> None:
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
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

View File

@@ -17,6 +17,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text
assert '<option value="detail">Details</option>' not in response.text
assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
@@ -24,7 +25,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" in response.text
assert "Davon erkannte HA-Automationen" in response.text
assert "Erkannte HA-Automationen" in response.text
assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text
@@ -42,5 +43,7 @@ def test_dashboard_is_served_at_root() -> None:
assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' 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 "Manuelle Overrides" not in response.text

View File

@@ -92,11 +92,11 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
except asyncio.CancelledError:
pass
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=20,
ping_timeout=10,
)
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=30,
ping_timeout=30,
)
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},