diff --git a/frigate/api/app.py b/frigate/api/app.py index 142d4ee0e4..0af3bc1aba 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -31,7 +31,10 @@ from frigate.api.auth import ( get_allowed_cameras_for_filter, require_role, ) -from frigate.api.config_util import swap_runtime_config +from frigate.api.config_util import ( + publish_camera_section_updates, + swap_runtime_config, +) from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import ( AppConfigSetBody, @@ -963,6 +966,17 @@ def config_set(request: Request, body: AppConfigSetBody): body.update_topic, settings ) + # a config/cameras/* topic publishes camera copies, a + # global topic the global object. FrigateConfig.parse + # folds some global sections down into every camera, + # and workers read both objects, so any such section + # needs its camera copies sent alongside the global + # publish above. + if body.update_topic == "config/birdseye": + publish_camera_section_updates( + request.app, config, CameraConfigUpdateEnum.birdseye + ) + return JSONResponse( content=( { diff --git a/frigate/api/config_util.py b/frigate/api/config_util.py index 7c27ab5d78..0cb954af10 100644 --- a/frigate/api/config_util.py +++ b/frigate/api/config_util.py @@ -3,6 +3,30 @@ from fastapi import FastAPI from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, +) + + +def publish_camera_section_updates( + app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum +) -> None: + """Broadcast every camera's re-resolved value for a global section. + + Global sections are folded into each camera at parse time and the camera + copies are what workers read, so send them rather than leave a worker to + guess which cameras were inheriting. + """ + for camera_name, camera_config in config.cameras.items(): + settings = getattr(camera_config, update_type.name, None) + + if settings is None: + continue + + app.config_publisher.publish_update( + CameraConfigUpdateTopic(update_type, camera_name), settings + ) def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: diff --git a/frigate/output/output.py b/frigate/output/output.py index 67dba5221f..0793c1cd15 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess): ) if update_topic is not None and birdseye_config is not None: - previous_global_mode = self.config.birdseye.mode + # only the global-only fields are applied here; the per-camera + # enabled and mode arrive on config/cameras//birdseye, + # already resolved against yaml by the config parse 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 diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index 68d4537165..4e2e851f48 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -374,6 +374,62 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) + @patch("frigate.api.app.find_config_file") + def test_global_birdseye_save_fans_out_resolved_camera_configs( + self, mock_find_config + ): + """A global birdseye save must also publish the per-camera values. + + Global birdseye only seeds enabled and mode; the camera copies are what + the output process actually reads. Sending just the global object makes + a worker guess which cameras were inheriting, and the only available + guess (mode still equals the previous global) wrongly claims a camera + whose explicit yaml mode happens to match. + """ + self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"} + # explicit override that matches the global value being replaced + self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"} + + 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": {"birdseye": {"mode": "continuous"}}, + "update_topic": "config/birdseye", + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + + # the global object still goes out on its own topic + mock_publisher.publisher.publish.assert_called_once() + topic, settings = mock_publisher.publisher.publish.call_args[0] + self.assertEqual(topic, "config/birdseye") + self.assertEqual(settings.mode.value, "continuous") + + published = { + call[0][0].camera: call[0][1] + for call in mock_publisher.publish_update.call_args_list + } + self.assertEqual(set(published), {"front_door", "back_yard"}) + + for call in mock_publisher.publish_update.call_args_list: + self.assertEqual( + call[0][0].update_type, CameraConfigUpdateEnum.birdseye + ) + + # the override survives, the inheriting camera follows global + self.assertEqual(published["front_door"].mode.value, "motion") + self.assertEqual(published["back_yard"].mode.value, "continuous") + finally: + os.unlink(config_path) + @patch("frigate.api.app.find_config_file") def test_save_updates_the_config_holder(self, mock_find_config): """A save must move the holder onto the freshly parsed config.