Restore runtime state on startup (#23326)

* add class

* restore runtime state in dispatcher

* restore on startup with special case for profile

* add tests

* update docs

* mypy
This commit is contained in:
Josh Hawkins
2026-05-27 12:03:09 -06:00
committed by GitHub
parent 2858662be9
commit e9ef4f978a
11 changed files with 676 additions and 8 deletions
+62
View File
@@ -3,11 +3,13 @@
import datetime
import json
import logging
from collections.abc import Iterable
from typing import Any, Callable, Optional, cast
from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.updater import (
@@ -67,6 +69,7 @@ class Dispatcher:
self.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {}
self.audio_transcription_state: str = "idle"
self._runtime_state = RuntimeStatePersistence()
self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command,
"audio_transcription": self._on_audio_transcription_command,
@@ -397,6 +400,60 @@ class Dispatcher:
for comm in self.comms:
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
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:
"""Callback for detect topic."""
detect_settings = self.config.cameras[camera_name].detect
@@ -428,6 +485,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
detect_settings,
)
self._runtime_state.set(camera_name, "detect", detect_settings.enabled)
self.publish(f"{camera_name}/detect/state", payload, retain=True)
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
@@ -452,6 +510,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
camera_settings.enabled,
)
self._runtime_state.set(camera_name, "enabled", camera_settings.enabled)
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
def _on_motion_command(self, camera_name: str, payload: str) -> None:
@@ -614,6 +673,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
audio_settings,
)
self._runtime_state.set(camera_name, "audio", audio_settings.enabled)
self.publish(f"{camera_name}/audio/state", payload, retain=True)
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
@@ -670,6 +730,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
record_settings,
)
self._runtime_state.set(camera_name, "recordings", record_settings.enabled)
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
@@ -689,6 +750,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
snapshots_settings,
)
self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled)
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None:
+163
View File
@@ -0,0 +1,163 @@
"""Persistence layer for dispatcher runtime state overrides."""
import json
import logging
import os
from collections.abc import Iterable
from typing import Any
from filelock import FileLock, Timeout
from frigate.util.config import find_config_file
logger = logging.getLogger(__name__)
class RuntimeStatePersistence:
"""Persist last-known runtime states for dispatcher toggles.
Stores boolean overrides applied to camera-level toggles by the dispatcher.
Overrides are replayed at startup on top of the YAML-derived in-memory
config, so changes made via MQTT or the live-view UI survive a restart.
"""
# Maps dispatcher topic name -> YAML key suffix under cameras.<cam>
TRACKED_TOPICS: dict[str, str] = {
"enabled": "enabled",
"detect": "detect.enabled",
"snapshots": "snapshots.enabled",
"recordings": "record.enabled",
"audio": "audio.enabled",
}
_SUFFIX_TO_TOPIC: dict[str, str] = {v: k for k, v in TRACKED_TOPICS.items()}
def __init__(self) -> None:
self._path = os.path.join(
os.path.dirname(find_config_file()), ".runtime_state.json"
)
self._lock_path = f"{self._path}.lock"
self._lock_timeout = 5
def load(self) -> dict[str, dict[str, bool]]:
"""Return {camera: {topic: bool}} or {} if missing/corrupt."""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
except Timeout:
logger.error("Timed out acquiring runtime state lock for load")
return {}
cameras = data.get("cameras", {})
if not isinstance(cameras, dict):
return {}
# Filter out malformed camera entries so callers can trust the shape.
return {
name: features
for name, features in cameras.items()
if isinstance(features, dict)
}
def set(self, camera: str, topic: str, value: bool) -> None:
"""Persist a single (camera, topic, value). No-op if topic untracked."""
if topic not in self.TRACKED_TOPICS:
return
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.setdefault("cameras", {})
if not isinstance(cameras, dict):
cameras = {}
data["cameras"] = cameras
cam = cameras.setdefault(camera, {})
if not isinstance(cam, dict):
cam = {}
cameras[camera] = cam
cam[topic] = bool(value)
self._write_locked(data)
except Timeout:
logger.error("Timed out persisting runtime state for %s/%s", camera, topic)
except OSError:
logger.exception("Failed to persist runtime state for %s/%s", camera, topic)
def clear_all(self) -> None:
"""Wipe every stored runtime override.
Called when the "layer below" changes in a way that invalidates all
runtime overrides for the current session (currently: profile
activation or deactivation).
"""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
if not os.path.exists(self._path):
return
self._write_locked({"cameras": {}})
except Timeout:
logger.error("Timed out clearing runtime state")
except OSError:
logger.exception("Failed to clear runtime state")
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Remove stored entries whose YAML key was just rewritten.
Each dotted key must be of the form ``cameras.<camera>.<suffix>``.
Keys that don't match a tracked topic are ignored.
"""
to_remove: list[tuple[str, str]] = []
for key in dotted_keys:
parts = key.split(".")
if len(parts) < 3 or parts[0] != "cameras":
continue
camera = parts[1]
suffix = ".".join(parts[2:])
topic = self._SUFFIX_TO_TOPIC.get(suffix)
if topic is not None:
to_remove.append((camera, topic))
if not to_remove:
return
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.get("cameras")
if not isinstance(cameras, dict):
return
changed = False
for camera, topic in to_remove:
cam = cameras.get(camera)
if isinstance(cam, dict) and topic in cam:
del cam[topic]
changed = True
if not cam:
del cameras[camera]
if changed:
self._write_locked(data)
except Timeout:
logger.error("Timed out clearing runtime state for YAML keys")
except OSError:
logger.exception("Failed to clear runtime state for YAML keys")
def _read_locked(self) -> dict[str, Any]:
"""Read the JSON file while the FileLock is held.
Returns ``{}`` on a missing or corrupt file so the caller can write a
fresh structure on the next mutation.
"""
if not os.path.exists(self._path):
return {}
try:
with open(self._path, "r") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
logger.exception(
"Failed to read runtime state file %s; starting fresh", self._path
)
return {}
return data if isinstance(data, dict) else {}
def _write_locked(self, data: dict[str, Any]) -> None:
"""Atomically write the JSON file while the FileLock is held."""
tmp_path = f"{self._path}.tmp"
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
os.replace(tmp_path, self._path)