mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
Compare commits
47 Commits
c430f6e64e
...
ee3913506c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee3913506c | ||
|
|
94dfe15a41 | ||
|
|
cae4385cdc | ||
|
|
d748cf39c9 | ||
|
|
f8c0a84259 | ||
|
|
7af0ecc7e9 | ||
|
|
a593918f28 | ||
|
|
e4e7063a1d | ||
|
|
8f5b494164 | ||
|
|
baf2e56b04 | ||
|
|
26b463571f | ||
|
|
e2bc564bfe | ||
|
|
ff2f119f51 | ||
|
|
6ce61b30ec | ||
|
|
7995e55161 | ||
|
|
a28ddb29fd | ||
|
|
18a5faedca | ||
|
|
506c4191f4 | ||
|
|
f211e4a615 | ||
|
|
6a94378000 | ||
|
|
5307a7d44b | ||
|
|
26e20d293d | ||
|
|
4128d27104 | ||
|
|
4064bef4b8 | ||
|
|
9ce1b921c1 | ||
|
|
eff7680b1a | ||
|
|
b204a05515 | ||
|
|
2ff95d9f42 | ||
|
|
10c5b028db | ||
|
|
35a925f6f6 | ||
|
|
fdb4926850 | ||
|
|
7570feb69c | ||
|
|
079995239e | ||
|
|
ee28c400c9 | ||
|
|
f2c108199b | ||
|
|
e42cd9ac67 | ||
|
|
4616ab6395 | ||
|
|
32a263e421 | ||
|
|
a77cbc4323 | ||
|
|
8718ed2770 | ||
|
|
d5991e66b2 | ||
|
|
0ce5bd3014 | ||
|
|
241943f66c | ||
|
|
aa928503ec | ||
|
|
190d34fae9 | ||
|
|
80fc5e296a | ||
|
|
de1bd9ac23 |
@ -589,38 +589,23 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
|
||||
if request.app.stats_emitter is not None:
|
||||
request.app.stats_emitter.config = config
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
if camera == "*":
|
||||
# Wildcard: fan out update to all cameras
|
||||
enum_value = CameraConfigUpdateEnum[field]
|
||||
for camera_name in config.cameras:
|
||||
settings = config.get_nested_object(
|
||||
f"config/cameras/{camera_name}/{field}"
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(enum_value, camera_name),
|
||||
settings,
|
||||
)
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum[field], camera
|
||||
),
|
||||
settings,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum[field], camera
|
||||
),
|
||||
settings,
|
||||
)
|
||||
else:
|
||||
# Generic handling for global config updates
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
@ -1281,13 +1281,6 @@ def preview_gif(
|
||||
else:
|
||||
# need to generate from existing images
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
if not os.path.isdir(preview_dir):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
file_start = f"preview_{camera_name}"
|
||||
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
@ -1463,13 +1456,6 @@ def preview_mp4(
|
||||
else:
|
||||
# need to generate from existing images
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
if not os.path.isdir(preview_dir):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
file_start = f"preview_{camera_name}"
|
||||
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
|
||||
@ -242,14 +242,6 @@ class CameraConfig(FrigateBaseModel):
|
||||
def create_ffmpeg_cmds(self):
|
||||
if "_ffmpeg_cmds" in self:
|
||||
return
|
||||
self._build_ffmpeg_cmds()
|
||||
|
||||
def recreate_ffmpeg_cmds(self):
|
||||
"""Force regeneration of ffmpeg commands from current config."""
|
||||
self._build_ffmpeg_cmds()
|
||||
|
||||
def _build_ffmpeg_cmds(self):
|
||||
"""Build ffmpeg commands from the current ffmpeg config."""
|
||||
ffmpeg_cmds = []
|
||||
for ffmpeg_input in self.ffmpeg.inputs:
|
||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||
|
||||
@ -17,7 +17,6 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
birdseye = "birdseye"
|
||||
detect = "detect"
|
||||
enabled = "enabled"
|
||||
ffmpeg = "ffmpeg"
|
||||
motion = "motion" # includes motion and motion masks
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
@ -92,9 +91,6 @@ class CameraConfigUpdateSubscriber:
|
||||
|
||||
if update_type == CameraConfigUpdateEnum.audio:
|
||||
config.audio = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.ffmpeg:
|
||||
config.ffmpeg = updated_config
|
||||
config.recreate_ffmpeg_cmds()
|
||||
elif update_type == CameraConfigUpdateEnum.audio_transcription:
|
||||
config.audio_transcription = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.birdseye:
|
||||
|
||||
@ -12,7 +12,6 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
||||
from frigate.data_processing.common.license_plate.mixin import (
|
||||
WRITE_DEBUG_IMAGES,
|
||||
LicensePlateProcessingMixin,
|
||||
@ -48,11 +47,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
super().__init__(config, metrics, model_runner)
|
||||
|
||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
||||
"""Update LPR config at runtime."""
|
||||
self.lpr_config = lpr_config
|
||||
logger.debug("LPR config updated dynamically")
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
|
||||
@ -19,7 +19,6 @@ from frigate.comms.event_metadata_updater import (
|
||||
)
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import FaceRecognitionConfig
|
||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||
from frigate.data_processing.common.face.model import (
|
||||
ArcFaceRecognizer,
|
||||
@ -96,11 +95,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
self.recognizer.build()
|
||||
|
||||
def update_config(self, face_config: FaceRecognitionConfig) -> None:
|
||||
"""Update face recognition config at runtime."""
|
||||
self.face_config = face_config
|
||||
logger.debug("Face recognition config updated dynamically")
|
||||
|
||||
def __download_models(self, path: str) -> None:
|
||||
try:
|
||||
file_name = os.path.basename(path)
|
||||
|
||||
@ -8,7 +8,6 @@ import numpy as np
|
||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
||||
from frigate.data_processing.common.license_plate.mixin import (
|
||||
LicensePlateProcessingMixin,
|
||||
)
|
||||
@ -41,11 +40,6 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
||||
self.camera_current_cars: dict[str, list[str]] = {}
|
||||
super().__init__(config, metrics)
|
||||
|
||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
||||
"""Update LPR config at runtime."""
|
||||
self.lpr_config = lpr_config
|
||||
logger.debug("LPR config updated dynamically")
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
obj_data: dict[str, Any],
|
||||
|
||||
@ -99,13 +99,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.classification_config_subscriber = ConfigSubscriber(
|
||||
"config/classification/custom/"
|
||||
)
|
||||
self.bird_classification_config_subscriber = ConfigSubscriber(
|
||||
"config/classification", exact=True
|
||||
)
|
||||
self.face_recognition_config_subscriber = ConfigSubscriber(
|
||||
"config/face_recognition", exact=True
|
||||
)
|
||||
self.lpr_config_subscriber = ConfigSubscriber("config/lpr", exact=True)
|
||||
|
||||
# Configure Frigate DB
|
||||
db = SqliteVecQueueDatabase(
|
||||
@ -280,9 +273,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
while not self.stop_event.is_set():
|
||||
self.config_updater.check_for_updates()
|
||||
self._check_classification_config_updates()
|
||||
self._check_bird_classification_config_updates()
|
||||
self._check_face_recognition_config_updates()
|
||||
self._check_lpr_config_updates()
|
||||
self._process_requests()
|
||||
self._process_updates()
|
||||
self._process_recordings_updates()
|
||||
@ -294,9 +284,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
self.config_updater.stop()
|
||||
self.classification_config_subscriber.stop()
|
||||
self.bird_classification_config_subscriber.stop()
|
||||
self.face_recognition_config_subscriber.stop()
|
||||
self.lpr_config_subscriber.stop()
|
||||
self.event_subscriber.stop()
|
||||
self.event_end_subscriber.stop()
|
||||
self.recordings_subscriber.stop()
|
||||
@ -369,62 +356,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
||||
)
|
||||
|
||||
def _check_bird_classification_config_updates(self) -> None:
|
||||
"""Check for bird classification config updates."""
|
||||
topic, classification_config = (
|
||||
self.bird_classification_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
self.config.classification = classification_config
|
||||
logger.debug("Applied dynamic bird classification config update")
|
||||
|
||||
def _check_face_recognition_config_updates(self) -> None:
|
||||
"""Check for face recognition config updates."""
|
||||
topic, face_config = self.face_recognition_config_subscriber.check_for_update()
|
||||
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
previous_min_area = self.config.face_recognition.min_area
|
||||
self.config.face_recognition = face_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.face_recognition.min_area == previous_min_area:
|
||||
camera_config.face_recognition.min_area = face_config.min_area
|
||||
|
||||
for processor in self.realtime_processors:
|
||||
if isinstance(processor, FaceRealTimeProcessor):
|
||||
processor.update_config(face_config)
|
||||
|
||||
logger.debug("Applied dynamic face recognition config update")
|
||||
|
||||
def _check_lpr_config_updates(self) -> None:
|
||||
"""Check for LPR config updates."""
|
||||
topic, lpr_config = self.lpr_config_subscriber.check_for_update()
|
||||
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
previous_min_area = self.config.lpr.min_area
|
||||
self.config.lpr = lpr_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.lpr.min_area == previous_min_area:
|
||||
camera_config.lpr.min_area = lpr_config.min_area
|
||||
|
||||
for processor in self.realtime_processors:
|
||||
if isinstance(processor, LicensePlateRealTimeProcessor):
|
||||
processor.update_config(lpr_config)
|
||||
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, LicensePlatePostProcessor):
|
||||
processor.update_config(lpr_config)
|
||||
|
||||
logger.debug("Applied dynamic LPR config update")
|
||||
|
||||
def _process_requests(self) -> None:
|
||||
"""Process embeddings requests"""
|
||||
|
||||
|
||||
@ -273,13 +273,17 @@ class BirdsEyeFrameManager:
|
||||
stop_event: mp.Event,
|
||||
):
|
||||
self.config = config
|
||||
self.mode = config.birdseye.mode
|
||||
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
||||
self.frame_shape = (height, width)
|
||||
self.yuv_shape = (height * 3 // 2, width)
|
||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
|
||||
self.stop_event = stop_event
|
||||
self.last_refresh_time = 0
|
||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
||||
|
||||
if config.birdseye.layout.max_cameras:
|
||||
self.last_refresh_time = 0
|
||||
|
||||
# initialize the frame as black and with the Frigate logo
|
||||
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
||||
@ -422,7 +426,7 @@ class BirdsEyeFrameManager:
|
||||
and self.config.cameras[cam].enabled
|
||||
and cam_data["last_active_frame"] > 0
|
||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
||||
< self.config.birdseye.inactivity_threshold
|
||||
< self.inactivity_threshold
|
||||
]
|
||||
)
|
||||
logger.debug(f"Active cameras: {active_cameras}")
|
||||
|
||||
@ -15,7 +15,6 @@ from ws4py.server.wsgirefserver import (
|
||||
)
|
||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.ws import WebSocket
|
||||
from frigate.config import FrigateConfig
|
||||
@ -139,7 +138,6 @@ class OutputProcess(FrigateProcess):
|
||||
CameraConfigUpdateEnum.record,
|
||||
],
|
||||
)
|
||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||
|
||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||
birdseye: Birdseye | None = None
|
||||
@ -169,20 +167,6 @@ class OutputProcess(FrigateProcess):
|
||||
websocket_thread.start()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
update_topic, birdseye_config = (
|
||||
birdseye_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if update_topic is not None:
|
||||
previous_global_mode = self.config.birdseye.mode
|
||||
self.config.birdseye = birdseye_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.birdseye.mode == previous_global_mode:
|
||||
camera_config.birdseye.mode = birdseye_config.mode
|
||||
|
||||
logger.debug("Applied dynamic birdseye config update")
|
||||
|
||||
# check if there is an updated config
|
||||
updates = config_subscriber.check_for_updates()
|
||||
|
||||
@ -313,7 +297,6 @@ class OutputProcess(FrigateProcess):
|
||||
birdseye.stop()
|
||||
|
||||
config_subscriber.stop()
|
||||
birdseye_config_subscriber.stop()
|
||||
websocket_server.manager.close_all()
|
||||
websocket_server.manager.stop()
|
||||
websocket_server.manager.join()
|
||||
|
||||
@ -1,261 +0,0 @@
|
||||
"""Tests for the config_set endpoint's wildcard camera propagation."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
"""Test that wildcard camera updates fan out to all cameras."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
},
|
||||
"back_yard": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 720,
|
||||
"width": 1280,
|
||||
"fps": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _create_app_with_publisher(self):
|
||||
"""Create app with a mocked config publisher."""
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
return {"username": username, "role": role}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return list(self.minimal_config.get("cameras", {}).keys())
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
return app, mock_publisher
|
||||
|
||||
def _write_config_file(self):
|
||||
"""Write the minimal config to a temp YAML file and return the path."""
|
||||
yaml = ruamel.yaml.YAML()
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||
yaml.dump(self.minimal_config, f)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_wildcard_detect_update_fans_out_to_all_cameras(self, mock_find_config):
|
||||
"""config/cameras/*/detect fans out to all cameras."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"detect": {"fps": 15}},
|
||||
"update_topic": "config/cameras/*/detect",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
# Verify publish_update called for each camera
|
||||
self.assertEqual(mock_publisher.publish_update.call_count, 2)
|
||||
|
||||
published_cameras = set()
|
||||
for c in mock_publisher.publish_update.call_args_list:
|
||||
topic = c[0][0]
|
||||
self.assertIsInstance(topic, CameraConfigUpdateTopic)
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
|
||||
published_cameras.add(topic.camera)
|
||||
|
||||
self.assertEqual(published_cameras, {"front_door", "back_yard"})
|
||||
|
||||
# Global publisher should NOT be called for wildcard
|
||||
mock_publisher.publisher.publish.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_wildcard_motion_update_fans_out(self, mock_find_config):
|
||||
"""config/cameras/*/motion fans out to all cameras."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"motion": {"threshold": 30}},
|
||||
"update_topic": "config/cameras/*/motion",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
published_cameras = set()
|
||||
for c in mock_publisher.publish_update.call_args_list:
|
||||
topic = c[0][0]
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.motion)
|
||||
published_cameras.add(topic.camera)
|
||||
|
||||
self.assertEqual(published_cameras, {"front_door", "back_yard"})
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_camera_specific_topic_only_updates_one_camera(self, mock_find_config):
|
||||
"""config/cameras/front_door/detect only updates front_door."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {
|
||||
"cameras": {"front_door": {"detect": {"fps": 20}}}
|
||||
},
|
||||
"update_topic": "config/cameras/front_door/detect",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Only one camera updated
|
||||
self.assertEqual(mock_publisher.publish_update.call_count, 1)
|
||||
topic = mock_publisher.publish_update.call_args[0][0]
|
||||
self.assertEqual(topic.camera, "front_door")
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
|
||||
|
||||
# Global publisher should NOT be called
|
||||
mock_publisher.publisher.publish.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_wildcard_sends_merged_per_camera_config(self, mock_find_config):
|
||||
"""Wildcard fan-out sends each camera's own merged config."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"detect": {"fps": 15}},
|
||||
"update_topic": "config/cameras/*/detect",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for c in mock_publisher.publish_update.call_args_list:
|
||||
camera_detect_config = c[0][1]
|
||||
self.assertIsNotNone(camera_detect_config)
|
||||
self.assertTrue(hasattr(camera_detect_config, "fps"))
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_non_camera_global_topic_uses_generic_publish(self, mock_find_config):
|
||||
"""Non-camera topics (e.g. config/live) use the generic publisher."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"live": {"height": 720}},
|
||||
"update_topic": "config/live",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Global topic publisher called
|
||||
mock_publisher.publisher.publish.assert_called_once()
|
||||
|
||||
# Camera-level publish_update NOT called
|
||||
mock_publisher.publish_update.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -151,9 +151,7 @@ def sync_recordings(
|
||||
|
||||
max_inserts = 1000
|
||||
for batch in chunked(recordings_to_delete, max_inserts):
|
||||
RecordingsToDelete.insert_many(
|
||||
[{"id": r["id"]} for r in batch]
|
||||
).execute()
|
||||
RecordingsToDelete.insert_many(batch).execute()
|
||||
|
||||
try:
|
||||
deleted = (
|
||||
|
||||
@ -214,11 +214,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{config.name: config},
|
||||
[
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.record,
|
||||
],
|
||||
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record],
|
||||
)
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.was_enabled = self.config.enabled
|
||||
@ -258,13 +254,9 @@ class CameraWatchdog(threading.Thread):
|
||||
self._last_record_status = status
|
||||
self._last_status_update_time = now
|
||||
|
||||
def _check_config_updates(self) -> dict[str, list[str]]:
|
||||
"""Check for config updates and return the update dict."""
|
||||
return self.config_subscriber.check_for_updates()
|
||||
|
||||
def _update_enabled_state(self) -> bool:
|
||||
"""Fetch the latest config and update enabled state."""
|
||||
self._check_config_updates()
|
||||
self.config_subscriber.check_for_updates()
|
||||
return self.config.enabled
|
||||
|
||||
def reset_capture_thread(
|
||||
@ -325,24 +317,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# 1 second watchdog loop
|
||||
while not self.stop_event.wait(1):
|
||||
updates = self._check_config_updates()
|
||||
|
||||
# Handle ffmpeg config changes by restarting all ffmpeg processes
|
||||
if "ffmpeg" in updates and self.config.enabled:
|
||||
self.logger.debug(
|
||||
"FFmpeg config updated for %s, restarting ffmpeg processes",
|
||||
self.config.name,
|
||||
)
|
||||
self.stop_all_ffmpeg()
|
||||
self.start_all_ffmpeg()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
continue
|
||||
|
||||
enabled = self.config.enabled
|
||||
enabled = self._update_enabled_state()
|
||||
if enabled != self.was_enabled:
|
||||
if enabled:
|
||||
self.logger.debug(f"Enabling camera {self.config.name}")
|
||||
|
||||
@ -264,7 +264,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Llindar del llamp",
|
||||
"description": "Llindar per detectar i ignorar les puntes d'il·luminació breu (més baixes són més sensibles, valors entre 0,3 i 1,0). Això no impedeix la detecció de moviment per complet; simplement fa que el detector deixi d'analitzar fotogrames addicionals una vegada que el llindar s'excedeix. Els enregistraments basats en moviment encara es creen durant aquests esdeveniments."
|
||||
"description": "Llindar per detectar i ignorar les puntes d'il·luminació breu (més baixes són més sensibles, valors entre 0,3 i 1,0)."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Millora el contrast",
|
||||
@ -300,10 +300,6 @@
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Màscara en brut"
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "Omet el llindar de moviment",
|
||||
"description": "Si més d'aquesta fracció de la imatge canvia en un sol fotograma, el detector no retornarà cap caixa de moviment i recalibrarà immediatament. Això pot estalviar CPU i reduir falsos positius durant el llamp, tempestes, etc., però pot perdre esdeveniments reals com una càmera PTZ que fa un seguiment automàtic d'un objecte. La compensació es troba entre deixar caure uns quants megabytes d'enregistraments versus revisar un parell de clips curts. Interval de 0,0 a 1,0."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
@ -868,8 +864,7 @@
|
||||
"description": "Un nom fàcil d'utilitzar per a la zona, que es mostra a la interfície d'usuari de la fragata. Si no s'estableix, s'utilitzarà una versió amb format del nom de la zona."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Habilitat",
|
||||
"description": "Activa o desactiva aquesta zona. Les zones inhabilitades s'ignoren en temps d'execució."
|
||||
"label": "Si aquesta zona està activa. Les zones inhabilitades s'ignoren en temps d'execució."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Feu un seguiment de l'estat original de la zona."
|
||||
|
||||
@ -124,7 +124,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Llindar del llamp",
|
||||
"description": "Llindar per detectar i ignorar les puntes d'il·luminació breu (més baixes són més sensibles, valors entre 0,3 i 1,0). Això no impedeix la detecció de moviment per complet; simplement fa que el detector deixi d'analitzar fotogrames addicionals una vegada que el llindar s'excedeix. Els enregistraments basats en moviment encara es creen durant aquests esdeveniments."
|
||||
"description": "Llindar per detectar i ignorar les puntes d'il·luminació breu (més baixes són més sensibles, valors entre 0,3 i 1,0)."
|
||||
},
|
||||
"contour_area": {
|
||||
"label": "Àrea de la vora",
|
||||
@ -156,10 +156,6 @@
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Màscara en brut"
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "Omet el llindar de moviment",
|
||||
"description": "Si més d'aquesta fracció de la imatge canvia en un sol fotograma, el detector no retornarà cap caixa de moviment i recalibrarà immediatament. Això pot estalviar CPU i reduir falsos positius durant el llamp, tempestes, etc., però pot perdre esdeveniments reals com una càmera PTZ que fa un seguiment automàtic d'un objecte. La compensació es troba entre deixar caure uns quants megabytes d'enregistraments versus revisar un parell de clips curts. Interval de 0,0 a 1,0."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
|
||||
@ -63,25 +63,5 @@
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Necessita revisió",
|
||||
"securityConcern": "Preocupació per la seguretat",
|
||||
"select_all": "Tots",
|
||||
"motionSearch": {
|
||||
"menuItem": "Cerca de moviment",
|
||||
"openMenu": "Opcions de la càmera"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "Visualitza les vistes prèvies del moviment",
|
||||
"title": "Vista prèvia del moviment: {{camera}}",
|
||||
"mobileSettingsTitle": "Configuració de la vista prèvia del moviment",
|
||||
"mobileSettingsDesc": "Ajusteu la velocitat de reproducció i l'enfosquiment, i trieu una data per a revisar clips només en moviment.",
|
||||
"dim": "Atenuar",
|
||||
"dimAria": "Ajusta la intensitat de l'enfosquiment",
|
||||
"dimDesc": "Incrementa l'enfosquiment per augmentar la visibilitat de l'àrea de moviment.",
|
||||
"speed": "Velocitat",
|
||||
"speedAria": "Selecciona la velocitat de reproducció de la vista prèvia",
|
||||
"speedDesc": "Trieu la rapidesa amb què es reprodueixen els clips de vista prèvia.",
|
||||
"back": "Enrere",
|
||||
"empty": "No hi ha cap vista prèvia disponible",
|
||||
"noPreview": "Vista prèvia no disponible",
|
||||
"seekAria": "Cerca el reproductor {{camera}} a {{time}}"
|
||||
}
|
||||
"select_all": "Tots"
|
||||
}
|
||||
|
||||
@ -84,9 +84,7 @@
|
||||
"cameraUi": "UI de la càmera",
|
||||
"cameraTimestampStyle": "Estil de la marca horària",
|
||||
"cameraMqtt": "Càmera MQTT",
|
||||
"maintenance": "Manteniment",
|
||||
"mediaSync": "Sincronització multimèdia",
|
||||
"regionGrid": "Quadrícula de la regió"
|
||||
"maintenance": "Manteniment"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@ -1416,16 +1414,6 @@
|
||||
"previews": "Previsualitzacions",
|
||||
"exports": "Exporta",
|
||||
"recordings": "Enregistraments"
|
||||
},
|
||||
"regionGrid": {
|
||||
"title": "Quadrícula de la regió",
|
||||
"desc": "La quadrícula de regions és una optimització que aprèn on solen aparèixer objectes de diferents mides en el camp de visió de cada càmera. Frigate utilitza aquestes dades per detectar regions de mida eficient. La quadrícula es construeix automàticament amb el temps a partir de dades d'objectes rastrejats.",
|
||||
"clear": "Neteja la quadrícula de la regió",
|
||||
"clearConfirmTitle": "Neteja la quadrícula de la regió",
|
||||
"clearConfirmDesc": "No es recomana netejar la quadrícula de la regió tret que hagi canviat recentment la mida del model del detector o hagi canviat la posició física de la càmera i tingui problemes de seguiment d'objectes. La quadrícula es reconstruirà automàticament amb el temps a mesura que els objectes siguin rastrejats. Es requereix un reinici de la fragata perquè els canvis tinguin efecte.",
|
||||
"clearSuccess": "La quadrícula de la regió s'ha netejat correctament",
|
||||
"clearError": "Ha fallat en netejar la graella de la regió",
|
||||
"restartRequired": "Cal reiniciar per a que els canvis de la quadrícula de la regió tinguin efecte"
|
||||
}
|
||||
},
|
||||
"configForm": {
|
||||
|
||||
@ -264,7 +264,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Prag fulger/lumină",
|
||||
"description": "Prag pentru detectarea și ignorarea vârfurilor scurte de lumină (o valoare mai mică este mai sensibilă, valori între 0.3 și 1.0). Acest lucru nu oprește complet detecția mișcării; doar determină detectorul să nu mai analizeze cadre suplimentare odată ce pragul este depășit. Înregistrările bazate pe mișcare sunt create în continuare în timpul acestor evenimente."
|
||||
"description": "Prag pentru a ignora salturile bruște de lumină (sensibilitate între 0.3 și 1.0)."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Îmbunătățire contrast",
|
||||
@ -868,7 +868,7 @@
|
||||
"description": "Un nume ușor de recunoscut pentru zonă, afișat în interfața Frigate. Dacă nu este setat, se va folosi o versiune formatată a numelui zonei."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Activat",
|
||||
"label": "Dacă această zonă este activă. Zonele dezactivate sunt ignorate la rulare.",
|
||||
"description": "Activează sau dezactivează această zonă. Zonele dezactivate sunt ignorate în timpul funcționării."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
|
||||
@ -373,7 +373,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Prag fulger/lumină",
|
||||
"description": "Prag pentru detectarea și ignorarea vârfurilor scurte de lumină (o valoare mai mică este mai sensibilă, valori între 0.3 și 1.0). Acest lucru nu oprește complet detecția mișcării; doar determină detectorul să nu mai analizeze cadre suplimentare odată ce pragul este depășit. Înregistrările bazate pe mișcare sunt create în continuare în timpul acestor evenimente."
|
||||
"description": "Prag pentru a ignora salturile bruște de lumină (sensibilitate între 0.3 și 1.0)."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Îmbunătățire contrast",
|
||||
|
||||
@ -25,7 +25,14 @@ const audio: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["num_threads"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"listen",
|
||||
"filters",
|
||||
"min_volume",
|
||||
"max_not_heard",
|
||||
"num_threads",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["num_threads"],
|
||||
|
||||
@ -28,7 +28,10 @@ const birdseye: SectionConfigOverrides = {
|
||||
"width",
|
||||
"height",
|
||||
"quality",
|
||||
"mode",
|
||||
"layout.scaling_factor",
|
||||
"inactivity_threshold",
|
||||
"layout.max_cameras",
|
||||
"idle_heartbeat_fps",
|
||||
],
|
||||
uiSchema: {
|
||||
|
||||
@ -3,7 +3,7 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const classification: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/custom_classification/object_classification",
|
||||
restartRequired: ["bird.enabled"],
|
||||
restartRequired: ["bird.enabled", "bird.threshold"],
|
||||
hiddenFields: ["custom"],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -30,7 +30,16 @@ const detect: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
|
||||
@ -32,7 +32,18 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
restartRequired: ["enabled", "model_size", "device"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"unknown_score",
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_area",
|
||||
"min_faces",
|
||||
"save_attempts",
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -116,7 +116,16 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
fieldOrder: [
|
||||
"hwaccel_args",
|
||||
"path",
|
||||
@ -153,7 +162,17 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
fieldGroups: {
|
||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||
},
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"inputs",
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -40,7 +40,21 @@ const lpr: SectionConfigOverrides = {
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: ["model_size", "enhancement", "device"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"detection_threshold",
|
||||
"min_area",
|
||||
"recognition_threshold",
|
||||
"min_plate_length",
|
||||
"format",
|
||||
"match_distance",
|
||||
"known_plates",
|
||||
"enhancement",
|
||||
"debug_save_plates",
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
|
||||
@ -31,7 +31,18 @@ const motion: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["frame_height"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"skip_motion_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["frame_height"],
|
||||
|
||||
@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: ["track", "alert", "detect", "filters", "genai"],
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
|
||||
@ -29,7 +29,16 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"expire_interval",
|
||||
"continuous",
|
||||
"motion",
|
||||
"alerts",
|
||||
"detections",
|
||||
"preview",
|
||||
"export",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@ -44,7 +44,7 @@ const review: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: ["alerts", "detections", "genai"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
|
||||
@ -27,7 +27,14 @@ const snapshots: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"retain",
|
||||
],
|
||||
hiddenFields: ["enabled_in_config", "required_zones"],
|
||||
},
|
||||
camera: {
|
||||
|
||||
@ -3,7 +3,14 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const telemetry: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: ["version_check"],
|
||||
restartRequired: [
|
||||
"network_interfaces",
|
||||
"stats.amd_gpu_stats",
|
||||
"stats.intel_gpu_stats",
|
||||
"stats.intel_gpu_device",
|
||||
"stats.network_bandwidth",
|
||||
"version_check",
|
||||
],
|
||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -56,7 +56,6 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
buildConfigDataForPath,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
@ -235,10 +234,7 @@ export function ConfigSection({
|
||||
? cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: undefined
|
||||
: globalCameraDefaultSections.has(sectionPath) &&
|
||||
cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: `config/${sectionPath}`;
|
||||
: `config/${sectionPath}`;
|
||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
|
||||
|
||||
@ -831,7 +827,7 @@ export function ConfigSection({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-t border-secondary bg-background pt-0",
|
||||
"w-full border-t border-secondary bg-background pb-5 pt-0",
|
||||
!noStickyButtons && "sticky bottom-0 z-50",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { usePersistence } from "./use-persistence";
|
||||
import { useUserPersistence } from "./use-user-persistence";
|
||||
@ -12,28 +12,20 @@ export function useOverlayState<S>(
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const locationRef = useRef(location);
|
||||
locationRef.current = location;
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S, replace: boolean = false) => {
|
||||
const loc = locationRef.current;
|
||||
const currentValue = loc.state?.[key] as S | undefined;
|
||||
|
||||
if (Object.is(currentValue, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLocationState = { ...loc.state };
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(loc.pathname + (preserveSearch ? loc.search : ""), {
|
||||
navigate(location.pathname + (preserveSearch ? location.search : ""), {
|
||||
state: newLocationState,
|
||||
replace,
|
||||
});
|
||||
},
|
||||
// locationRef is stable so we don't need it in deps
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[key, navigate, preserveSearch],
|
||||
[key, currentLocationState, navigate],
|
||||
);
|
||||
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
@ -55,9 +47,7 @@ export function usePersistedOverlayState<S extends string>(
|
||||
] {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const locationRef = useRef(location);
|
||||
locationRef.current = location;
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
// currently selected value
|
||||
|
||||
@ -73,21 +63,14 @@ export function usePersistedOverlayState<S extends string>(
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S | undefined, replace: boolean = false) => {
|
||||
const loc = locationRef.current;
|
||||
const currentValue = loc.state?.[key] as S | undefined;
|
||||
|
||||
if (Object.is(currentValue, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedValue(value);
|
||||
const newLocationState = { ...loc.state };
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(loc.pathname, { state: newLocationState, replace });
|
||||
navigate(location.pathname, { state: newLocationState, replace });
|
||||
},
|
||||
// locationRef is stable so we don't need it in deps
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[key, navigate, setPersistedValue],
|
||||
[key, currentLocationState, navigate],
|
||||
);
|
||||
|
||||
return [
|
||||
@ -115,9 +98,7 @@ export function useUserPersistedOverlayState<S extends string>(
|
||||
const { auth } = useContext(AuthContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const locationRef = useRef(location);
|
||||
locationRef.current = location;
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
// currently selected value from URL state
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
@ -131,21 +112,14 @@ export function useUserPersistedOverlayState<S extends string>(
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S | undefined, replace: boolean = false) => {
|
||||
const loc = locationRef.current;
|
||||
const currentValue = loc.state?.[key] as S | undefined;
|
||||
|
||||
if (Object.is(currentValue, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedValue(value);
|
||||
const newLocationState = { ...loc.state };
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(loc.pathname, { state: newLocationState, replace });
|
||||
navigate(location.pathname, { state: newLocationState, replace });
|
||||
},
|
||||
// locationRef is stable so we don't need it in deps
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[key, navigate, setPersistedValue],
|
||||
[key, currentLocationState, navigate, setPersistedValue],
|
||||
);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
@ -168,21 +142,17 @@ export function useHashState<S extends string>(): [
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const locationRef = useRef(location);
|
||||
locationRef.current = location;
|
||||
|
||||
const setHash = useCallback(
|
||||
(value: S | undefined) => {
|
||||
const loc = locationRef.current;
|
||||
if (!value) {
|
||||
navigate(loc.pathname);
|
||||
navigate(location.pathname);
|
||||
} else {
|
||||
navigate(`${loc.pathname}#${value}`, { state: loc.state });
|
||||
navigate(`${location.pathname}#${value}`, { state: location.state });
|
||||
}
|
||||
},
|
||||
// locationRef is stable so we don't need it in deps
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[navigate],
|
||||
[location, navigate],
|
||||
);
|
||||
|
||||
const hash = useMemo(
|
||||
|
||||
@ -479,7 +479,14 @@ const CAMERA_SELECT_BUTTON_PAGES = [
|
||||
"regionGrid",
|
||||
];
|
||||
|
||||
const ALLOWED_VIEWS_FOR_VIEWER = ["profileSettings", "notifications"];
|
||||
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
|
||||
|
||||
const LARGE_BOTTOM_MARGIN_PAGES = [
|
||||
"masksAndZones",
|
||||
"motionTuner",
|
||||
"mediaSync",
|
||||
"regionGrid",
|
||||
];
|
||||
|
||||
// keys for camera sections
|
||||
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
@ -1355,9 +1362,9 @@ export default function Settings() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarProvider className="relative h-full min-h-0 flex-1">
|
||||
<Sidebar variant="inset" className="absolute h-full pl-0 pt-0">
|
||||
<SidebarContent className="scrollbar-container overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
|
||||
<SidebarProvider>
|
||||
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
||||
<SidebarContent className="scrollbar-container mb-24 overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
|
||||
<SidebarMenu>
|
||||
{settingsGroups.map((group) => {
|
||||
const filteredItems = group.items.filter((item) =>
|
||||
@ -1445,7 +1452,8 @@ export default function Settings() {
|
||||
<SidebarInset>
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-container flex-1 overflow-y-auto pl-2 pr-0 pt-2",
|
||||
"scrollbar-container mb-16 flex-1 overflow-y-auto p-2 pr-0",
|
||||
LARGE_BOTTOM_MARGIN_PAGES.includes(pageToggle) && "mb-24",
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
|
||||
@ -54,20 +54,6 @@ export const cameraUpdateTopicMap: Record<string, string> = {
|
||||
ui: "ui",
|
||||
};
|
||||
|
||||
// Sections where global config serves as the default for per-camera config.
|
||||
// Global updates to these sections are fanned out to all cameras via wildcard.
|
||||
export const globalCameraDefaultSections = new Set([
|
||||
"detect",
|
||||
"objects",
|
||||
"motion",
|
||||
"record",
|
||||
"snapshots",
|
||||
"review",
|
||||
"audio",
|
||||
"notifications",
|
||||
"ffmpeg",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildOverrides — pure recursive diff of current vs stored config & defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -490,9 +476,6 @@ export function prepareSectionSavePayload(opts: {
|
||||
if (level === "camera" && cameraName) {
|
||||
const topic = cameraUpdateTopicMap[sectionPath];
|
||||
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
|
||||
} else if (globalCameraDefaultSections.has(sectionPath)) {
|
||||
const topic = cameraUpdateTopicMap[sectionPath];
|
||||
updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
|
||||
} else {
|
||||
updateTopic = `config/${sectionPath}`;
|
||||
}
|
||||
|
||||
@ -632,10 +632,9 @@ export default function DraggableGridLayout({
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name]}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates((prev) => ({
|
||||
...prev,
|
||||
setVolumeStates({
|
||||
[camera.name]: value,
|
||||
}))
|
||||
})
|
||||
}
|
||||
muteAll={muteAll}
|
||||
unmuteAll={unmuteAll}
|
||||
|
||||
@ -131,10 +131,12 @@ export default function MotionSearchView({
|
||||
);
|
||||
|
||||
// Camera previews – defer until dialog is closed
|
||||
const allPreviews = useCameraPreviews(timeRange, {
|
||||
camera: selectedCamera ?? undefined,
|
||||
fetchPreviews: !isSearchDialogOpen,
|
||||
});
|
||||
const allPreviews = useCameraPreviews(
|
||||
isSearchDialogOpen ? { after: 0, before: 0 } : timeRange,
|
||||
{
|
||||
camera: selectedCamera ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// ROI state
|
||||
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);
|
||||
|
||||
@ -210,7 +210,7 @@ export default function UiSettingsView() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="flex size-full flex-col md:pb-8">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h4" className="mb-3">
|
||||
{t("general.title")}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user