diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 877f5133ca..4dfbb74172 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -30,6 +30,9 @@ from frigate.util.image import ( logger = logging.getLogger(__name__) +# Minimum seconds to hold the current camera before switching to a different one +CAMERA_HOLD_SECONDS = 5 + @dataclass(frozen=True, slots=True) class BirdseyeActivity: @@ -496,8 +499,11 @@ class BirdsEyeFrameManager: if max_cameras: now = datetime.datetime.now().timestamp() - if len(active_cameras) == max_cameras and now - self.last_refresh_time < 10: - # don't refresh cameras too often + if ( + len(self.active_cameras) == max_cameras + and len(active_cameras) >= max_cameras + and now - self.last_refresh_time < CAMERA_HOLD_SECONDS + ): active_cameras = self.active_cameras else: limited_active_cameras = sorted( diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 372ed5a8a1..12b685afae 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -500,6 +500,80 @@ class TestBirdseyeLiveActivity(unittest.TestCase): assert self.manager.active_cameras == {"front"} +class TestBirdseyeCameraHold(unittest.TestCase): + """Test that CAMERA_HOLD_SECONDS prevents rapid camera switching.""" + + def setUp(self): + config = { + "mqtt": {"enabled": False}, + "birdseye": { + "enabled": True, + "modes": ["motion"], + "inactivity_threshold": 30, + }, + "cameras": { + camera: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + } + for camera in ("back", "front") + }, + } + self.config = FrigateConfig(**config) + self.manager = BirdsEyeFrameManager(self.config, mp.Event()) + + for camera_data in self.manager.cameras.values(): + camera_data["current_frame"] = None + camera_data["current_frame_time"] = 100.0 + camera_data["last_active_frame"] = 0.0 + camera_data["live_active"] = False + + def test_max_cameras_cooldown_applies_when_more_active_than_max(self): + """The max_cameras cooldown should apply even when more cameras are active than max.""" + self.config.birdseye.layout.max_cameras = 1 + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.cameras["front"]["current_frame_time"] = 100.0 + self.manager.update_frame() + assert "front" in self.manager.active_cameras + + self.manager.cameras["back"]["last_active_frame"] = 99.0 + self.manager.cameras["back"]["current_frame_time"] = 100.0 + self.manager.update_frame() + + assert "front" in self.manager.active_cameras + + def test_camera_count_change_ignores_hold(self): + """Adding a camera (count change) should not be blocked by the hold period.""" + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.update_frame() + assert self.manager.active_cameras == {"front"} + + self.manager.cameras["back"]["last_active_frame"] = 99.0 + self.manager.cameras["back"]["current_frame_time"] = 100.0 + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.update_frame() + + assert self.manager.active_cameras == {"front", "back"} + + def test_max_cameras_count_increase_not_blocked_by_hold(self): + """With max_cameras=2 showing 1, a second active camera must appear immediately.""" + self.config.birdseye.layout.max_cameras = 2 + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.cameras["front"]["current_frame_time"] = 100.0 + self.manager.update_frame() + assert self.manager.active_cameras == {"front"} + + self.manager.cameras["back"]["last_active_frame"] = 99.0 + self.manager.cameras["back"]["current_frame_time"] = 100.0 + self.manager.update_frame() + + assert self.manager.active_cameras == {"front", "back"} + + class TestBirdseyeModePayload(unittest.TestCase): """Test the MQTT payload contract for Birdseye activity modes."""