mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 11:51:53 +03:00
add class
This commit is contained in:
parent
2858662be9
commit
5e7db736d2
156
frigate/comms/runtime_state.py
Normal file
156
frigate/comms/runtime_state.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""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", {})
|
||||||
|
return cameras if isinstance(cameras, dict) else {}
|
||||||
|
|
||||||
|
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)
|
||||||
Loading…
Reference in New Issue
Block a user