From a94b532655162d66f52a6032391b867d65eeb228 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:00:29 -0500 Subject: [PATCH] fix the model lookup KeyError for cameras added at runtime (#24026) --- frigate/config/camera/updater.py | 1 + frigate/config/config.py | 26 ++++++++++- frigate/test/test_camera_config_updater.py | 52 ++++++++++++++++++++++ frigate/test/test_config.py | 44 ++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 1667bcc8d9..839d97a905 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -96,6 +96,7 @@ class CameraConfigUpdateSubscriber: return elif update_type == CameraConfigUpdateEnum.remove: self.config.cameras.pop(camera, None) + self.config.drop_camera_model(camera) self.camera_configs.pop(camera, None) return diff --git a/frigate/config/config.py b/frigate/config/config.py index 896f97027e..a003e138cc 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -687,13 +687,37 @@ class FrigateConfig(FrigateBaseModel): def model_for_camera(self, camera_name: str) -> ModelConfig: """Get the detection model a camera runs on. + Cameras added at runtime (wizard, clone, debug replay) are inserted + into cameras after parse, so they miss the cache built during + post_validation and are resolved here on first lookup. + Args: camera_name: Name of the camera Returns: The model matching the camera's detect scene """ - return self._camera_models[camera_name] + model = self._camera_models.get(camera_name) + + if model is None: + camera = self.cameras.get(camera_name) + scene = camera.detect.scene if camera is not None else SceneEnum.all + model = self._resolve_camera_model(camera_name, scene) + self._camera_models[camera_name] = model + + return model + + def drop_camera_model(self, camera_name: str) -> None: + """Forget the cached model for a camera removed at runtime. + + A later re-add resolves fresh, so a camera recreated under the same + name with a different detect scene doesn't inherit the removed + camera's model. + + Args: + camera_name: Name of the removed camera + """ + self._camera_models.pop(camera_name, None) def devices_for_model(self, model: ModelConfig) -> list[DeviceSpec]: """Get the parsed hardware devices a model runs on. diff --git a/frigate/test/test_camera_config_updater.py b/frigate/test/test_camera_config_updater.py index 1fbf03f0e5..4ded90c0ba 100644 --- a/frigate/test/test_camera_config_updater.py +++ b/frigate/test/test_camera_config_updater.py @@ -9,6 +9,32 @@ from frigate.config.camera.updater import ( CameraConfigUpdateSubscriber, ) from frigate.const import SUB_CACHE_TAG +from frigate.detectors.detector_config import SceneEnum + + +def _build_scene_frigate_config(scene: str | None) -> FrigateConfig: + detect = {"height": 1080, "width": 1920, "fps": 5} + if scene is not None: + detect["scene"] = scene + return FrigateConfig( + **{ + "mqtt": {"host": "mqtt"}, + "models": [ + {"devices": ["cpu"]}, + {"scene": "outdoor", "devices": ["openvino:CPU"]}, + ], + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": detect, + } + }, + } + ) def _build_camera_config(sub_enabled: bool) -> CameraConfig: @@ -86,6 +112,32 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase): assert not _has_sub_output(camera_config) + @patch("frigate.detectors.detector_config.load_labels") + def test_removed_camera_readded_without_scene_gets_fresh_model(self, mock_labels): + mock_labels.return_value = {} + config = _build_scene_frigate_config("outdoor") + subscriber = CameraConfigUpdateSubscriber( + config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove] + ) + assert config.model_for_camera("front_door").scene == SceneEnum.outdoor + + subscriber.subscriber.check_for_update.side_effect = [ + ("config/cameras/front_door/remove", config.cameras["front_door"]), + (None, None), + ] + subscriber.check_for_updates() + + # recreating the camera through the wizard leaves the scene unset, + # so the removed camera's cached model must not carry over + readded = _build_scene_frigate_config(None).cameras["front_door"] + subscriber.subscriber.check_for_update.side_effect = [ + ("config/cameras/front_door/add", readded), + (None, None), + ] + subscriber.check_for_updates() + + assert config.model_for_camera("front_door").scene == SceneEnum.all + def test_unchanged_record_update_keeps_existing_cmds(self): camera_config = _build_camera_config(sub_enabled=False) subscriber = CameraConfigUpdateSubscriber( diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index a4ca0c5510..0eb571fcc3 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -179,6 +179,50 @@ class TestConfig(unittest.TestCase): assert frigate_config.model_for_camera("back").scene == SceneEnum.all + @patch("frigate.detectors.detector_config.load_labels") + def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels): + mock_labels.return_value = {} + config = { + "models": [ + {"devices": ["cpu"], "width": 320}, + {"scene": "outdoor", "devices": ["openvino:CPU"], "width": 416}, + ], + } + + frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal))) + + # runtime camera adds (wizard, clone, debug replay) insert an already + # resolved camera into the shared config without re-running parse + added = deepcopy(self.minimal) + added["cameras"]["new_cam"] = { + "detect": {"height": 1080, "width": 1920, "fps": 5, "scene": "outdoor"}, + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}, + ] + }, + } + new_config = FrigateConfig(**(deep_merge(deepcopy(config), added))) + frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"] + + assert frigate_config.model_for_camera("new_cam").scene == SceneEnum.outdoor + assert frigate_config.model_for_camera("new_cam").width == 416 + + @patch("frigate.detectors.detector_config.load_labels") + def test_model_for_camera_unknown_camera_uses_default_model(self, mock_labels): + mock_labels.return_value = {} + config = { + "models": [ + {"devices": ["cpu"], "width": 320}, + {"scene": "outdoor", "devices": ["openvino:CPU"], "width": 416}, + ], + } + + frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal))) + + # a caller racing a runtime remove may still name the popped camera + assert frigate_config.model_for_camera("removed").scene == SceneEnum.all + @patch("frigate.detectors.detector_config.load_labels") def test_camera_scene_without_a_model_or_a_default(self, mock_labels): mock_labels.return_value = {}