From c2e667c0dd87c0f035da2e786c98252e1b391a93 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:45:39 -0600 Subject: [PATCH] Add dynamic configuration for more fields (#22295) * face recognition dynamic config * lpr dynamic config * safe changes for birdseye dynamic config * bird classification dynamic config * always assign new config to stats emitter to make telemetry fields dynamic * add wildcard support for camera config updates in config_set * update restart required fields for global sections * add test * fix rebase issue * collapsible settings sidebar use the preexisting control available with shadcn's sidebar (cmd/ctrl-B) to give users more space to set masks/zones on smaller screens * dynamic ffmpeg * ensure previews dir exists when ffmpeg processes restart, there's a brief window where the preview frame generation pipeline is torn down and restarted. before these changes, ffmpeg only restarted on crash/stall recovery or full Frigate restart. Now that ffmpeg restarts happen on-demand via config changes, there's a higher chance a frontend request hits the preview_mp4 or preview_gif endpoints during that brief restart window when the directory might not exist yet. The existing os.listdir() call would throw FileNotFoundError without a directory existence check. this fix just checks if the directory exists and returns 404 if not, exactly how preview_thumbnail already handles the same scenario a few lines below * global ffmpeg section * clean up * tweak * fix test --- frigate/api/app.py | 37 ++- frigate/api/media.py | 14 + frigate/config/camera/camera.py | 8 + frigate/config/camera/updater.py | 4 + frigate/data_processing/post/license_plate.py | 6 + frigate/data_processing/real_time/face.py | 6 + .../real_time/license_plate.py | 6 + frigate/embeddings/maintainer.py | 69 +++++ frigate/output/birdseye.py | 8 +- frigate/output/output.py | 17 ++ frigate/test/http_api/test_http_config_set.py | 261 ++++++++++++++++++ frigate/video.py | 31 ++- .../config-form/section-configs/audio.ts | 9 +- .../config-form/section-configs/birdseye.ts | 3 - .../section-configs/classification.ts | 2 +- .../config-form/section-configs/detect.ts | 11 +- .../section-configs/face_recognition.ts | 13 +- .../config-form/section-configs/ffmpeg.ts | 23 +- .../config-form/section-configs/lpr.ts | 16 +- .../config-form/section-configs/motion.ts | 13 +- .../config-form/section-configs/objects.ts | 2 +- .../config-form/section-configs/record.ts | 11 +- .../config-form/section-configs/review.ts | 2 +- .../config-form/section-configs/snapshots.ts | 9 +- .../config-form/section-configs/telemetry.ts | 9 +- .../config-form/sections/BaseSection.tsx | 8 +- web/src/pages/Settings.tsx | 18 +- web/src/utils/configUtil.ts | 17 ++ web/src/views/settings/UiSettingsView.tsx | 2 +- 29 files changed, 489 insertions(+), 146 deletions(-) create mode 100644 frigate/test/http_api/test_http_config_set.py diff --git a/frigate/api/app.py b/frigate/api/app.py index a28f174de..2472b5080 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -589,23 +589,38 @@ 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 field == "add": - settings = config.cameras[camera] - elif field == "remove": - settings = old_config.cameras[camera] + 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, + ) else: - settings = config.get_nested_object(body.update_topic) + if field == "add": + settings = config.cameras[camera] + elif field == "remove": + settings = old_config.cameras[camera] + else: + 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) diff --git a/frigate/api/media.py b/frigate/api/media.py index 2ddabc631..903cf60c0 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -1281,6 +1281,13 @@ 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}" @@ -1456,6 +1463,13 @@ 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}" diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 21397065b..9960abdce 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -242,6 +242,14 @@ 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) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 44aea527d..0c49ec465 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -17,6 +17,7 @@ class CameraConfigUpdateEnum(str, Enum): birdseye = "birdseye" detect = "detect" enabled = "enabled" + ffmpeg = "ffmpeg" motion = "motion" # includes motion and motion masks notifications = "notifications" objects = "objects" @@ -91,6 +92,9 @@ 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: diff --git a/frigate/data_processing/post/license_plate.py b/frigate/data_processing/post/license_plate.py index e95cf234e..9f8d72975 100644 --- a/frigate/data_processing/post/license_plate.py +++ b/frigate/data_processing/post/license_plate.py @@ -12,6 +12,7 @@ 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, @@ -47,6 +48,11 @@ 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: diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index e1c11bf11..408a0456e 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -19,6 +19,7 @@ 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, @@ -95,6 +96,11 @@ 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) diff --git a/frigate/data_processing/real_time/license_plate.py b/frigate/data_processing/real_time/license_plate.py index 59c625de2..5c098369f 100644 --- a/frigate/data_processing/real_time/license_plate.py +++ b/frigate/data_processing/real_time/license_plate.py @@ -8,6 +8,7 @@ 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, ) @@ -40,6 +41,11 @@ 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], diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 8e45af498..b85f231c0 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -99,6 +99,13 @@ 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( @@ -273,6 +280,9 @@ 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() @@ -284,6 +294,9 @@ 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() @@ -356,6 +369,62 @@ 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""" diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index d3717d281..cdadafe71 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -273,17 +273,13 @@ 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.inactivity_threshold = config.birdseye.inactivity_threshold - - if config.birdseye.layout.max_cameras: - self.last_refresh_time = 0 + 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) @@ -426,7 +422,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.inactivity_threshold + < self.config.birdseye.inactivity_threshold ] ) logger.debug(f"Active cameras: {active_cameras}") diff --git a/frigate/output/output.py b/frigate/output/output.py index 38b1ddc52..83962e1c9 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -15,6 +15,7 @@ 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 @@ -138,6 +139,7 @@ class OutputProcess(FrigateProcess): CameraConfigUpdateEnum.record, ], ) + birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) jsmpeg_cameras: dict[str, JsmpegCamera] = {} birdseye: Birdseye | None = None @@ -167,6 +169,20 @@ 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() @@ -297,6 +313,7 @@ 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() diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py new file mode 100644 index 000000000..48b1ac2c7 --- /dev/null +++ b/frigate/test/http_api/test_http_config_set.py @@ -0,0 +1,261 @@ +"""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() diff --git a/frigate/video.py b/frigate/video.py index 5e42619dd..67da0a664 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -214,7 +214,11 @@ class CameraWatchdog(threading.Thread): self.config_subscriber = CameraConfigUpdateSubscriber( None, {config.name: config}, - [CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record], + [ + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.ffmpeg, + CameraConfigUpdateEnum.record, + ], ) self.requestor = InterProcessRequestor() self.was_enabled = self.config.enabled @@ -254,9 +258,13 @@ 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.config_subscriber.check_for_updates() + self._check_config_updates() return self.config.enabled def reset_capture_thread( @@ -317,7 +325,24 @@ class CameraWatchdog(threading.Thread): # 1 second watchdog loop while not self.stop_event.wait(1): - enabled = self._update_enabled_state() + 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 if enabled != self.was_enabled: if enabled: self.logger.debug(f"Enabling camera {self.config.name}") diff --git a/web/src/components/config-form/section-configs/audio.ts b/web/src/components/config-form/section-configs/audio.ts index 81ddb9b0a..09fe4e974 100644 --- a/web/src/components/config-form/section-configs/audio.ts +++ b/web/src/components/config-form/section-configs/audio.ts @@ -25,14 +25,7 @@ const audio: SectionConfigOverrides = { }, }, global: { - restartRequired: [ - "enabled", - "listen", - "filters", - "min_volume", - "max_not_heard", - "num_threads", - ], + restartRequired: ["num_threads"], }, camera: { restartRequired: ["num_threads"], diff --git a/web/src/components/config-form/section-configs/birdseye.ts b/web/src/components/config-form/section-configs/birdseye.ts index 7df38edd8..63fae75d9 100644 --- a/web/src/components/config-form/section-configs/birdseye.ts +++ b/web/src/components/config-form/section-configs/birdseye.ts @@ -28,10 +28,7 @@ const birdseye: SectionConfigOverrides = { "width", "height", "quality", - "mode", "layout.scaling_factor", - "inactivity_threshold", - "layout.max_cameras", "idle_heartbeat_fps", ], uiSchema: { diff --git a/web/src/components/config-form/section-configs/classification.ts b/web/src/components/config-form/section-configs/classification.ts index 015f723bd..8a59e151c 100644 --- a/web/src/components/config-form/section-configs/classification.ts +++ b/web/src/components/config-form/section-configs/classification.ts @@ -3,7 +3,7 @@ import type { SectionConfigOverrides } from "./types"; const classification: SectionConfigOverrides = { base: { sectionDocs: "/configuration/custom_classification/object_classification", - restartRequired: ["bird.enabled", "bird.threshold"], + restartRequired: ["bird.enabled"], hiddenFields: ["custom"], advancedFields: [], }, diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 2c3da7b06..b0073d1d3 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -30,16 +30,7 @@ const detect: SectionConfigOverrides = { ], }, global: { - restartRequired: [ - "enabled", - "width", - "height", - "fps", - "min_initialized", - "max_disappeared", - "annotation_offset", - "stationary", - ], + restartRequired: ["width", "height", "min_initialized", "max_disappeared"], }, camera: { restartRequired: ["width", "height", "min_initialized", "max_disappeared"], diff --git a/web/src/components/config-form/section-configs/face_recognition.ts b/web/src/components/config-form/section-configs/face_recognition.ts index 2f5b2dd46..18e963940 100644 --- a/web/src/components/config-form/section-configs/face_recognition.ts +++ b/web/src/components/config-form/section-configs/face_recognition.ts @@ -32,18 +32,7 @@ const faceRecognition: SectionConfigOverrides = { "blur_confidence_filter", "device", ], - restartRequired: [ - "enabled", - "model_size", - "unknown_score", - "detection_threshold", - "recognition_threshold", - "min_area", - "min_faces", - "save_attempts", - "blur_confidence_filter", - "device", - ], + restartRequired: ["enabled", "model_size", "device"], }, }; diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index ccbca5609..d0d426a6a 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -116,16 +116,7 @@ const ffmpeg: SectionConfigOverrides = { }, }, global: { - restartRequired: [ - "path", - "global_args", - "hwaccel_args", - "input_args", - "output_args", - "retry_interval", - "apple_compatibility", - "gpu", - ], + restartRequired: [], fieldOrder: [ "hwaccel_args", "path", @@ -162,17 +153,7 @@ const ffmpeg: SectionConfigOverrides = { fieldGroups: { cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"], }, - restartRequired: [ - "inputs", - "path", - "global_args", - "hwaccel_args", - "input_args", - "output_args", - "retry_interval", - "apple_compatibility", - "gpu", - ], + restartRequired: [], }, }; diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index 3e2561f64..c5e16eb23 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -40,21 +40,7 @@ const lpr: SectionConfigOverrides = { "device", "replace_rules", ], - 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", - ], + restartRequired: ["model_size", "enhancement", "device"], uiSchema: { format: { "ui:options": { size: "md" }, diff --git a/web/src/components/config-form/section-configs/motion.ts b/web/src/components/config-form/section-configs/motion.ts index edc0b6e14..bab142d92 100644 --- a/web/src/components/config-form/section-configs/motion.ts +++ b/web/src/components/config-form/section-configs/motion.ts @@ -31,18 +31,7 @@ const motion: SectionConfigOverrides = { ], }, global: { - restartRequired: [ - "enabled", - "threshold", - "lightning_threshold", - "skip_motion_threshold", - "improve_contrast", - "contour_area", - "delta_alpha", - "frame_alpha", - "frame_height", - "mqtt_off_delay", - ], + restartRequired: ["frame_height"], }, camera: { restartRequired: ["frame_height"], diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index a70746c49..bf5f6c350 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = { }, }, global: { - restartRequired: ["track", "alert", "detect", "filters", "genai"], + restartRequired: [], hiddenFields: [ "enabled_in_config", "mask", diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index c47d67ad0..53803eed9 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -29,16 +29,7 @@ const record: SectionConfigOverrides = { }, }, global: { - restartRequired: [ - "enabled", - "expire_interval", - "continuous", - "motion", - "alerts", - "detections", - "preview", - "export", - ], + restartRequired: [], }, camera: { restartRequired: [], diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index bb3ec4ca4..7d7a84756 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -44,7 +44,7 @@ const review: SectionConfigOverrides = { }, }, global: { - restartRequired: ["alerts", "detections", "genai"], + restartRequired: [], }, camera: { restartRequired: [], diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index b098d84a5..8f08fa843 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -27,14 +27,7 @@ const snapshots: SectionConfigOverrides = { }, }, global: { - restartRequired: [ - "enabled", - "bounding_box", - "crop", - "quality", - "timestamp", - "retain", - ], + restartRequired: [], hiddenFields: ["enabled_in_config", "required_zones"], }, camera: { diff --git a/web/src/components/config-form/section-configs/telemetry.ts b/web/src/components/config-form/section-configs/telemetry.ts index f197e3bcf..20003a497 100644 --- a/web/src/components/config-form/section-configs/telemetry.ts +++ b/web/src/components/config-form/section-configs/telemetry.ts @@ -3,14 +3,7 @@ import type { SectionConfigOverrides } from "./types"; const telemetry: SectionConfigOverrides = { base: { sectionDocs: "/configuration/reference", - restartRequired: [ - "network_interfaces", - "stats.amd_gpu_stats", - "stats.intel_gpu_stats", - "stats.intel_gpu_device", - "stats.network_bandwidth", - "version_check", - ], + restartRequired: ["version_check"], fieldOrder: ["network_interfaces", "stats", "version_check"], advancedFields: [], }, diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 821857ae6..047edd449 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -56,6 +56,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { cameraUpdateTopicMap, + globalCameraDefaultSections, buildOverrides, buildConfigDataForPath, sanitizeSectionData as sharedSanitizeSectionData, @@ -234,7 +235,10 @@ export function ConfigSection({ ? cameraUpdateTopicMap[sectionPath] ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` : undefined - : `config/${sectionPath}`; + : globalCameraDefaultSections.has(sectionPath) && + cameraUpdateTopicMap[sectionPath] + ? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}` + : `config/${sectionPath}`; // Default: show title for camera level (since it might be collapsible), hide for global const shouldShowTitle = showTitle ?? effectiveLevel === "camera"; @@ -827,7 +831,7 @@ export function ConfigSection({
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index ea6c3d650..2f069da78 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -479,14 +479,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [ "regionGrid", ]; -const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"]; - -const LARGE_BOTTOM_MARGIN_PAGES = [ - "masksAndZones", - "motionTuner", - "mediaSync", - "regionGrid", -]; +const ALLOWED_VIEWS_FOR_VIEWER = ["profileSettings", "notifications"]; // keys for camera sections const CAMERA_SECTION_MAPPING: Record = { @@ -1362,9 +1355,9 @@ export default function Settings() { )}
- - - + + + {settingsGroups.map((group) => { const filteredItems = group.items.filter((item) => @@ -1452,8 +1445,7 @@ export default function Settings() {
{(() => { diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 9d2327cb3..216ade9fa 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -54,6 +54,20 @@ export const cameraUpdateTopicMap: Record = { 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 // --------------------------------------------------------------------------- @@ -476,6 +490,9 @@ 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}`; } diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 5b04aa0fe..60aaffabf 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -210,7 +210,7 @@ export default function UiSettingsView() { ]; return ( -
+
{t("general.title")}