diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 27d5ef1255..f560d1bcf4 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -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,62 @@ 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 + 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: """Callback for detect topic.""" detect_settings = self.config.cameras[camera_name].detect @@ -428,6 +487,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 +512,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 +675,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 +732,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 +752,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: