fix the model lookup KeyError for cameras added at runtime (#24026)

This commit is contained in:
Josh Hawkins 2026-08-18 16:00:29 -05:00
parent fec73c887e
commit a94b532655
4 changed files with 122 additions and 1 deletions

View File

@ -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

View File

@ -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.

View File

@ -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(

View File

@ -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 = {}