From 60930e50c2cf581742ac7a3eed6091b1926e0aaa Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:09:35 -0600 Subject: [PATCH] add zones support to camera profiles --- frigate/config/camera/profile.py | 2 + frigate/config/profile_manager.py | 29 +++++++++- frigate/test/test_profiles.py | 93 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py index da9a53fcf..f9510343d 100644 --- a/frigate/config/camera/profile.py +++ b/frigate/config/camera/profile.py @@ -12,6 +12,7 @@ from .objects import ObjectConfig from .record import RecordConfig from .review import ReviewConfig from .snapshots import SnapshotsConfig +from .zone import ZoneConfig __all__ = ["CameraProfileConfig"] @@ -34,3 +35,4 @@ class CameraProfileConfig(FrigateBaseModel): record: Optional[RecordConfig] = None review: Optional[ReviewConfig] = None snapshots: Optional[SnapshotsConfig] = None + zones: Optional[dict[str, ZoneConfig]] = None diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 2e771b244..ebf4bb0c0 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -1,5 +1,6 @@ """Profile manager for activating/deactivating named config profiles.""" +import copy import logging from pathlib import Path from typing import Optional @@ -9,6 +10,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdatePublisher, CameraConfigUpdateTopic, ) +from frigate.config.camera.zone import ZoneConfig from frigate.const import CONFIG_DIR from frigate.util.builtin import deep_merge from frigate.util.config import apply_section_update @@ -44,13 +46,15 @@ class ProfileManager: self.config_updater = config_updater self._base_configs: dict[str, dict[str, dict]] = {} self._base_enabled: dict[str, bool] = {} + self._base_zones: dict[str, dict[str, ZoneConfig]] = {} self._snapshot_base_configs() def _snapshot_base_configs(self) -> None: - """Snapshot each camera's current section configs and enabled state.""" + """Snapshot each camera's current section configs, enabled, and zones.""" for cam_name, cam_config in self.config.cameras.items(): self._base_configs[cam_name] = {} self._base_enabled[cam_name] = cam_config.enabled + self._base_zones[cam_name] = copy.deepcopy(cam_config.zones) for section in PROFILE_SECTION_UPDATES: section_config = getattr(cam_config, section, None) if section_config is not None: @@ -105,6 +109,12 @@ class ProfileManager: cam_config.enabled = base_enabled changed.setdefault(cam_name, set()).add("enabled") + # Restore zones + base_zones = self._base_zones.get(cam_name) + if base_zones is not None and cam_config.zones != base_zones: + cam_config.zones = copy.deepcopy(base_zones) + changed.setdefault(cam_name, set()).add("zones") + # Restore section configs base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: @@ -136,6 +146,14 @@ class ProfileManager: cam_config.enabled = profile.enabled changed.setdefault(cam_name, set()).add("enabled") + # Apply zones override — merge profile zones into base zones + if profile.zones is not None: + base_zones = self._base_zones.get(cam_name, {}) + merged_zones = copy.deepcopy(base_zones) + merged_zones.update(profile.zones) + cam_config.zones = merged_zones + changed.setdefault(cam_name, set()).add("zones") + base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: @@ -175,6 +193,15 @@ class ProfileManager: ) continue + if section == "zones": + self.config_updater.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.zones, cam_name + ), + cam_config.zones, + ) + 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 72a2d91e6..ba4d08854 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -70,6 +70,24 @@ class TestCameraProfileConfig(unittest.TestCase): profile = CameraProfileConfig() assert profile.enabled is None + def test_zones_field(self): + """Profile with zones override.""" + profile = CameraProfileConfig( + zones={ + "driveway": { + "coordinates": "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + "objects": ["car"], + } + } + ) + assert profile.zones is not None + assert "driveway" in profile.zones + + def test_zones_default_none(self): + """Zones defaults to None when not set.""" + profile = CameraProfileConfig() + assert profile.zones 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}) @@ -380,6 +398,81 @@ class TestProfileManager(unittest.TestCase): self.manager.activate_profile(None) assert self.config.cameras["front"].enabled is True + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile_adds_zone(self, mock_persist): + """Profile with zones adds/overrides zones on camera.""" + from frigate.config.camera.profile import CameraProfileConfig + from frigate.config.camera.zone import ZoneConfig + + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + zones={ + "driveway": ZoneConfig( + coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + objects=["car"], + ) + } + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + assert "driveway" not in self.config.cameras["front"].zones + + err = self.manager.activate_profile("away") + assert err is None + assert "driveway" in self.config.cameras["front"].zones + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_restores_zones(self, mock_persist): + """Deactivating a profile restores base zones.""" + from frigate.config.camera.profile import CameraProfileConfig + from frigate.config.camera.zone import ZoneConfig + + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + zones={ + "driveway": ZoneConfig( + coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + objects=["car"], + ) + } + ) + self.manager = ProfileManager(self.config, self.mock_updater) + + self.manager.activate_profile("away") + assert "driveway" in self.config.cameras["front"].zones + + self.manager.activate_profile(None) + assert "driveway" not in self.config.cameras["front"].zones + + @patch.object(ProfileManager, "_persist_active_profile") + def test_zones_zmq_published(self, mock_persist): + """ZMQ update is published for zones change.""" + from frigate.config.camera.profile import CameraProfileConfig + from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, + ) + from frigate.config.camera.zone import ZoneConfig + + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + zones={ + "driveway": ZoneConfig( + coordinates="0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9", + objects=["car"], + ) + } + ) + self.manager = ProfileManager(self.config, self.mock_updater) + self.mock_updater.reset_mock() + + self.manager.activate_profile("away") + + zones_calls = [ + call + for call in self.mock_updater.publish_update.call_args_list + if call[0][0] + == CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, "front") + ] + assert len(zones_calls) == 1 + @patch.object(ProfileManager, "_persist_active_profile") def test_enabled_zmq_published(self, mock_persist): """ZMQ update is published for enabled state change."""