Add SQLite dashboard cache

This commit is contained in:
2026-06-18 00:07:47 +02:00
parent bd087728e1
commit 8b0abba422
9 changed files with 218 additions and 11 deletions

View File

@@ -29,6 +29,8 @@ nach einer ausdrücklichen Freigabe ausführen.
[`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.5.2"
version: "1.5.3"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next

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
@@ -864,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 {}
@@ -875,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")
@@ -897,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

@@ -22,6 +22,7 @@ class Settings:
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:
@@ -57,4 +58,7 @@ def load_settings() -> Settings:
),
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"):
@@ -80,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:
@@ -99,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()
@@ -106,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.5.2",
version="1.5.3",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -160,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:

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

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "1.5.2"
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]},