diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 8b62f78b5..1cc712e5d 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -169,14 +169,13 @@ class MqttClient(Communicator): self.config.active_profile or "none", retain=True, ) - available_profiles: list[str] = [] - for camera in self.config.cameras.values(): - for profile_name in camera.profiles: - if profile_name not in available_profiles: - available_profiles.append(profile_name) + available_profiles = [ + {"name": name, "friendly_name": defn.friendly_name} + for name, defn in sorted(self.config.profiles.items()) + ] self.publish( "profiles/available", - json.dumps(sorted(available_profiles)), + json.dumps(available_profiles), retain=True, ) diff --git a/frigate/config/config.py b/frigate/config/config.py index 768d4ea2b..a57bd42ff 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -68,6 +68,7 @@ from .env import EnvVars from .logger import LoggerConfig from .mqtt import MqttConfig from .network import NetworkingConfig +from .profile import ProfileDefinitionConfig from .proxy import ProxyConfig from .telemetry import TelemetryConfig from .tls import TlsConfig @@ -561,6 +562,12 @@ class FrigateConfig(FrigateBaseModel): description="Configuration for named camera groups used to organize cameras in the UI.", ) + profiles: Dict[str, ProfileDefinitionConfig] = Field( + default_factory=dict, + title="Profiles", + description="Named profile definitions with friendly names. Camera profiles must reference names defined here.", + ) + active_profile: Optional[str] = Field( default=None, title="Active profile", @@ -917,6 +924,15 @@ class FrigateConfig(FrigateBaseModel): verify_objects_track(camera_config, labelmap_objects) verify_lpr_and_face(self, camera_config) + # Validate camera profiles reference top-level profile definitions + for cam_name, cam_config in self.cameras.items(): + for profile_name in cam_config.profiles: + if profile_name not in self.profiles: + raise ValueError( + f"Camera '{cam_name}' references profile '{profile_name}' " + f"which is not defined in the top-level 'profiles' section" + ) + # set names on classification configs for name, config in self.classification.custom.items(): config.name = name diff --git a/frigate/config/profile.py b/frigate/config/profile.py new file mode 100644 index 000000000..2d6dd1be3 --- /dev/null +++ b/frigate/config/profile.py @@ -0,0 +1,20 @@ +"""Top-level profile definition configuration.""" + +from pydantic import Field + +from .base import FrigateBaseModel + +__all__ = ["ProfileDefinitionConfig"] + + +class ProfileDefinitionConfig(FrigateBaseModel): + """Defines a named profile with a human-readable display name. + + The dict key is the machine name used internally; friendly_name + is the label shown in the UI and API responses. + """ + + friendly_name: str = Field( + title="Friendly name", + description="Display name for this profile shown in the UI.", + ) diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index e5b2c8703..ac07cf54c 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -81,11 +81,7 @@ class ProfileManager: # Re-apply profile overrides without publishing ZMQ updates # (the config/set caller handles its own ZMQ publishing) if current_active is not None: - has_profile = any( - current_active in cam.profiles - for cam in self.config.cameras.values() - ) - if has_profile: + if current_active in self.config.profiles: changed: dict[str, set[str]] = {} self._apply_profile_overrides(current_active, changed) self.config.active_profile = current_active @@ -104,11 +100,8 @@ class ProfileManager: None on success, or an error message string on failure. """ if profile_name is not None: - has_profile = any( - profile_name in cam.profiles for cam in self.config.cameras.values() - ) - if not has_profile: - return f"Profile '{profile_name}' not found on any camera" + if profile_name not in self.config.profiles: + return f"Profile '{profile_name}' is not defined in the profiles section" # Track which camera/section pairs get changed for ZMQ publishing changed: dict[str, set[str]] = {} @@ -265,12 +258,12 @@ class ProfileManager: logger.exception("Failed to load persisted profile") return None - def get_available_profiles(self) -> list[str]: - """Get a deduplicated list of all profile names across cameras.""" - profiles: set[str] = set() - for cam_config in self.config.cameras.values(): - profiles.update(cam_config.profiles.keys()) - return sorted(profiles) + def get_available_profiles(self) -> list[dict[str, str]]: + """Get list of all profile definitions from the top-level config.""" + return [ + {"name": name, "friendly_name": defn.friendly_name} + for name, defn in sorted(self.config.profiles.items()) + ] def get_profile_info(self) -> dict: """Get profile state info for API responses.""" diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index ba4d08854..9beed1bf5 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch from frigate.config import FrigateConfig from frigate.config.camera.profile import CameraProfileConfig +from frigate.config.profile import ProfileDefinitionConfig from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager from frigate.const import MODEL_CACHE_DIR @@ -125,6 +126,9 @@ class TestCameraProfileConfig(unittest.TestCase): config_data = { "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + }, "cameras": { "front": { "ffmpeg": { @@ -147,6 +151,37 @@ class TestCameraProfileConfig(unittest.TestCase): with self.assertRaises(ValidationError): FrigateConfig(**config_data) + def test_undefined_profile_reference_rejected(self): + """Camera referencing a profile not defined in top-level profiles is rejected.""" + from pydantic import ValidationError + + config_data = { + "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + }, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "nonexistent": { + "detect": {"enabled": False}, + }, + }, + }, + }, + } + with self.assertRaises(ValidationError): + FrigateConfig(**config_data) + class TestProfileInConfig(unittest.TestCase): """Test that profiles parse correctly in FrigateConfig.""" @@ -154,6 +189,10 @@ class TestProfileInConfig(unittest.TestCase): def setUp(self): self.base_config = { "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + "disarmed": {"friendly_name": "Disarmed"}, + }, "cameras": { "front": { "ffmpeg": { @@ -244,6 +283,10 @@ class TestProfileManager(unittest.TestCase): def setUp(self): self.config_data = { "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + "disarmed": {"friendly_name": "Disarmed"}, + }, "cameras": { "front": { "ffmpeg": { @@ -295,17 +338,21 @@ class TestProfileManager(unittest.TestCase): self.manager = ProfileManager(self.config, self.mock_updater) def test_get_available_profiles(self): - """Available profiles are collected from all cameras.""" + """Available profiles come from top-level profile definitions.""" profiles = self.manager.get_available_profiles() - assert "armed" in profiles - assert "disarmed" in profiles assert len(profiles) == 2 + names = [p["name"] for p in profiles] + assert "armed" in names + assert "disarmed" in names + # Verify friendly_name is included + armed = next(p for p in profiles if p["name"] == "armed") + assert armed["friendly_name"] == "Armed" def test_activate_invalid_profile(self): """Activating non-existent profile returns error.""" err = self.manager.activate_profile("nonexistent") assert err is not None - assert "not found" in err + assert "not defined" in err @patch.object(ProfileManager, "_persist_active_profile") def test_activate_profile(self, mock_persist): @@ -368,13 +415,12 @@ class TestProfileManager(unittest.TestCase): @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.profiles["away"] = ProfileDefinitionConfig( + friendly_name="Away" + ) 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 @@ -385,8 +431,9 @@ class TestProfileManager(unittest.TestCase): @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.profiles["away"] = ProfileDefinitionConfig( + friendly_name="Away" + ) self.config.cameras["front"].profiles["away"] = CameraProfileConfig( enabled=False ) @@ -401,9 +448,11 @@ class TestProfileManager(unittest.TestCase): @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.profiles["away"] = ProfileDefinitionConfig( + friendly_name="Away" + ) self.config.cameras["front"].profiles["away"] = CameraProfileConfig( zones={ "driveway": ZoneConfig( @@ -423,9 +472,11 @@ class TestProfileManager(unittest.TestCase): @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.profiles["away"] = ProfileDefinitionConfig( + friendly_name="Away" + ) self.config.cameras["front"].profiles["away"] = CameraProfileConfig( zones={ "driveway": ZoneConfig( @@ -445,13 +496,15 @@ class TestProfileManager(unittest.TestCase): @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.profiles["away"] = ProfileDefinitionConfig( + friendly_name="Away" + ) self.config.cameras["front"].profiles["away"] = CameraProfileConfig( zones={ "driveway": ZoneConfig( @@ -476,12 +529,14 @@ class TestProfileManager(unittest.TestCase): @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.profiles["away"] = ProfileDefinitionConfig( + friendly_name="Away" + ) self.config.cameras["front"].profiles["away"] = CameraProfileConfig( enabled=False ) @@ -507,12 +562,14 @@ class TestProfileManager(unittest.TestCase): assert self.mock_updater.publish_update.called def test_get_profile_info(self): - """Profile info returns correct structure.""" + """Profile info returns correct structure with friendly names.""" info = self.manager.get_profile_info() assert "profiles" in info assert "active_profile" in info assert info["active_profile"] is None - assert "armed" in info["profiles"] + names = [p["name"] for p in info["profiles"]] + assert "armed" in names + assert "disarmed" in names class TestProfilePersistence(unittest.TestCase):