diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py index d37829f22..da9a53fcf 100644 --- a/frigate/config/camera/profile.py +++ b/frigate/config/camera/profile.py @@ -24,6 +24,7 @@ class CameraProfileConfig(FrigateBaseModel): explicitly-set fields are used as overrides via exclude_unset. """ + enabled: Optional[bool] = None audio: Optional[AudioConfig] = None birdseye: Optional[BirdseyeCameraConfig] = None detect: Optional[DetectConfig] = None diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 07daab82a..2e771b244 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -43,12 +43,14 @@ class ProfileManager: self.config: FrigateConfig = config self.config_updater = config_updater self._base_configs: dict[str, dict[str, dict]] = {} + self._base_enabled: dict[str, bool] = {} self._snapshot_base_configs() def _snapshot_base_configs(self) -> None: - """Snapshot each camera's current section configs as the base.""" + """Snapshot each camera's current section configs and enabled state.""" for cam_name, cam_config in self.config.cameras.items(): self._base_configs[cam_name] = {} + self._base_enabled[cam_name] = cam_config.enabled for section in PROFILE_SECTION_UPDATES: section_config = getattr(cam_config, section, None) if section_config is not None: @@ -97,6 +99,13 @@ class ProfileManager: def _reset_to_base(self, changed: dict[str, set[str]]) -> None: """Reset all cameras to their base (no-profile) config.""" for cam_name, cam_config in self.config.cameras.items(): + # Restore enabled state + base_enabled = self._base_enabled.get(cam_name) + if base_enabled is not None and cam_config.enabled != base_enabled: + cam_config.enabled = base_enabled + changed.setdefault(cam_name, set()).add("enabled") + + # Restore section configs base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: base_data = base.get(section) @@ -122,6 +131,11 @@ class ProfileManager: if profile is None: continue + # Apply enabled override + if profile.enabled is not None and cam_config.enabled != profile.enabled: + cam_config.enabled = profile.enabled + changed.setdefault(cam_name, set()).add("enabled") + base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: @@ -152,6 +166,15 @@ class ProfileManager: continue for section in sections: + if section == "enabled": + self.config_updater.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.enabled, cam_name + ), + cam_config.enabled, + ) + continue + update_enum = PROFILE_SECTION_UPDATES.get(section) if update_enum is None: continue diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 55bfd09fa..72a2d91e6 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -53,6 +53,23 @@ class TestCameraProfileConfig(unittest.TestCase): assert profile.review is not None assert profile.review.alerts.labels == ["person", "car"] + def test_enabled_field(self): + """Profile with enabled set to False.""" + profile = CameraProfileConfig(enabled=False) + assert profile.enabled is False + dumped = profile.model_dump(exclude_unset=True) + assert dumped == {"enabled": False} + + def test_enabled_field_true(self): + """Profile with enabled set to True.""" + profile = CameraProfileConfig(enabled=True) + assert profile.enabled is True + + def test_enabled_default_none(self): + """Enabled defaults to None when not set.""" + profile = CameraProfileConfig() + assert profile.enabled is None + def test_none_sections_not_in_dump(self): """Sections left as None should not appear in exclude_unset dump.""" profile = CameraProfileConfig(detect={"enabled": False}) @@ -330,6 +347,66 @@ class TestProfileManager(unittest.TestCase): # Back camera has no "disarmed" profile, should be unchanged assert self.config.cameras["back"].notifications.enabled == back_base_notifications + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile_disables_camera(self, mock_persist): + """Profile with enabled=false disables the camera.""" + # Add a profile that disables the front camera + from frigate.config.camera.profile import CameraProfileConfig + + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + # Re-create manager to pick up new profile + self.manager = ProfileManager(self.config, self.mock_updater) + + assert self.config.cameras["front"].enabled is True + err = self.manager.activate_profile("away") + assert err is None + assert self.config.cameras["front"].enabled is False + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_restores_enabled(self, mock_persist): + """Deactivating a profile restores the camera's base enabled state.""" + from frigate.config.camera.profile import CameraProfileConfig + + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + self.manager.activate_profile("away") + assert self.config.cameras["front"].enabled is False + + self.manager.activate_profile(None) + assert self.config.cameras["front"].enabled is True + + @patch.object(ProfileManager, "_persist_active_profile") + def test_enabled_zmq_published(self, mock_persist): + """ZMQ update is published for enabled state change.""" + from frigate.config.camera.profile import CameraProfileConfig + from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, + ) + + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + self.manager = ProfileManager(self.config, self.mock_updater) + self.mock_updater.reset_mock() + + self.manager.activate_profile("away") + + # Find the enabled update call + enabled_calls = [ + call + for call in self.mock_updater.publish_update.call_args_list + if call[0][0] + == CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, "front") + ] + assert len(enabled_calls) == 1 + assert enabled_calls[0][0][1] is False + @patch.object(ProfileManager, "_persist_active_profile") def test_zmq_updates_published(self, mock_persist): """ZMQ updates are published when a profile is activated."""