Add SQLite dashboard cache
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

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

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: