mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-07 03:11:15 +03:00
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:
@@ -908,6 +908,11 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# drop runtime overrides for any fields the user just rewrote in
|
||||
# yaml so a stale override doesn't silently win after restart
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = config
|
||||
|
||||
+8
-1
@@ -348,7 +348,11 @@ class FrigateApp:
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
self.profile_manager.activate_profile(persisted)
|
||||
# don't clear runtime overrides here, restore_runtime_state() later
|
||||
# in startup replays it on top of the activated profile
|
||||
self.profile_manager.activate_profile(
|
||||
persisted, clear_runtime_overrides=False
|
||||
)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
@@ -612,6 +616,9 @@ class FrigateApp:
|
||||
self.start_record_cleanup()
|
||||
self.start_watchdog()
|
||||
|
||||
# restore persisted runtime overrides on top of config
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.init_auth()
|
||||
|
||||
try:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -124,11 +124,24 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
def activate_profile(self, profile_name: Optional[str]) -> Optional[str]:
|
||||
# drop all runtime overrides so they don't replay stale values on restart
|
||||
if self.dispatcher is not None:
|
||||
self.dispatcher.clear_runtime_state()
|
||||
|
||||
def activate_profile(
|
||||
self,
|
||||
profile_name: Optional[str],
|
||||
clear_runtime_overrides: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Activate a profile by name, or deactivate if None.
|
||||
|
||||
Args:
|
||||
profile_name: Profile name to activate, or None to deactivate.
|
||||
clear_runtime_overrides: When True (the default, for user-initiated
|
||||
activations) drop the dispatcher's runtime override file because
|
||||
the layer below changed. Startup callers that are replaying a
|
||||
persisted profile pass False so the runtime state stays
|
||||
available for the subsequent replay step.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
@@ -156,6 +169,11 @@ class ProfileManager:
|
||||
|
||||
self.config.active_profile = profile_name
|
||||
self._persist_active_profile(profile_name)
|
||||
|
||||
# a profile switch invalidates the steady-state runtime overrides
|
||||
if clear_runtime_overrides and self.dispatcher is not None:
|
||||
self.dispatcher.clear_runtime_state()
|
||||
|
||||
logger.info(
|
||||
"Profile %s",
|
||||
f"'{profile_name}' activated" if profile_name else "deactivated",
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for Dispatcher runtime state persistence wiring."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
|
||||
def _make_camera_mock(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
enabled_in_config: bool = True,
|
||||
detect_enabled: bool = True,
|
||||
record_enabled: bool = True,
|
||||
record_enabled_in_config: bool = True,
|
||||
snapshots_enabled: bool = True,
|
||||
audio_enabled: bool = True,
|
||||
audio_enabled_in_config: bool = True,
|
||||
) -> MagicMock:
|
||||
"""Build a camera config mock with the fields the in-scope handlers read."""
|
||||
camera = MagicMock()
|
||||
camera.enabled = enabled
|
||||
camera.enabled_in_config = enabled_in_config
|
||||
camera.detect.enabled = detect_enabled
|
||||
camera.motion.enabled = True # avoid the detect→motion side-effect path
|
||||
camera.record.enabled = record_enabled
|
||||
camera.record.enabled_in_config = record_enabled_in_config
|
||||
camera.snapshots.enabled = snapshots_enabled
|
||||
camera.audio.enabled = audio_enabled
|
||||
camera.audio.enabled_in_config = audio_enabled_in_config
|
||||
return camera
|
||||
|
||||
|
||||
def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
|
||||
"""Construct a Dispatcher with the bare-minimum mocks the tests need."""
|
||||
config = MagicMock()
|
||||
config.cameras = cameras
|
||||
config_updater = MagicMock()
|
||||
onvif = MagicMock()
|
||||
ptz_metrics: dict = {}
|
||||
communicators: list = []
|
||||
|
||||
with (
|
||||
patch("frigate.comms.dispatcher.CameraActivityManager"),
|
||||
patch("frigate.comms.dispatcher.AudioActivityManager"),
|
||||
):
|
||||
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
|
||||
|
||||
|
||||
class TestRestoreRuntimeState(unittest.TestCase):
|
||||
"""Verify replay routes through handlers and tolerates missing entries."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.dispatcher = _build_dispatcher(
|
||||
{
|
||||
"front_door": _make_camera_mock(),
|
||||
"back_yard": _make_camera_mock(),
|
||||
}
|
||||
)
|
||||
# Swap each in-scope handler for a MagicMock so we can assert calls
|
||||
# without exercising the handler's own logic.
|
||||
self.handler_mocks: dict[str, MagicMock] = {}
|
||||
for topic in ("enabled", "detect", "snapshots", "recordings", "audio"):
|
||||
mock = MagicMock()
|
||||
self.dispatcher._camera_settings_handlers[topic] = mock
|
||||
self.handler_mocks[topic] = mock
|
||||
|
||||
def test_replays_each_stored_entry_through_its_handler(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(
|
||||
return_value={
|
||||
"front_door": {"detect": False, "recordings": False},
|
||||
"back_yard": {"audio": False},
|
||||
}
|
||||
),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["audio"].assert_called_once_with("back_yard", "OFF")
|
||||
self.handler_mocks["enabled"].assert_not_called()
|
||||
self.handler_mocks["snapshots"].assert_not_called()
|
||||
|
||||
def test_skips_unknown_cameras(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"removed_cam": {"detect": False}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
for mock in self.handler_mocks.values():
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_skips_unknown_topics(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"front_door": {"some_old_topic": True}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
for mock in self.handler_mocks.values():
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_continues_after_handler_exception(self) -> None:
|
||||
self.handler_mocks["detect"].side_effect = RuntimeError("boom")
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(
|
||||
return_value={
|
||||
"front_door": {"detect": False, "recordings": False},
|
||||
}
|
||||
),
|
||||
)
|
||||
# Must not raise; the recordings handler must still run.
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
|
||||
|
||||
def test_true_value_routes_as_on_payload(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"front_door": {"detect": True}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
|
||||
|
||||
|
||||
class TestHandlersPersistViaSet(unittest.TestCase):
|
||||
"""Verify each in-scope handler writes to the runtime state on success."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.mkdtemp()
|
||||
self.config_path = os.path.join(self.tmp_dir, "config.yml")
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write("")
|
||||
self._patcher = patch(
|
||||
"frigate.comms.runtime_state.find_config_file",
|
||||
return_value=self.config_path,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
# Start with everything OFF so each ON payload triggers a real change
|
||||
self.cameras = {
|
||||
"front_door": _make_camera_mock(
|
||||
enabled=False,
|
||||
detect_enabled=False,
|
||||
record_enabled=False,
|
||||
snapshots_enabled=False,
|
||||
audio_enabled=False,
|
||||
)
|
||||
}
|
||||
self.dispatcher = _build_dispatcher(self.cameras)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patcher.stop()
|
||||
for name in os.listdir(self.tmp_dir):
|
||||
os.remove(os.path.join(self.tmp_dir, name))
|
||||
os.rmdir(self.tmp_dir)
|
||||
|
||||
def _stored_state(self) -> dict:
|
||||
return RuntimeStatePersistence().load()
|
||||
|
||||
def test_enabled_handler_persists(self) -> None:
|
||||
self.dispatcher._on_enabled_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"enabled": True}})
|
||||
|
||||
def test_detect_handler_persists(self) -> None:
|
||||
self.dispatcher._on_detect_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"detect": True}})
|
||||
|
||||
def test_recordings_handler_persists(self) -> None:
|
||||
self.dispatcher._on_recordings_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"recordings": True}})
|
||||
|
||||
def test_snapshots_handler_persists(self) -> None:
|
||||
self.dispatcher._on_snapshots_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"snapshots": True}})
|
||||
|
||||
def test_audio_handler_persists(self) -> None:
|
||||
self.dispatcher._on_audio_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"audio": True}})
|
||||
|
||||
def test_enabled_in_config_gate_blocks_persistence(self) -> None:
|
||||
"""An ON payload rejected by the gate must not be persisted."""
|
||||
cam = self.cameras["front_door"]
|
||||
cam.enabled_in_config = False
|
||||
cam.record.enabled_in_config = False
|
||||
cam.audio.enabled_in_config = False
|
||||
|
||||
self.dispatcher._on_enabled_command("front_door", "ON")
|
||||
self.dispatcher._on_recordings_command("front_door", "ON")
|
||||
self.dispatcher._on_audio_command("front_door", "ON")
|
||||
|
||||
self.assertEqual(self._stored_state(), {})
|
||||
|
||||
|
||||
class TestClearPassthrough(unittest.TestCase):
|
||||
"""The dispatcher's public clear methods delegate to the store."""
|
||||
|
||||
def test_clear_runtime_state_for_yaml_keys_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
keys = ["cameras.front_door.detect.enabled"]
|
||||
dispatcher.clear_runtime_state_for_yaml_keys(keys)
|
||||
dispatcher._runtime_state.clear_for_yaml_keys.assert_called_once_with(keys)
|
||||
|
||||
def test_clear_runtime_state_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
dispatcher.clear_runtime_state()
|
||||
dispatcher._runtime_state.clear_all.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -727,6 +727,55 @@ class TestProfileManager(unittest.TestCase):
|
||||
# Should not raise
|
||||
json.dumps(api_base)
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activate_profile_clears_dispatcher_runtime_state(self, mock_persist):
|
||||
"""User-initiated activation drops runtime overrides (steady-state rule)."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_deactivate_profile_clears_dispatcher_runtime_state(self, mock_persist):
|
||||
"""Deactivating a profile also drops runtime overrides."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.reset_mock()
|
||||
|
||||
manager.activate_profile(None)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
|
||||
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
|
||||
"""After /api/config/set, an active-profile re-application drops state."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.reset_mock()
|
||||
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
|
||||
"""Plain /api/config/set without a profile doesn't trigger the broad clear."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
# No activate_profile call — config.active_profile is None
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
|
||||
class TestProfilePersistence(unittest.TestCase):
|
||||
"""Test profile persistence to disk."""
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for RuntimeStatePersistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
|
||||
class TestRuntimeStatePersistence(unittest.TestCase):
|
||||
"""Unit tests for the JSON-backed runtime state store."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.mkdtemp()
|
||||
self.config_path = os.path.join(self.tmp_dir, "config.yml")
|
||||
# Touch a placeholder config.yml so find_config_file returns a real path
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write("")
|
||||
self._patcher = patch(
|
||||
"frigate.comms.runtime_state.find_config_file",
|
||||
return_value=self.config_path,
|
||||
)
|
||||
self._patcher.start()
|
||||
self.store = RuntimeStatePersistence()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patcher.stop()
|
||||
for name in os.listdir(self.tmp_dir):
|
||||
os.remove(os.path.join(self.tmp_dir, name))
|
||||
os.rmdir(self.tmp_dir)
|
||||
|
||||
def test_load_returns_empty_when_file_missing(self) -> None:
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_set_then_load_round_trip(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", True)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
result = self.store.load()
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"front_door": {"detect": False, "recordings": True},
|
||||
"back_yard": {"audio": False},
|
||||
},
|
||||
)
|
||||
|
||||
def test_set_with_untracked_topic_is_noop(self) -> None:
|
||||
self.store.set("front_door", "ptz_autotracker", True)
|
||||
self.assertEqual(self.store.load(), {})
|
||||
# File should not even be created if no tracked entries were written
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
self.assertFalse(os.path.exists(runtime_path))
|
||||
|
||||
def test_set_overwrites_previous_value(self) -> None:
|
||||
self.store.set("front_door", "detect", True)
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_load_returns_empty_when_file_corrupt(self) -> None:
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
with open(runtime_path, "w") as f:
|
||||
f.write("{not valid json")
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_load_handles_unexpected_top_level_shape(self) -> None:
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
with open(runtime_path, "w") as f:
|
||||
json.dump(["unexpected", "list"], f)
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_for_yaml_keys_removes_matching_entries(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", False)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_for_yaml_keys(
|
||||
[
|
||||
"cameras.front_door.detect.enabled",
|
||||
"cameras.back_yard.audio.enabled",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.store.load(),
|
||||
{"front_door": {"recordings": False}},
|
||||
)
|
||||
|
||||
def test_clear_for_yaml_keys_collapses_empty_camera_dict(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys(["cameras.front_door.detect.enabled"])
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_for_yaml_keys_ignores_unrelated_keys(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys(
|
||||
[
|
||||
"ui.theme",
|
||||
"go2rtc.streams.x",
|
||||
"cameras.front_door.ffmpeg.inputs",
|
||||
"not_cameras.front_door.detect.enabled",
|
||||
]
|
||||
)
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_clear_for_yaml_keys_handles_empty_iterable(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys([])
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_camera_level_enabled_uses_top_level_yaml_key(self) -> None:
|
||||
"""`enabled` topic maps to the camera-level `cameras.<cam>.enabled` key."""
|
||||
self.store.set("front_door", "enabled", False)
|
||||
self.store.clear_for_yaml_keys(["cameras.front_door.enabled"])
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_all_wipes_every_entry(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", True)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_all()
|
||||
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_all_is_safe_when_file_missing(self) -> None:
|
||||
# No prior set() calls — file does not exist
|
||||
self.store.clear_all()
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user