restore runtime state in dispatcher

This commit is contained in:
Josh Hawkins 2026-05-27 12:38:38 -05:00
parent 5e7db736d2
commit 57280a1d9e

View File

@ -3,11 +3,13 @@
import datetime import datetime
import json import json
import logging import logging
from collections.abc import Iterable
from typing import Any, Callable, Optional, cast from typing import Any, Callable, Optional, cast
from frigate.camera import PTZMetrics from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator from frigate.comms.base_communicator import Communicator
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient from frigate.comms.webpush import WebPushClient
from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
@ -67,6 +69,7 @@ class Dispatcher:
self.embeddings_reindex: dict[str, Any] = {} self.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {} self.birdseye_layout: dict[str, Any] = {}
self.audio_transcription_state: str = "idle" self.audio_transcription_state: str = "idle"
self._runtime_state = RuntimeStatePersistence()
self._camera_settings_handlers: dict[str, Callable] = { self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command, "audio": self._on_audio_command,
"audio_transcription": self._on_audio_transcription_command, "audio_transcription": self._on_audio_transcription_command,
@ -397,6 +400,62 @@ class Dispatcher:
for comm in self.comms: for comm in self.comms:
comm.stop() comm.stop()
def restore_runtime_state(self) -> None:
"""Replay persisted runtime overrides through the camera settings handlers.
Called once after Frigate startup completes so processing threads can
receive the resulting ``config_updater`` broadcasts. Unknown cameras
and topics are skipped; handler exceptions are logged and replay
continues for remaining entries.
"""
state = self._runtime_state.load()
for camera_name, features in state.items():
if camera_name not in self.config.cameras:
continue
if not isinstance(features, dict):
continue
for topic, value in features.items():
handler = self._camera_settings_handlers.get(topic)
if handler is None:
continue
payload = "ON" if value else "OFF"
try:
handler(camera_name, payload)
except Exception:
logger.exception(
"Failed to restore runtime state %s.%s=%s",
camera_name,
topic,
payload,
)
continue
logger.info(
"Restored runtime state: %s.%s=%s",
camera_name,
topic,
payload,
)
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Clear stored runtime overrides for YAML keys that were just rewritten.
Called by ``/api/config/set`` after a successful YAML save so an
explicit settings-UI save isn't silently overridden by an older
runtime toggle on the next restart.
"""
self._runtime_state.clear_for_yaml_keys(dotted_keys)
def clear_runtime_state(self) -> None:
"""Wipe every stored runtime override.
Called when a profile is activated or deactivated. A profile switch
changes the layer below the runtime overrides, so the stored
"steady state" is no longer valid and must be reset; otherwise a
subsequent restart would replay stale overrides on top of the new
profile-derived in-memory state.
"""
self._runtime_state.clear_all()
def _on_detect_command(self, camera_name: str, payload: str) -> None: def _on_detect_command(self, camera_name: str, payload: str) -> None:
"""Callback for detect topic.""" """Callback for detect topic."""
detect_settings = self.config.cameras[camera_name].detect detect_settings = self.config.cameras[camera_name].detect
@ -428,6 +487,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name), CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
detect_settings, detect_settings,
) )
self._runtime_state.set(camera_name, "detect", detect_settings.enabled)
self.publish(f"{camera_name}/detect/state", payload, retain=True) self.publish(f"{camera_name}/detect/state", payload, retain=True)
def _on_enabled_command(self, camera_name: str, payload: str) -> None: def _on_enabled_command(self, camera_name: str, payload: str) -> None:
@ -452,6 +512,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name), CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
camera_settings.enabled, camera_settings.enabled,
) )
self._runtime_state.set(camera_name, "enabled", camera_settings.enabled)
self.publish(f"{camera_name}/enabled/state", payload, retain=True) self.publish(f"{camera_name}/enabled/state", payload, retain=True)
def _on_motion_command(self, camera_name: str, payload: str) -> None: def _on_motion_command(self, camera_name: str, payload: str) -> None:
@ -614,6 +675,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name), CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
audio_settings, audio_settings,
) )
self._runtime_state.set(camera_name, "audio", audio_settings.enabled)
self.publish(f"{camera_name}/audio/state", payload, retain=True) self.publish(f"{camera_name}/audio/state", payload, retain=True)
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None: def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
@ -670,6 +732,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name), CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
record_settings, record_settings,
) )
self._runtime_state.set(camera_name, "recordings", record_settings.enabled)
self.publish(f"{camera_name}/recordings/state", payload, retain=True) self.publish(f"{camera_name}/recordings/state", payload, retain=True)
def _on_snapshots_command(self, camera_name: str, payload: str) -> None: def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
@ -689,6 +752,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name), CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
snapshots_settings, snapshots_settings,
) )
self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled)
self.publish(f"{camera_name}/snapshots/state", payload, retain=True) self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None: def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None: