From c89ce88afc94b3518d8c9b7faa97cc8e179f7bdc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:19:38 -0500 Subject: [PATCH] fix birdseye camera overrides being clobbered by a global mode change A global birdseye save published only the global object, leaving the output process to infer which cameras were inheriting by comparing against the previous global mode. That cannot tell an inherited value from an explicit one that happens to match, so it overwrote the override until a restart. Publish the per-camera values the config parse already resolved instead. --- frigate/api/app.py | 16 +++++- frigate/api/config_util.py | 24 ++++++++ frigate/output/output.py | 9 +-- frigate/test/http_api/test_http_config_set.py | 56 +++++++++++++++++++ 4 files changed, 98 insertions(+), 7 deletions(-) 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.