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.
This commit is contained in:
Josh Hawkins
2026-07-28 17:19:38 -05:00
parent 4d52a3d40d
commit c89ce88afc
4 changed files with 98 additions and 7 deletions
+15 -1
View File
@@ -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=(
{
+24
View File
@@ -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:
+3 -6
View File
@@ -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/<name>/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
@@ -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.