Compare commits

..

2 Commits

Author SHA1 Message Date
a7a2f8c78a Make SillyHome startup resilient 2026-06-16 13:43:41 +02:00
faf4099756 Load actuator suggestions asynchronously 2026-06-16 12:14:51 +02:00
5 changed files with 65 additions and 15 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 0.7.15 - 2026-06-16
- Add-on-Start ist robust gegen Home-Assistant-Core-502 beim Systemboot:
API und WebSocket-Listener starten trotzdem, Reconciliation/Training werden
im Hintergrund mit Retry nachgeholt.
- Periodische Reconciliation und Fallback-Auswertung beenden den Dienst nicht
mehr bei temporären HA-Fehlern.
- Add-on-Watchdog prüft `/health`, damit Supervisor den Dienst nach Absturz
wieder starten kann.
## 0.7.14 - 2026-06-16
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
## 0.7.13 - 2026-06-16 ## 0.7.13 - 2026-06-16
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur - Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.13" version: "0.7.15"
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
@@ -7,6 +7,7 @@ arch:
- amd64 - amd64
startup: application startup: application
boot: auto boot: auto
watchdog: http://[HOST]:[PORT:8000]/health
init: false init: false
ingress: true ingress: true
ingress_port: 8000 ingress_port: 8000

View File

@@ -43,6 +43,7 @@ class _WsStatus:
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None client: HaClient | None = None
startup_task: asyncio.Task[None] | None = None
reconcile_task: asyncio.Task[None] | None = None reconcile_task: asyncio.Task[None] | None = None
event_listener_task: asyncio.Task[None] | None = None event_listener_task: asyncio.Task[None] | None = None
fallback_task: asyncio.Task[None] | None = None fallback_task: asyncio.Task[None] | None = None
@@ -74,15 +75,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=settings, settings=settings,
) )
app.state.ws_status = _WsStatus() app.state.ws_status = _WsStatus()
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup") startup_task = asyncio.create_task(_startup_reconciliation(app))
await asyncio.to_thread(app.state.behavior_engine.train_all)
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
reconcile_task = asyncio.create_task(_periodic_reconciliation(app)) reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client)) event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
fallback_task = asyncio.create_task(_fallback_prediction(app)) fallback_task = asyncio.create_task(_fallback_prediction(app))
try: try:
yield yield
finally: finally:
if startup_task is not None:
startup_task.cancel()
with suppress(asyncio.CancelledError):
await startup_task
if reconcile_task is not None: if reconcile_task is not None:
reconcile_task.cancel() reconcile_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
@@ -102,7 +105,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="0.7.13", version="0.7.15",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -147,10 +150,39 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
service = getattr(app.state, "actuator_service", None) service = getattr(app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):
continue continue
try:
await asyncio.to_thread(service.reconcile_all, "scheduled") await asyncio.to_thread(service.reconcile_all, "scheduled")
engine = getattr(app.state, "behavior_engine", None) engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine): if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.train_all)
except Exception:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
async def _startup_reconciliation(app: FastAPI) -> None:
delay_seconds = 5
while True:
service = getattr(app.state, "actuator_service", None)
engine = getattr(app.state, "behavior_engine", None)
if not isinstance(service, ActuatorReconciliationService) or not isinstance(
engine,
BehaviorEngine,
):
return
try:
await asyncio.to_thread(service.reconcile_all, "startup")
await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
return
except Exception as exc:
logger.warning(
"Startup-Reconciliation verschoben: %s. Neuer Versuch in %ss.",
exc,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
delay_seconds = min(delay_seconds * 2, 60)
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None: async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
@@ -243,7 +275,11 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket") logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc: except Exception as exc:
logger.exception("Fehler bei Event-Verarbeitung: %s", exc) logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
except (websockets.exceptions.ConnectionClosed, OSError) as exc: except (
websockets.exceptions.ConnectionClosed,
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc) logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
@@ -280,7 +316,10 @@ async def _fallback_prediction(app: FastAPI) -> None:
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)", "Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
ws_status.status if ws_status else "unavailable", ws_status.status if ws_status else "unavailable",
) )
try:
await asyncio.to_thread(engine.evaluate_all) await asyncio.to_thread(engine.evaluate_all)
except Exception:
logger.exception("Fallback-Vorhersage fehlgeschlagen.")
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]: def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:

View File

@@ -305,11 +305,8 @@ async function loadOverview() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([ await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
loadActuatorDiscovery(), void loadActuatorSuggestions();
loadActuatorSuggestions(),
loadConfiguredActuators(),
]);
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {

View File

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