From 0381c96973680dfcb3afb16710a3606704c73b7d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:46:45 -0600 Subject: [PATCH 01/64] add CameraProfileConfig model for named config overrides --- frigate/config/camera/profile.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 frigate/config/camera/profile.py diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py new file mode 100644 index 000000000..3cb8ac3cf --- /dev/null +++ b/frigate/config/camera/profile.py @@ -0,0 +1,37 @@ +"""Camera profile configuration for named config overrides.""" + +from typing import Optional + +from ..base import FrigateBaseModel +from .audio import AudioConfig +from .birdseye import BirdseyeCameraConfig +from .detect import DetectConfig +from .live import CameraLiveConfig +from .motion import MotionConfig +from .notification import NotificationConfig +from .objects import ObjectConfig +from .record import RecordConfig +from .review import ReviewConfig +from .snapshots import SnapshotsConfig + +__all__ = ["CameraProfileConfig"] + + +class CameraProfileConfig(FrigateBaseModel): + """A named profile containing partial camera config overrides. + + Sections set to None inherit from the camera's base config. + Sections that are defined get Pydantic-validated, then only + explicitly-set fields are used as overrides via exclude_unset. + """ + + audio: Optional[AudioConfig] = None + birdseye: Optional[BirdseyeCameraConfig] = None + detect: Optional[DetectConfig] = None + live: Optional[CameraLiveConfig] = None + motion: Optional[MotionConfig] = None + notifications: Optional[NotificationConfig] = None + objects: Optional[ObjectConfig] = None + record: Optional[RecordConfig] = None + review: Optional[ReviewConfig] = None + snapshots: Optional[SnapshotsConfig] = None From ea2252d09f5e1e2cc282f4b5fc86ca8aa22a0d6c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:48:33 -0600 Subject: [PATCH 02/64] add profiles field to CameraConfig --- frigate/config/camera/camera.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 9960abdce..3c9e1f892 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -34,6 +34,7 @@ from .mqtt import CameraMqttConfig from .notification import NotificationConfig from .objects import ObjectConfig from .onvif import OnvifConfig +from .profile import CameraProfileConfig from .record import RecordConfig from .review import ReviewConfig from .snapshots import SnapshotsConfig @@ -184,6 +185,12 @@ class CameraConfig(FrigateBaseModel): title="Camera URL", description="URL to visit the camera directly from system page", ) + + profiles: dict[str, CameraProfileConfig] = Field( + default_factory=dict, + title="Profiles", + description="Named config profiles with partial overrides that can be activated at runtime.", + ) zones: dict[str, ZoneConfig] = Field( default_factory=dict, title="Zones", From 1c190220cbb0bd557c80d456ae2037f440fee43d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:49:13 -0600 Subject: [PATCH 03/64] add active_profile field to FrigateConfig Runtime-only field excluded from YAML serialization, tracks which profile is currently active. --- frigate/config/config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frigate/config/config.py b/frigate/config/config.py index 339d675dc..768d4ea2b 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -561,6 +561,13 @@ class FrigateConfig(FrigateBaseModel): description="Configuration for named camera groups used to organize cameras in the UI.", ) + active_profile: Optional[str] = Field( + default=None, + title="Active profile", + description="Currently active profile name. Runtime-only, not persisted in YAML.", + exclude=True, + ) + _plus_api: PlusApi @property From c367e5c95a3cdaf63a347a2f24c7e22e9687b991 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:51:22 -0600 Subject: [PATCH 04/64] add ProfileManager for profile activation and persistence Handles snapshotting base configs, applying profile overrides via deep_merge + apply_section_update, publishing ZMQ updates, and persisting active profile to /config/.active_profile. --- frigate/config/profile_manager.py | 203 ++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 frigate/config/profile_manager.py diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py new file mode 100644 index 000000000..656f27b42 --- /dev/null +++ b/frigate/config/profile_manager.py @@ -0,0 +1,203 @@ +"""Profile manager for activating/deactivating named config profiles.""" + +import json +import logging +from pathlib import Path +from typing import Optional + +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdatePublisher, + CameraConfigUpdateTopic, +) +from frigate.util.builtin import deep_merge +from frigate.util.config import apply_section_update + +logger = logging.getLogger(__name__) + +PROFILE_SECTIONS = { + "audio", + "birdseye", + "detect", + "live", + "motion", + "notifications", + "objects", + "record", + "review", + "snapshots", +} + +SECTION_TO_UPDATE_ENUM: dict[str, CameraConfigUpdateEnum] = { + "audio": CameraConfigUpdateEnum.audio, + "birdseye": CameraConfigUpdateEnum.birdseye, + "detect": CameraConfigUpdateEnum.detect, + "live": CameraConfigUpdateEnum.enabled, + "motion": CameraConfigUpdateEnum.motion, + "notifications": CameraConfigUpdateEnum.notifications, + "objects": CameraConfigUpdateEnum.objects, + "record": CameraConfigUpdateEnum.record, + "review": CameraConfigUpdateEnum.review, + "snapshots": CameraConfigUpdateEnum.snapshots, +} + +PERSISTENCE_FILE = Path("/config/.active_profile") + + +class ProfileManager: + """Manages profile activation, persistence, and config application.""" + + def __init__( + self, + config, + config_updater: CameraConfigUpdatePublisher, + ): + from frigate.config.config import FrigateConfig + + self.config: FrigateConfig = config + self.config_updater = config_updater + self._base_configs: dict[str, dict[str, dict]] = {} + self._snapshot_base_configs() + + def _snapshot_base_configs(self) -> None: + """Snapshot each camera's current section configs as the base.""" + for cam_name, cam_config in self.config.cameras.items(): + self._base_configs[cam_name] = {} + for section in PROFILE_SECTIONS: + section_config = getattr(cam_config, section, None) + if section_config is not None: + self._base_configs[cam_name][section] = section_config.model_dump() + + def activate_profile(self, profile_name: Optional[str]) -> Optional[str]: + """Activate a profile by name, or deactivate if None. + + Args: + profile_name: Profile name to activate, or None to deactivate. + + Returns: + 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" + + # Reset all cameras to base config + self._reset_to_base() + + # Apply new profile overrides if activating + if profile_name is not None: + err = self._apply_profile_overrides(profile_name) + if err: + return err + + self.config.active_profile = profile_name + self._persist_active_profile(profile_name) + logger.info( + "Profile %s", + f"'{profile_name}' activated" if profile_name else "deactivated", + ) + return None + + def _reset_to_base(self) -> None: + """Reset all cameras to their base (no-profile) config.""" + for cam_name, cam_config in self.config.cameras.items(): + base = self._base_configs.get(cam_name, {}) + for section in PROFILE_SECTIONS: + base_data = base.get(section) + if base_data is None: + continue + err = apply_section_update(cam_config, section, base_data) + if err: + logger.error( + "Failed to reset section '%s' on camera '%s': %s", + section, + cam_name, + err, + ) + + def _apply_profile_overrides(self, profile_name: str) -> Optional[str]: + """Apply profile overrides for all cameras that have the named profile.""" + affected_cameras: set[str] = set() + + for cam_name, cam_config in self.config.cameras.items(): + profile = cam_config.profiles.get(profile_name) + if profile is None: + continue + + base = self._base_configs.get(cam_name, {}) + + for section in PROFILE_SECTIONS: + profile_section = getattr(profile, section, None) + if profile_section is None: + continue + + overrides = profile_section.model_dump(exclude_unset=True) + if not overrides: + continue + + base_data = base.get(section, {}) + merged = deep_merge(overrides, base_data) + + err = apply_section_update(cam_config, section, merged) + if err: + return f"Failed to apply profile '{profile_name}' section '{section}' on camera '{cam_name}': {err}" + + affected_cameras.add(cam_name) + + # Publish ZMQ updates for all affected cameras + self._publish_updates(affected_cameras) + return None + + def _publish_updates(self, affected_cameras: set[str]) -> None: + """Publish ZMQ config updates for affected cameras.""" + for cam_name in affected_cameras: + cam_config = self.config.cameras.get(cam_name) + if cam_config is None: + continue + + for section, update_enum in SECTION_TO_UPDATE_ENUM.items(): + settings = getattr(cam_config, section, None) + if settings is not None: + self.config_updater.publish_update( + CameraConfigUpdateTopic(update_enum, cam_name), + settings, + ) + + def _persist_active_profile(self, profile_name: Optional[str]) -> None: + """Persist the active profile name to disk.""" + try: + if profile_name is None: + PERSISTENCE_FILE.unlink(missing_ok=True) + else: + PERSISTENCE_FILE.write_text(profile_name) + except OSError: + logger.exception("Failed to persist active profile") + + @staticmethod + def load_persisted_profile() -> Optional[str]: + """Load the persisted active profile name from disk.""" + try: + if PERSISTENCE_FILE.exists(): + name = PERSISTENCE_FILE.read_text().strip() + return name if name else None + except OSError: + 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_profile_info(self) -> dict: + """Get profile state info for API responses.""" + return { + "profiles": self.get_available_profiles(), + "active_profile": self.config.active_profile, + } From f76bb9cfb8a1d419aca78f0542a8d97c9b1cafba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:52:17 -0600 Subject: [PATCH 05/64] add profile API endpoints (GET /profiles, GET/PUT /profile) --- frigate/api/app.py | 41 +++++++++++++++++++++++++++- frigate/api/defs/request/app_body.py | 6 ++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 2472b5080..3ef0cccab 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -31,7 +31,11 @@ from frigate.api.auth import ( require_role, ) from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters -from frigate.api.defs.request.app_body import AppConfigSetBody, MediaSyncBody +from frigate.api.defs.request.app_body import ( + AppConfigSetBody, + MediaSyncBody, + ProfileSetBody, +) from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig from frigate.config.camera.updater import ( @@ -201,6 +205,41 @@ def config(request: Request): return JSONResponse(content=config) +@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) +def get_profiles(request: Request): + """List all available profiles and the currently active profile.""" + profile_manager = request.app.profile_manager + return JSONResponse(content=profile_manager.get_profile_info()) + + +@router.get("/profile/active", dependencies=[Depends(allow_any_authenticated())]) +def get_active_profile(request: Request): + """Get the currently active profile.""" + config_obj: FrigateConfig = request.app.frigate_config + return JSONResponse(content={"active_profile": config_obj.active_profile}) + + +@router.put("/profile/set", dependencies=[Depends(require_role(["admin"]))]) +def set_profile(request: Request, body: ProfileSetBody): + """Activate or deactivate a profile.""" + profile_manager = request.app.profile_manager + err = profile_manager.activate_profile(body.profile) + if err: + return JSONResponse( + content={"success": False, "message": err}, + status_code=400, + ) + request.app.dispatcher.publish( + "profile/state", body.profile or "none", retain=True + ) + return JSONResponse( + content={ + "success": True, + "active_profile": body.profile, + } + ) + + @router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())]) def ffmpeg_presets(): """Return available ffmpeg preset keys for config UI usage.""" diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 3d2ab5961..1640da739 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -30,6 +30,12 @@ class AppPutRoleBody(BaseModel): role: str +class ProfileSetBody(BaseModel): + profile: Optional[str] = Field( + default=None, description="Profile name to activate, or null to deactivate" + ) + + class MediaSyncBody(BaseModel): dry_run: bool = Field( default=True, description="If True, only report orphans without deleting them" From 76c65bbf6d817a29274e989f20f715474fce2599 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:54:32 -0600 Subject: [PATCH 06/64] add MQTT and dispatcher integration for profiles - Subscribe to frigate/profile/set MQTT topic - Publish profile/state and profiles/available on connect - Add _on_profile_command handler to dispatcher - Broadcast active profile state on WebSocket connect --- frigate/comms/dispatcher.py | 21 +++++++++++++++++++++ frigate/comms/mqtt.py | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 490a829dc..ad512e6ab 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -91,7 +91,9 @@ class Dispatcher: } self._global_settings_handlers: dict[str, Callable] = { "notifications": self._on_global_notification_command, + "profile": self._on_profile_command, } + self.profile_manager = None for comm in self.comms: comm.subscribe(self._receive) @@ -298,6 +300,11 @@ class Dispatcher: ) self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) self.publish("audio_detections", json.dumps(audio_detections)) + self.publish( + "profile/state", + self.config.active_profile or "none", + retain=True, + ) def handle_notification_test() -> None: self.publish("notification_test", "Test notification") @@ -556,6 +563,20 @@ class Dispatcher: ) self.publish("notifications/state", payload, retain=True) + def _on_profile_command(self, payload: str) -> None: + """Callback for profile/set topic.""" + if self.profile_manager is None: + logger.error("Profile manager not initialized") + return + + profile_name = payload.strip() if payload.strip() not in ("", "none", "None") else None + err = self.profile_manager.activate_profile(profile_name) + if err: + logger.error("Failed to activate profile: %s", err) + return + + self.publish("profile/state", payload.strip() or "none", retain=True) + def _on_audio_command(self, camera_name: str, payload: str) -> None: """Callback for audio topic.""" audio_settings = self.config.cameras[camera_name].audio diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 9279b4388..8b62f78b5 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -1,3 +1,4 @@ +import json import logging import threading from typing import Any, Callable @@ -163,6 +164,22 @@ class MqttClient(Communicator): retain=True, ) + self.publish( + "profile/state", + 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) + self.publish( + "profiles/available", + json.dumps(sorted(available_profiles)), + retain=True, + ) + self.publish("available", "online", retain=True) def on_mqtt_command( @@ -289,6 +306,11 @@ class MqttClient(Communicator): self.on_mqtt_command, ) + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/profile/set", + self.on_mqtt_command, + ) + self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command ) From 26ec07985c21a1d5fbf94522f71ae99a8164c2eb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:56:53 -0600 Subject: [PATCH 07/64] wire ProfileManager into app startup and FastAPI - Create ProfileManager after dispatcher init - Restore persisted profile on startup - Pass dispatcher and profile_manager to FastAPI app --- frigate/api/fastapi_app.py | 4 ++++ frigate/app.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 0a731bcee..aab9f946b 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -69,6 +69,8 @@ def create_fastapi_app( event_metadata_updater: EventMetadataPublisher, config_publisher: CameraConfigUpdatePublisher, replay_manager: DebugReplayManager, + dispatcher=None, + profile_manager=None, enforce_default_admin: bool = True, ): logger.info("Starting FastAPI app") @@ -151,6 +153,8 @@ def create_fastapi_app( app.event_metadata_updater = event_metadata_updater app.config_publisher = config_publisher app.replay_manager = replay_manager + app.dispatcher = dispatcher + app.profile_manager = profile_manager if frigate_config.auth.enabled: secret = get_jwt_secret() diff --git a/frigate/app.py b/frigate/app.py index 0add3e3b8..1a24b23b5 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient from frigate.comms.zmq_proxy import ZmqProxy from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.config import FrigateConfig +from frigate.config.profile_manager import ProfileManager from frigate.const import ( CACHE_DIR, CLIPS_DIR, @@ -349,6 +350,19 @@ class FrigateApp: comms, ) + def init_profile_manager(self) -> None: + self.profile_manager = ProfileManager( + self.config, self.inter_config_updater + ) + self.dispatcher.profile_manager = self.profile_manager + + persisted = ProfileManager.load_persisted_profile() + if persisted and any( + persisted in cam.profiles for cam in self.config.cameras.values() + ): + logger.info("Restoring persisted profile '%s'", persisted) + self.profile_manager.activate_profile(persisted) + def start_detectors(self) -> None: for name in self.config.cameras.keys(): try: @@ -557,6 +571,7 @@ class FrigateApp: self.init_inter_process_communicator() self.start_detectors() self.init_dispatcher() + self.init_profile_manager() self.init_embeddings_client() self.start_video_output_processor() self.start_ptz_autotracker() @@ -586,6 +601,8 @@ class FrigateApp: self.event_metadata_updater, self.inter_config_updater, self.replay_manager, + self.dispatcher, + self.profile_manager, ), host="127.0.0.1", port=5001, From 9efc2499f60eae53f55af37c6f8748693b591f5a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:59:43 -0600 Subject: [PATCH 08/64] add tests for invalid profile values and keys Tests that Pydantic rejects: invalid field values (fps: "not_a_number"), unknown section keys (ffmpeg in profile), invalid nested values, and invalid profiles in full config parsing. --- frigate/test/test_profiles.py | 396 ++++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 frigate/test/test_profiles.py diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py new file mode 100644 index 000000000..a8ddf22b9 --- /dev/null +++ b/frigate/test/test_profiles.py @@ -0,0 +1,396 @@ +"""Tests for the profiles system.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from frigate.config import FrigateConfig +from frigate.config.camera.profile import CameraProfileConfig +from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager +from frigate.const import MODEL_CACHE_DIR + + +class TestCameraProfileConfig(unittest.TestCase): + """Test the CameraProfileConfig Pydantic model.""" + + def test_empty_profile(self): + """All sections default to None.""" + profile = CameraProfileConfig() + assert profile.detect is None + assert profile.motion is None + assert profile.objects is None + assert profile.review is None + assert profile.notifications is None + + def test_partial_detect(self): + """Profile with only detect.enabled set.""" + profile = CameraProfileConfig(detect={"enabled": False}) + assert profile.detect is not None + assert profile.detect.enabled is False + dumped = profile.detect.model_dump(exclude_unset=True) + assert dumped == {"enabled": False} + + def test_partial_notifications(self): + """Profile with only notifications.enabled set.""" + profile = CameraProfileConfig(notifications={"enabled": True}) + assert profile.notifications is not None + assert profile.notifications.enabled is True + dumped = profile.notifications.model_dump(exclude_unset=True) + assert dumped == {"enabled": True} + + def test_partial_objects(self): + """Profile with objects.track set.""" + profile = CameraProfileConfig(objects={"track": ["car", "package"]}) + assert profile.objects is not None + assert profile.objects.track == ["car", "package"] + + def test_partial_review(self): + """Profile with nested review.alerts.labels.""" + profile = CameraProfileConfig( + review={"alerts": {"labels": ["person", "car"]}} + ) + assert profile.review is not None + assert profile.review.alerts.labels == ["person", "car"] + + def test_none_sections_not_in_dump(self): + """Sections left as None should not appear in exclude_unset dump.""" + profile = CameraProfileConfig(detect={"enabled": False}) + dumped = profile.model_dump(exclude_unset=True) + assert "detect" in dumped + assert "motion" not in dumped + assert "objects" not in dumped + + def test_invalid_field_value_rejected(self): + """Invalid field values are caught by Pydantic.""" + from pydantic import ValidationError + + with self.assertRaises(ValidationError): + CameraProfileConfig(detect={"fps": "not_a_number"}) + + def test_invalid_section_key_rejected(self): + """Unknown section keys are rejected (extra=forbid from FrigateBaseModel).""" + from pydantic import ValidationError + + with self.assertRaises(ValidationError): + CameraProfileConfig(ffmpeg={"inputs": []}) + + def test_invalid_nested_field_rejected(self): + """Invalid nested field values are caught.""" + from pydantic import ValidationError + + with self.assertRaises(ValidationError): + CameraProfileConfig( + review={"alerts": {"labels": "not_a_list"}} + ) + + def test_invalid_profile_in_camera_config(self): + """Invalid profile section in full config is caught at parse time.""" + from pydantic import ValidationError + + config_data = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "detect": {"fps": "invalid"}, + }, + }, + }, + }, + } + with self.assertRaises(ValidationError): + FrigateConfig(**config_data) + + +class TestProfileInConfig(unittest.TestCase): + """Test that profiles parse correctly in FrigateConfig.""" + + def setUp(self): + self.base_config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "notifications": {"enabled": True}, + "objects": {"track": ["person", "car", "package"]}, + }, + "disarmed": { + "notifications": {"enabled": False}, + "objects": {"track": ["package"]}, + }, + }, + }, + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.2:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "detect": {"enabled": True}, + }, + }, + }, + }, + } + + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + + def test_profiles_parse(self): + """Profiles are parsed into Dict[str, CameraProfileConfig].""" + config = FrigateConfig(**self.base_config) + front = config.cameras["front"] + assert "armed" in front.profiles + assert "disarmed" in front.profiles + assert isinstance(front.profiles["armed"], CameraProfileConfig) + + def test_profile_sections_parsed(self): + """Profile sections are properly typed.""" + config = FrigateConfig(**self.base_config) + armed = config.cameras["front"].profiles["armed"] + assert armed.notifications is not None + assert armed.notifications.enabled is True + assert armed.objects is not None + assert armed.objects.track == ["person", "car", "package"] + assert armed.detect is None # not set in this profile + + def test_camera_without_profiles(self): + """Camera with no profiles has empty dict.""" + config_data = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, + } + config = FrigateConfig(**config_data) + assert config.cameras["front"].profiles == {} + + +class TestProfileManager(unittest.TestCase): + """Test ProfileManager activation, deactivation, and switching.""" + + def setUp(self): + self.config_data = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "notifications": {"enabled": False}, + "objects": {"track": ["person"]}, + "profiles": { + "armed": { + "notifications": {"enabled": True}, + "objects": {"track": ["person", "car", "package"]}, + }, + "disarmed": { + "notifications": {"enabled": False}, + "objects": {"track": ["package"]}, + }, + }, + }, + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.2:554/video", + "roles": ["detect"], + } + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "profiles": { + "armed": { + "notifications": {"enabled": True}, + }, + }, + }, + }, + } + + if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR): + os.makedirs(MODEL_CACHE_DIR) + + self.config = FrigateConfig(**self.config_data) + self.mock_updater = MagicMock() + self.manager = ProfileManager(self.config, self.mock_updater) + + def test_get_available_profiles(self): + """Available profiles are collected from all cameras.""" + profiles = self.manager.get_available_profiles() + assert "armed" in profiles + assert "disarmed" in profiles + assert len(profiles) == 2 + + 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 + + @patch.object(ProfileManager, "_persist_active_profile") + def test_activate_profile(self, mock_persist): + """Activating a profile applies overrides.""" + err = self.manager.activate_profile("armed") + assert err is None + assert self.config.active_profile == "armed" + + # Front camera should have armed overrides + front = self.config.cameras["front"] + assert front.notifications.enabled is True + assert front.objects.track == ["person", "car", "package"] + + # Back camera should have armed overrides + back = self.config.cameras["back"] + assert back.notifications.enabled is True + + @patch.object(ProfileManager, "_persist_active_profile") + def test_deactivate_profile(self, mock_persist): + """Deactivating a profile restores base config.""" + # Activate first + self.manager.activate_profile("armed") + assert self.config.cameras["front"].notifications.enabled is True + + # Deactivate + err = self.manager.activate_profile(None) + assert err is None + assert self.config.active_profile is None + + # Should be back to base + front = self.config.cameras["front"] + assert front.notifications.enabled is False + assert front.objects.track == ["person"] + + @patch.object(ProfileManager, "_persist_active_profile") + def test_switch_profiles(self, mock_persist): + """Switching from one profile to another works.""" + self.manager.activate_profile("armed") + assert self.config.cameras["front"].objects.track == [ + "person", + "car", + "package", + ] + + self.manager.activate_profile("disarmed") + assert self.config.active_profile == "disarmed" + assert self.config.cameras["front"].objects.track == ["package"] + assert self.config.cameras["front"].notifications.enabled is False + + @patch.object(ProfileManager, "_persist_active_profile") + def test_unaffected_camera(self, mock_persist): + """Camera without the activated profile is unaffected.""" + back_base_notifications = self.config.cameras["back"].notifications.enabled + + self.manager.activate_profile("disarmed") + + # 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_zmq_updates_published(self, mock_persist): + """ZMQ updates are published when a profile is activated.""" + self.manager.activate_profile("armed") + assert self.mock_updater.publish_update.called + + def test_get_profile_info(self): + """Profile info returns correct structure.""" + 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"] + + +class TestProfilePersistence(unittest.TestCase): + """Test profile persistence to disk.""" + + def test_persist_and_load(self): + """Active profile name can be persisted and loaded.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + temp_path = f.name + + try: + with patch.object( + ProfileManager, "_persist_active_profile" + ) as mock_persist: + # Simulate writing + from pathlib import Path + + path = Path(temp_path) + path.write_text("armed") + loaded = path.read_text().strip() + assert loaded == "armed" + finally: + os.unlink(temp_path) + + def test_load_empty_file(self): + """Empty persistence file returns None.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("") + temp_path = f.name + + try: + from pathlib import Path + + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): + with patch.object( + type(PERSISTENCE_FILE), "read_text", return_value="" + ): + result = ProfileManager.load_persisted_profile() + assert result is None + finally: + os.unlink(temp_path) + + def test_load_missing_file(self): + """Missing persistence file returns None.""" + with patch.object(type(PERSISTENCE_FILE), "exists", return_value=False): + result = ProfileManager.load_persisted_profile() + assert result is None + + +if __name__ == "__main__": + unittest.main() From 61746984d160adb5470d5bcc680b6251261189b6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:01:10 -0600 Subject: [PATCH 09/64] formatting --- frigate/config/profile_manager.py | 1 - frigate/test/test_profiles.py | 16 +++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 656f27b42..81c7d19d8 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -1,6 +1,5 @@ """Profile manager for activating/deactivating named config profiles.""" -import json import logging from pathlib import Path from typing import Optional diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index a8ddf22b9..55bfd09fa 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -354,16 +354,12 @@ class TestProfilePersistence(unittest.TestCase): temp_path = f.name try: - with patch.object( - ProfileManager, "_persist_active_profile" - ) as mock_persist: - # Simulate writing - from pathlib import Path + from pathlib import Path - path = Path(temp_path) - path.write_text("armed") - loaded = path.read_text().strip() - assert loaded == "armed" + path = Path(temp_path) + path.write_text("armed") + loaded = path.read_text().strip() + assert loaded == "armed" finally: os.unlink(temp_path) @@ -374,8 +370,6 @@ class TestProfilePersistence(unittest.TestCase): temp_path = f.name try: - from pathlib import Path - with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): with patch.object( type(PERSISTENCE_FILE), "read_text", return_value="" From 708399508ba4492d9cc21680edfbd01f3f8b9bbd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:08:54 -0600 Subject: [PATCH 10/64] fix CameraLiveConfig JSON serialization error on profile activation refactor _publish_updates to only publish ZMQ updates for sections that actually changed, not all sections on affected cameras. --- frigate/config/profile_manager.py | 36 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 81c7d19d8..e9ec052ef 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -31,7 +31,6 @@ SECTION_TO_UPDATE_ENUM: dict[str, CameraConfigUpdateEnum] = { "audio": CameraConfigUpdateEnum.audio, "birdseye": CameraConfigUpdateEnum.birdseye, "detect": CameraConfigUpdateEnum.detect, - "live": CameraConfigUpdateEnum.enabled, "motion": CameraConfigUpdateEnum.motion, "notifications": CameraConfigUpdateEnum.notifications, "objects": CameraConfigUpdateEnum.objects, @@ -84,15 +83,21 @@ class ProfileManager: if not has_profile: return f"Profile '{profile_name}' not found on any camera" + # Track which camera/section pairs get changed for ZMQ publishing + changed: dict[str, set[str]] = {} + # Reset all cameras to base config - self._reset_to_base() + self._reset_to_base(changed) # Apply new profile overrides if activating if profile_name is not None: - err = self._apply_profile_overrides(profile_name) + err = self._apply_profile_overrides(profile_name, changed) if err: return err + # Publish ZMQ updates only for sections that actually changed + self._publish_updates(changed) + self.config.active_profile = profile_name self._persist_active_profile(profile_name) logger.info( @@ -101,7 +106,7 @@ class ProfileManager: ) return None - def _reset_to_base(self) -> None: + 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(): base = self._base_configs.get(cam_name, {}) @@ -117,11 +122,13 @@ class ProfileManager: cam_name, err, ) + else: + changed.setdefault(cam_name, set()).add(section) - def _apply_profile_overrides(self, profile_name: str) -> Optional[str]: + def _apply_profile_overrides( + self, profile_name: str, changed: dict[str, set[str]] + ) -> Optional[str]: """Apply profile overrides for all cameras that have the named profile.""" - affected_cameras: set[str] = set() - for cam_name, cam_config in self.config.cameras.items(): profile = cam_config.profiles.get(profile_name) if profile is None: @@ -145,20 +152,21 @@ class ProfileManager: if err: return f"Failed to apply profile '{profile_name}' section '{section}' on camera '{cam_name}': {err}" - affected_cameras.add(cam_name) + changed.setdefault(cam_name, set()).add(section) - # Publish ZMQ updates for all affected cameras - self._publish_updates(affected_cameras) return None - def _publish_updates(self, affected_cameras: set[str]) -> None: - """Publish ZMQ config updates for affected cameras.""" - for cam_name in affected_cameras: + def _publish_updates(self, changed: dict[str, set[str]]) -> None: + """Publish ZMQ config updates only for sections that changed.""" + for cam_name, sections in changed.items(): cam_config = self.config.cameras.get(cam_name) if cam_config is None: continue - for section, update_enum in SECTION_TO_UPDATE_ENUM.items(): + for section in sections: + update_enum = SECTION_TO_UPDATE_ENUM.get(section) + if update_enum is None: + continue settings = getattr(cam_config, section, None) if settings is not None: self.config_updater.publish_update( From 7c6926d1e683dbeaec669a867216d94abcc5c0c3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:28:07 -0600 Subject: [PATCH 11/64] consolidate --- frigate/config/camera/profile.py | 2 -- frigate/config/profile_manager.py | 26 +++++++------------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py index 3cb8ac3cf..d37829f22 100644 --- a/frigate/config/camera/profile.py +++ b/frigate/config/camera/profile.py @@ -6,7 +6,6 @@ from ..base import FrigateBaseModel from .audio import AudioConfig from .birdseye import BirdseyeCameraConfig from .detect import DetectConfig -from .live import CameraLiveConfig from .motion import MotionConfig from .notification import NotificationConfig from .objects import ObjectConfig @@ -28,7 +27,6 @@ class CameraProfileConfig(FrigateBaseModel): audio: Optional[AudioConfig] = None birdseye: Optional[BirdseyeCameraConfig] = None detect: Optional[DetectConfig] = None - live: Optional[CameraLiveConfig] = None motion: Optional[MotionConfig] = None notifications: Optional[NotificationConfig] = None objects: Optional[ObjectConfig] = None diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index e9ec052ef..07daab82a 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -9,25 +9,13 @@ from frigate.config.camera.updater import ( CameraConfigUpdatePublisher, CameraConfigUpdateTopic, ) +from frigate.const import CONFIG_DIR from frigate.util.builtin import deep_merge from frigate.util.config import apply_section_update logger = logging.getLogger(__name__) -PROFILE_SECTIONS = { - "audio", - "birdseye", - "detect", - "live", - "motion", - "notifications", - "objects", - "record", - "review", - "snapshots", -} - -SECTION_TO_UPDATE_ENUM: dict[str, CameraConfigUpdateEnum] = { +PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "audio": CameraConfigUpdateEnum.audio, "birdseye": CameraConfigUpdateEnum.birdseye, "detect": CameraConfigUpdateEnum.detect, @@ -39,7 +27,7 @@ SECTION_TO_UPDATE_ENUM: dict[str, CameraConfigUpdateEnum] = { "snapshots": CameraConfigUpdateEnum.snapshots, } -PERSISTENCE_FILE = Path("/config/.active_profile") +PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" class ProfileManager: @@ -61,7 +49,7 @@ class ProfileManager: """Snapshot each camera's current section configs as the base.""" for cam_name, cam_config in self.config.cameras.items(): self._base_configs[cam_name] = {} - for section in PROFILE_SECTIONS: + for section in PROFILE_SECTION_UPDATES: section_config = getattr(cam_config, section, None) if section_config is not None: self._base_configs[cam_name][section] = section_config.model_dump() @@ -110,7 +98,7 @@ class ProfileManager: """Reset all cameras to their base (no-profile) config.""" for cam_name, cam_config in self.config.cameras.items(): base = self._base_configs.get(cam_name, {}) - for section in PROFILE_SECTIONS: + for section in PROFILE_SECTION_UPDATES: base_data = base.get(section) if base_data is None: continue @@ -136,7 +124,7 @@ class ProfileManager: base = self._base_configs.get(cam_name, {}) - for section in PROFILE_SECTIONS: + for section in PROFILE_SECTION_UPDATES: profile_section = getattr(profile, section, None) if profile_section is None: continue @@ -164,7 +152,7 @@ class ProfileManager: continue for section in sections: - update_enum = SECTION_TO_UPDATE_ENUM.get(section) + update_enum = PROFILE_SECTION_UPDATES.get(section) if update_enum is None: continue settings = getattr(cam_config, section, None) From d15fc4e58e78e601df826183ec8826be14387679 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:47:17 -0600 Subject: [PATCH 12/64] add enabled field to camera profiles for enabling/disabling cameras --- frigate/config/camera/profile.py | 1 + frigate/config/profile_manager.py | 25 +++++++++- frigate/test/test_profiles.py | 77 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) 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.""" 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 13/64] 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.""" From 72b4a4ddadc96603d72c5f07073a16a994b096e1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:00:25 -0500 Subject: [PATCH 14/64] add frontend profile types, color utility, and config save support --- web/src/types/frigateConfig.ts | 15 +++++++ web/src/types/profile.ts | 23 +++++++++++ web/src/utils/configUtil.ts | 74 +++++++++++++++++++++++++++------- web/src/utils/profileColors.ts | 67 ++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 web/src/types/profile.ts create mode 100644 web/src/utils/profileColors.ts diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index dcf3c312f..ffb86217e 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -305,8 +305,23 @@ export interface CameraConfig { friendly_name?: string; }; }; + profiles?: Record; } +export type CameraProfileConfig = { + enabled?: boolean; + audio?: Partial; + birdseye?: Partial; + detect?: Partial; + motion?: Partial; + notifications?: Partial; + objects?: Partial; + record?: Partial; + review?: Partial; + snapshots?: Partial; + zones?: Partial; +}; + export type CameraGroupConfig = { cameras: string[]; icon: IconName; diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts new file mode 100644 index 000000000..2c96e51da --- /dev/null +++ b/web/src/types/profile.ts @@ -0,0 +1,23 @@ +export type ProfileColor = { + bg: string; + text: string; + dot: string; + bgMuted: string; +}; + +export type ProfileState = { + editingProfile: Record; + newProfiles: string[]; + allProfileNames: string[]; + onSelectProfile: ( + camera: string, + section: string, + profile: string | null, + ) => void; + onAddProfile: (name: string) => void; + onDeleteProfileSection: ( + camera: string, + section: string, + profile: string, + ) => void; +}; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 216ade9fa..5320fca69 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -68,6 +68,42 @@ export const globalCameraDefaultSections = new Set([ "ffmpeg", ]); +// --------------------------------------------------------------------------- +// Profile helpers +// --------------------------------------------------------------------------- + +/** Sections that can appear inside a camera profile definition. */ +export const PROFILE_ELIGIBLE_SECTIONS = new Set([ + "audio", + "birdseye", + "detect", + "motion", + "notifications", + "objects", + "record", + "review", + "snapshots", +]); + +/** + * Parse a section path that may encode a profile reference. + * + * Examples: + * "detect" → { isProfile: false, actualSection: "detect" } + * "profiles.armed.detect" → { isProfile: true, profileName: "armed", actualSection: "detect" } + */ +export function parseProfileFromSectionPath(sectionPath: string): { + isProfile: boolean; + profileName?: string; + actualSection: string; +} { + const match = sectionPath.match(/^profiles\.([^.]+)\.(.+)$/); + if (match) { + return { isProfile: true, profileName: match[1], actualSection: match[2] }; + } + return { isProfile: false, actualSection: sectionPath }; +} + // --------------------------------------------------------------------------- // buildOverrides — pure recursive diff of current vs stored config & defaults // --------------------------------------------------------------------------- @@ -421,15 +457,19 @@ export function prepareSectionSavePayload(opts: { level = "global"; } - // Resolve section config - const sectionConfig = getSectionConfig(sectionPath, level); + // Detect profile-encoded section paths (e.g., "profiles.armed.detect") + const profileInfo = parseProfileFromSectionPath(sectionPath); + const schemaSection = profileInfo.actualSection; - // Resolve section schema - const sectionSchema = extractSectionSchema(fullSchema, sectionPath, level); + // Resolve section config using the actual section name (not the profile path) + const sectionConfig = getSectionConfig(schemaSection, level); + + // Resolve section schema using the actual section name + const sectionSchema = extractSectionSchema(fullSchema, schemaSection, level); if (!sectionSchema) return null; const modifiedSchema = modifySchemaForSection( - sectionPath, + schemaSection, level, sectionSchema, ); @@ -457,7 +497,7 @@ export function prepareSectionSavePayload(opts: { ? applySchemaDefaults(modifiedSchema, {}) : {}; const effectiveDefaults = getEffectiveDefaultsForSection( - sectionPath, + schemaSection, level, modifiedSchema ?? undefined, schemaDefaults, @@ -466,7 +506,7 @@ export function prepareSectionSavePayload(opts: { // Build overrides const overrides = buildOverrides(pendingData, rawData, effectiveDefaults); const sanitizedOverrides = sanitizeOverridesForSection( - sectionPath, + schemaSection, level, overrides, ); @@ -485,9 +525,11 @@ export function prepareSectionSavePayload(opts: { ? `cameras.${cameraName}.${sectionPath}` : sectionPath; - // Compute updateTopic + // Compute updateTopic — profile definitions don't trigger hot-reload let updateTopic: string | undefined; - if (level === "camera" && cameraName) { + if (profileInfo.isProfile) { + updateTopic = undefined; + } else if (level === "camera" && cameraName) { const topic = cameraUpdateTopicMap[sectionPath]; updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined; } else if (globalCameraDefaultSections.has(sectionPath)) { @@ -497,12 +539,14 @@ export function prepareSectionSavePayload(opts: { updateTopic = `config/${sectionPath}`; } - // Restart detection - const needsRestart = requiresRestartForOverrides( - sanitizedOverrides, - sectionConfig.restartRequired, - true, - ); + // Restart detection — profile definitions never need restart + const needsRestart = profileInfo.isProfile + ? false + : requiresRestartForOverrides( + sanitizedOverrides, + sectionConfig.restartRequired, + true, + ); return { basePath, diff --git a/web/src/utils/profileColors.ts b/web/src/utils/profileColors.ts new file mode 100644 index 000000000..9a374cd02 --- /dev/null +++ b/web/src/utils/profileColors.ts @@ -0,0 +1,67 @@ +import type { ProfileColor } from "@/types/profile"; + +const PROFILE_COLORS: ProfileColor[] = [ + { + bg: "bg-blue-500", + text: "text-blue-500", + dot: "bg-blue-500", + bgMuted: "bg-blue-500/20", + }, + { + bg: "bg-emerald-500", + text: "text-emerald-500", + dot: "bg-emerald-500", + bgMuted: "bg-emerald-500/20", + }, + { + bg: "bg-amber-500", + text: "text-amber-500", + dot: "bg-amber-500", + bgMuted: "bg-amber-500/20", + }, + { + bg: "bg-purple-500", + text: "text-purple-500", + dot: "bg-purple-500", + bgMuted: "bg-purple-500/20", + }, + { + bg: "bg-rose-500", + text: "text-rose-500", + dot: "bg-rose-500", + bgMuted: "bg-rose-500/20", + }, + { + bg: "bg-cyan-500", + text: "text-cyan-500", + dot: "bg-cyan-500", + bgMuted: "bg-cyan-500/20", + }, + { + bg: "bg-orange-500", + text: "text-orange-500", + dot: "bg-orange-500", + bgMuted: "bg-orange-500/20", + }, + { + bg: "bg-teal-500", + text: "text-teal-500", + dot: "bg-teal-500", + bgMuted: "bg-teal-500/20", + }, +]; + +/** + * Get a deterministic color for a profile name. + * + * Colors are assigned based on sorted position among all profile names, + * so the same profile always gets the same color regardless of context. + */ +export function getProfileColor( + profileName: string, + allProfileNames: string[], +): ProfileColor { + const sorted = [...allProfileNames].sort(); + const index = sorted.indexOf(profileName); + return PROFILE_COLORS[(index >= 0 ? index : 0) % PROFILE_COLORS.length]; +} From edf7fcb5b44793ed1c0c5930a128f1c19655607b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:06:11 -0500 Subject: [PATCH 15/64] add profile state management and save preview support --- .../overlay/detail/SaveAllPreviewPopover.tsx | 13 ++ web/src/pages/Settings.tsx | 126 +++++++++++++++++- web/src/views/settings/SingleSectionPage.tsx | 2 + 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx index 399051145..a77593531 100644 --- a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx +++ b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; export type SaveAllPreviewItem = { scope: "global" | "camera"; cameraName?: string; + profileName?: string; fieldPath: string; value: unknown; }; @@ -114,6 +115,18 @@ export default function SaveAllPreviewPopover({ })} {scopeLabel} + {item.profileName && ( + <> + + {t("saveAllPreview.profile.label", { + ns: "views/settings", + })} + + + {item.profileName} + + + )} {t("saveAllPreview.field.label", { ns: "views/settings", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2f069da78..00b9fdf68 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -87,8 +87,10 @@ import { mutate } from "swr"; import { RJSFSchema } from "@rjsf/utils"; import { buildConfigDataForPath, + parseProfileFromSectionPath, prepareSectionSavePayload, } from "@/utils/configUtil"; +import type { ProfileState } from "@/types/profile"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import SaveAllPreviewPopover, { @@ -621,6 +623,22 @@ export default function Settings() { Record >({}); + // Profile editing state + const [editingProfile, setEditingProfile] = useState< + Record + >({}); + const [newProfiles, setNewProfiles] = useState([]); + + const allProfileNames = useMemo(() => { + if (!config) return []; + const names = new Set(); + Object.values(config.cameras).forEach((cam) => { + Object.keys(cam.profiles ?? {}).forEach((p) => names.add(p)); + }); + newProfiles.forEach((p) => names.add(p)); + return [...names].sort(); + }, [config, newProfiles]); + const navigate = useNavigate(); const cameras = useMemo(() => { @@ -692,11 +710,20 @@ export default function Settings() { const { scope, cameraName, sectionPath } = parsePendingDataKey(pendingDataKey); + const { isProfile, profileName, actualSection } = + parseProfileFromSectionPath(sectionPath); const flattened = flattenOverrides(payload.sanitizedOverrides); + const displaySection = isProfile ? actualSection : sectionPath; flattened.forEach(({ path, value }) => { - const fieldPath = path ? `${sectionPath}.${path}` : sectionPath; - items.push({ scope, cameraName, fieldPath, value }); + const fieldPath = path ? `${displaySection}.${path}` : displaySection; + items.push({ + scope, + cameraName, + profileName: isProfile ? profileName : undefined, + fieldPath, + value, + }); }); }, ); @@ -726,15 +753,20 @@ export default function Settings() { level = "global"; } + // For profile keys like "profiles.armed.detect", extract the actual section + const { actualSection } = parseProfileFromSectionPath(sectionPath); + if (level === "camera") { - return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined; + return CAMERA_SECTION_MAPPING[actualSection] as + | SettingsType + | undefined; } return ( - (GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ?? - (ENRICHMENTS_SECTION_MAPPING[sectionPath] as + (GLOBAL_SECTION_MAPPING[actualSection] as SettingsType | undefined) ?? + (ENRICHMENTS_SECTION_MAPPING[actualSection] as | SettingsType | undefined) ?? - (SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined) + (SYSTEM_SECTION_MAPPING[actualSection] as SettingsType | undefined) ); }, [], @@ -884,6 +916,16 @@ export default function Settings() { setPendingDataBySection({}); setUnsavedChanges(false); + setEditingProfile({}); + + // Clear new profiles that don't exist in saved config + if (config) { + const savedNames = new Set(); + Object.values(config.cameras).forEach((cam) => { + Object.keys(cam.profiles ?? {}).forEach((p) => savedNames.add(p)); + }); + setNewProfiles((prev) => prev.filter((p) => savedNames.has(p))); + } setSectionStatusByKey((prev) => { const updated = { ...prev }; @@ -899,7 +941,7 @@ export default function Settings() { } return updated; }); - }, [pendingDataBySection, pendingKeyToMenuKey]); + }, [pendingDataBySection, pendingKeyToMenuKey, config]); const handleDialog = useCallback( (save: boolean) => { @@ -970,6 +1012,75 @@ export default function Settings() { } }, [t, contentMobileOpen]); + // Profile state handlers + const handleSelectProfile = useCallback( + (camera: string, section: string, profile: string | null) => { + const key = `${camera}::${section}`; + setEditingProfile((prev) => { + if (profile === null) { + const { [key]: _, ...rest } = prev; + return rest; + } + return { ...prev, [key]: profile }; + }); + }, + [], + ); + + const handleAddProfile = useCallback((name: string) => { + setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name])); + }, []); + + const handleDeleteProfileSection = useCallback( + async (camera: string, section: string, profile: string) => { + try { + await axios.put("config/set", { + config_data: { + cameras: { + [camera]: { + profiles: { + [profile]: { + [section]: "", + }, + }, + }, + }, + }, + }); + await mutate("config"); + // Switch back to base config + handleSelectProfile(camera, section, null); + toast.success( + t("toast.save.success", { + ns: "common", + }), + ); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" })); + } + }, + [handleSelectProfile, t], + ); + + const profileState: ProfileState = useMemo( + () => ({ + editingProfile, + newProfiles, + allProfileNames, + onSelectProfile: handleSelectProfile, + onAddProfile: handleAddProfile, + onDeleteProfileSection: handleDeleteProfileSection, + }), + [ + editingProfile, + newProfiles, + allProfileNames, + handleSelectProfile, + handleAddProfile, + handleDeleteProfileSection, + ], + ); + const handleSectionStatusChange = useCallback( (sectionKey: string, level: "global" | "camera", status: SectionStatus) => { // Map section keys to menu keys based on level @@ -1244,6 +1355,7 @@ export default function Settings() { onSectionStatusChange={handleSectionStatusChange} pendingDataBySection={pendingDataBySection} onPendingDataChange={handlePendingDataChange} + profileState={profileState} /> ); })()} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index c1e7752f7..c1027aa18 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -5,6 +5,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; import { Badge } from "@/components/ui/badge"; import type { ConfigSectionData } from "@/types/configForm"; +import type { ProfileState } from "@/types/profile"; import { getSectionConfig } from "@/utils/configUtil"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; @@ -26,6 +27,7 @@ export type SettingsPageProps = { cameraName: string | undefined, data: ConfigSectionData | null, ) => void; + profileState?: ProfileState; }; export type SectionStatus = { From d5dc77daa456e947b5a4539f68ea9a474a785310 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:11:56 -0500 Subject: [PATCH 16/64] add profileName prop to BaseSection for profile-aware config editing --- .../config-form/sections/BaseSection.tsx | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 047edd449..d2f392944 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -34,6 +34,7 @@ import Heading from "@/components/ui/heading"; import get from "lodash/get"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; +import merge from "lodash/merge"; import { Collapsible, CollapsibleContent, @@ -136,6 +137,8 @@ export interface BaseSectionProps { cameraName: string | undefined, data: ConfigSectionData | null, ) => void; + /** When set, editing this profile's overrides instead of the base config */ + profileName?: string; } export interface CreateSectionOptions { @@ -166,6 +169,7 @@ export function ConfigSection({ onStatusChange, pendingDataBySection, onPendingDataChange, + profileName, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -181,12 +185,17 @@ export function ConfigSection({ const statusBar = useContext(StatusBarMessagesContext); // Create a key for this section's pending data + // When editing a profile, use "cameraName::profiles.profileName.sectionPath" + const effectiveSectionPath = profileName + ? `profiles.${profileName}.${sectionPath}` + : sectionPath; + const pendingDataKey = useMemo( () => effectiveLevel === "camera" && cameraName - ? `${cameraName}::${sectionPath}` - : sectionPath, - [effectiveLevel, cameraName, sectionPath], + ? `${cameraName}::${effectiveSectionPath}` + : effectiveSectionPath, + [effectiveLevel, cameraName, effectiveSectionPath], ); // Use pending data from parent if available, otherwise use local state @@ -213,12 +222,12 @@ export function ConfigSection({ const setPendingData = useCallback( (data: ConfigSectionData | null) => { if (onPendingDataChange) { - onPendingDataChange(sectionPath, cameraName, data); + onPendingDataChange(effectiveSectionPath, cameraName, data); } else { setLocalPendingData(data); } }, - [onPendingDataChange, sectionPath, cameraName], + [onPendingDataChange, effectiveSectionPath, cameraName], ); const [isSaving, setIsSaving] = useState(false); const [hasValidationErrors, setHasValidationErrors] = useState(false); @@ -230,8 +239,10 @@ export function ConfigSection({ const isInitializingRef = useRef(true); const lastPendingDataKeyRef = useRef(null); - const updateTopic = - effectiveLevel === "camera" && cameraName + // Profile definitions don't hot-reload — only PUT /api/profile/set applies them + const updateTopic = profileName + ? undefined + : effectiveLevel === "camera" && cameraName ? cameraUpdateTopicMap[sectionPath] ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` : undefined @@ -265,15 +276,27 @@ export function ConfigSection({ }); // Get current form data + // When editing a profile, show base camera config deep-merged with profile overrides const rawSectionValue = useMemo(() => { if (!config) return undefined; if (effectiveLevel === "camera" && cameraName) { - return get(config.cameras?.[cameraName], sectionPath); + const baseValue = get(config.cameras?.[cameraName], sectionPath); + if (profileName) { + const profileOverrides = get( + config.cameras?.[cameraName], + `profiles.${profileName}.${sectionPath}`, + ); + if (profileOverrides && typeof profileOverrides === "object") { + return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides)); + } + return baseValue; + } + return baseValue; } return get(config, sectionPath); - }, [config, cameraName, sectionPath, effectiveLevel]); + }, [config, cameraName, sectionPath, effectiveLevel, profileName]); const rawFormData = useMemo(() => { if (!config) return {}; @@ -499,8 +522,8 @@ export function ConfigSection({ try { const basePath = effectiveLevel === "camera" && cameraName - ? `cameras.${cameraName}.${sectionPath}` - : sectionPath; + ? `cameras.${cameraName}.${effectiveSectionPath}` + : effectiveSectionPath; const rawData = sanitizeSectionData(rawFormData); const overrides = buildOverrides( pendingData, @@ -522,9 +545,11 @@ export function ConfigSection({ return; } - const needsRestart = skipSave - ? false - : requiresRestartForOverrides(sanitizedOverrides); + // Profile definition edits never require restart + const needsRestart = + skipSave || profileName + ? false + : requiresRestartForOverrides(sanitizedOverrides); const configData = buildConfigDataForPath(basePath, sanitizedOverrides); await axios.put("config/set", { @@ -619,6 +644,8 @@ export function ConfigSection({ } }, [ sectionPath, + effectiveSectionPath, + profileName, pendingData, effectiveLevel, cameraName, @@ -642,8 +669,8 @@ export function ConfigSection({ try { const basePath = effectiveLevel === "camera" && cameraName - ? `cameras.${cameraName}.${sectionPath}` - : sectionPath; + ? `cameras.${cameraName}.${effectiveSectionPath}` + : effectiveSectionPath; const configData = buildConfigDataForPath(basePath, ""); @@ -675,7 +702,7 @@ export function ConfigSection({ ); } }, [ - sectionPath, + effectiveSectionPath, effectiveLevel, cameraName, requiresRestart, @@ -855,7 +882,8 @@ export function ConfigSection({ {((effectiveLevel === "camera" && isOverridden) || effectiveLevel === "global") && !hasChanges && - !skipSave && ( + !skipSave && + !profileName && ( + + + onSelectProfile(null)}> +
+ {editingProfile === null && ( + + )} + + {t("profiles.baseConfig", { + ns: "views/settings", + defaultValue: "Base Config", + })} + +
+
+ + {allProfileNames.length > 0 && } + + {allProfileNames.map((profile) => { + const color = getProfileColor(profile, allProfileNames); + const hasData = hasProfileData(profile); + const isActive = editingProfile === profile; + + return ( + onSelectProfile(profile)} + > +
+ {isActive && } + + {profile} + {!hasData && ( + + {t("profiles.noOverrides", { + ns: "views/settings", + defaultValue: "no overrides", + })} + + )} +
+ {hasData && ( + + )} +
+ ); + })} + + + { + setNewProfileName(""); + setNameError(null); + setAddDialogOpen(true); + }} + > + + {t("profiles.addProfile", { + ns: "views/settings", + defaultValue: "Add Profile...", + })} + +
+ + + + + + + {t("profiles.newProfile", { + ns: "views/settings", + defaultValue: "New Profile", + })} + + +
+ { + setNewProfileName(e.target.value); + setNameError(validateName(e.target.value)); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddSubmit(); + } + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + { + if (!open) setDeleteConfirmProfile(null); + }} + > + + + + {t("profiles.deleteSection", { + ns: "views/settings", + defaultValue: "Delete Section Overrides", + })} + + + {t("profiles.deleteSectionConfirm", { + ns: "views/settings", + defaultValue: + "Remove {{profile}}'s overrides for {{section}} on {{camera}}?", + profile: deleteConfirmProfile, + section: sectionKey, + camera: cameraName, + })} + + + + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common", defaultValue: "Delete" })} + + + + + + ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 00b9fdf68..7835ed1c3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1571,6 +1571,7 @@ export default function Settings() { onSectionStatusChange={handleSectionStatusChange} pendingDataBySection={pendingDataBySection} onPendingDataChange={handlePendingDataChange} + profileState={profileState} /> ); })()} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index c1027aa18..5dd0feb14 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -1,16 +1,22 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import useSWR from "swr"; import type { SectionConfig } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; +import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; import type { ConfigSectionData } from "@/types/configForm"; import type { ProfileState } from "@/types/profile"; -import { getSectionConfig } from "@/utils/configUtil"; +import { + getSectionConfig, + PROFILE_ELIGIBLE_SECTIONS, +} from "@/utils/configUtil"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import Heading from "@/components/ui/heading"; +import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; export type SettingsPageProps = { selectedCamera?: string; @@ -58,6 +64,7 @@ export function SingleSectionPage({ onSectionStatusChange, pendingDataBySection, onPendingDataChange, + profileState, }: SingleSectionPageProps) { const sectionNamespace = level === "camera" ? "config/cameras" : "config/global"; @@ -67,6 +74,7 @@ export function SingleSectionPage({ "common", ]); const { getLocaleDocUrl } = useDocDomain(); + const { data: config } = useSWR("config"); const [sectionStatus, setSectionStatus] = useState({ hasChanges: false, isOverridden: false, @@ -80,6 +88,20 @@ export function SingleSectionPage({ ? getLocaleDocUrl(resolvedSectionConfig.sectionDocs) : undefined; + // Profile support: determine if this section supports profiles + const isProfileEligible = + level === "camera" && + selectedCamera && + profileState && + PROFILE_ELIGIBLE_SECTIONS.has(sectionKey); + + const profileKey = selectedCamera + ? `${selectedCamera}::${sectionKey}` + : undefined; + const currentEditingProfile = profileKey + ? (profileState?.editingProfile[profileKey] ?? null) + : null; + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -126,6 +148,36 @@ export function SingleSectionPage({
+ {isProfileEligible && selectedCamera && profileState && ( + { + const profileData = + config?.cameras?.[selectedCamera]?.profiles?.[profile]; + return !!profileData?.[ + sectionKey as keyof typeof profileData + ]; + }} + onSelectProfile={(profile) => + profileState.onSelectProfile( + selectedCamera, + sectionKey, + profile, + ) + } + onAddProfile={profileState.onAddProfile} + onDeleteProfileSection={(profile) => + profileState.onDeleteProfileSection( + selectedCamera, + sectionKey, + profile, + ) + } + /> + )} {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && ( @@ -162,6 +214,7 @@ export function SingleSectionPage({ onPendingDataChange={onPendingDataChange} requiresRestart={requiresRestart} onStatusChange={handleSectionStatusChange} + profileName={currentEditingProfile ?? undefined} />
); From 7131acafa64dac80d2a5e74bf1d81d5db5f6c055 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:35:35 -0500 Subject: [PATCH 18/64] add per-profile camera enable/disable to Camera Management view --- .../settings/ProfileSectionDropdown.tsx | 39 +-- .../views/settings/CameraManagementView.tsx | 222 ++++++++++++++++++ 2 files changed, 230 insertions(+), 31 deletions(-) diff --git a/web/src/components/settings/ProfileSectionDropdown.tsx b/web/src/components/settings/ProfileSectionDropdown.tsx index 5a456d6ab..10ad0d437 100644 --- a/web/src/components/settings/ProfileSectionDropdown.tsx +++ b/web/src/components/settings/ProfileSectionDropdown.tsx @@ -65,13 +65,11 @@ export function ProfileSectionDropdown({ if (!/^[a-z0-9_]+$/.test(name)) { return t("profiles.nameInvalid", { ns: "views/settings", - defaultValue: "Only lowercase letters, numbers, and underscores", }); } if (allProfileNames.includes(name)) { return t("profiles.nameDuplicate", { ns: "views/settings", - defaultValue: "Profile already exists", }); } return null; @@ -132,10 +130,7 @@ export function ProfileSectionDropdown({ {editingProfile} ) : ( - t("profiles.baseConfig", { - ns: "views/settings", - defaultValue: "Base Config", - }) + t("profiles.baseConfig", { ns: "views/settings" }) )} @@ -147,10 +142,7 @@ export function ProfileSectionDropdown({ )} - {t("profiles.baseConfig", { - ns: "views/settings", - defaultValue: "Base Config", - })} + {t("profiles.baseConfig", { ns: "views/settings" })}
@@ -180,10 +172,7 @@ export function ProfileSectionDropdown({ {profile} {!hasData && ( - {t("profiles.noOverrides", { - ns: "views/settings", - defaultValue: "no overrides", - })} + {t("profiles.noOverrides", { ns: "views/settings" })} )} @@ -211,10 +200,7 @@ export function ProfileSectionDropdown({ }} > - {t("profiles.addProfile", { - ns: "views/settings", - defaultValue: "Add Profile...", - })} + {t("profiles.addProfile", { ns: "views/settings" })} @@ -223,17 +209,13 @@ export function ProfileSectionDropdown({ - {t("profiles.newProfile", { - ns: "views/settings", - defaultValue: "New Profile", - })} + {t("profiles.newProfile", { ns: "views/settings" })}
{ @@ -261,7 +243,7 @@ export function ProfileSectionDropdown({ onClick={handleAddSubmit} disabled={!newProfileName.trim() || !!nameError} > - {t("button.create", { ns: "common", defaultValue: "Create" })} + {t("button.create", { ns: "common" })} @@ -276,16 +258,11 @@ export function ProfileSectionDropdown({ - {t("profiles.deleteSection", { - ns: "views/settings", - defaultValue: "Delete Section Overrides", - })} + {t("profiles.deleteSection", { ns: "views/settings" })} {t("profiles.deleteSectionConfirm", { ns: "views/settings", - defaultValue: - "Remove {{profile}}'s overrides for {{section}} on {{camera}}?", profile: deleteConfirmProfile, section: sectionKey, camera: cameraName, @@ -300,7 +277,7 @@ export function ProfileSectionDropdown({ className="bg-destructive text-white hover:bg-destructive/90" onClick={handleDeleteConfirm} > - {t("button.delete", { ns: "common", defaultValue: "Delete" })} + {t("button.delete", { ns: "common" })} diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 1c5168953..46c1632f8 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -26,13 +26,25 @@ import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; +import type { ProfileState } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { cn } from "@/lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; type CameraManagementViewProps = { setUnsavedChanges: React.Dispatch>; + profileState?: ProfileState; }; export default function CameraManagementView({ setUnsavedChanges, + profileState, }: CameraManagementViewProps) { const { t } = useTranslation(["views/settings"]); @@ -200,6 +212,17 @@ export default function CameraManagementView({ )} )} + + {profileState && + profileState.allProfileNames.length > 0 && + enabledCameras.length > 0 && ( + + )}
) : ( @@ -364,3 +387,202 @@ function CameraConfigEnableSwitch({ ); } + +type ProfileCameraEnableSectionProps = { + profileState: ProfileState; + cameras: string[]; + config: FrigateConfig | undefined; + onConfigChanged: () => Promise; +}; + +function ProfileCameraEnableSection({ + profileState, + cameras, + config, + onConfigChanged, +}: ProfileCameraEnableSectionProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [selectedProfile, setSelectedProfile] = useState( + profileState.allProfileNames[0] ?? "", + ); + const [savingCamera, setSavingCamera] = useState(null); + // Optimistic local state: the parsed config API doesn't reflect profile + // enabled changes until Frigate restarts, so we track saved values locally. + const [localOverrides, setLocalOverrides] = useState< + Record> + >({}); + + const handleEnabledChange = useCallback( + async (camera: string, value: string) => { + setSavingCamera(camera); + try { + const enabledValue = + value === "enabled" ? true : value === "disabled" ? false : null; + const configData = + enabledValue === null + ? { + cameras: { + [camera]: { + profiles: { [selectedProfile]: { enabled: "" } }, + }, + }, + } + : { + cameras: { + [camera]: { + profiles: { [selectedProfile]: { enabled: enabledValue } }, + }, + }, + }; + + await axios.put("config/set", { config_data: configData }); + await onConfigChanged(); + + setLocalOverrides((prev) => ({ + ...prev, + [selectedProfile]: { + ...prev[selectedProfile], + [camera]: value, + }, + })); + + toast.success(t("toast.save.success", { ns: "common" }), { + position: "top-center", + }); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" }), { + position: "top-center", + }); + } finally { + setSavingCamera(null); + } + }, + [selectedProfile, onConfigChanged, t], + ); + + const getEnabledState = useCallback( + (camera: string): string => { + // Check optimistic local state first + const localValue = localOverrides[selectedProfile]?.[camera]; + if (localValue) return localValue; + + const profileData = + config?.cameras?.[camera]?.profiles?.[selectedProfile]; + if (!profileData || profileData.enabled === undefined) return "inherit"; + return profileData.enabled ? "enabled" : "disabled"; + }, + [config, selectedProfile, localOverrides], + ); + + const profileColor = selectedProfile + ? getProfileColor(selectedProfile, profileState.allProfileNames) + : null; + + if (!selectedProfile) return null; + + return ( + +
+
+ +

+ {t("cameraManagement.profiles.description", { + ns: "views/settings", + })} +

+
+
+ + +
+ {cameras.map((camera) => { + const state = getEnabledState(camera); + const isSaving = savingCamera === camera; + + return ( +
+ + {isSaving ? ( + + ) : ( + + )} +
+ ); + })} +
+
+
+
+ ); +} From fe7aa2ba3de7b448f562b0756f415c46b147024b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:52:26 -0500 Subject: [PATCH 19/64] add profiles summary page with card-based layout and fix backend zone comparison bug --- frigate/config/profile_manager.py | 12 +- web/src/pages/Settings.tsx | 7 +- web/src/views/settings/ProfilesView.tsx | 379 ++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 web/src/views/settings/ProfilesView.tsx diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index ebf4bb0c0..f05830693 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -71,8 +71,7 @@ class ProfileManager: """ if profile_name is not None: has_profile = any( - profile_name in cam.profiles - for cam in self.config.cameras.values() + 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" @@ -109,9 +108,10 @@ class ProfileManager: cam_config.enabled = base_enabled changed.setdefault(cam_name, set()).add("enabled") - # Restore zones + # Restore zones (always restore from snapshot; direct Pydantic + # comparison fails when ZoneConfig contains numpy arrays) base_zones = self._base_zones.get(cam_name) - if base_zones is not None and cam_config.zones != base_zones: + if base_zones is not None: cam_config.zones = copy.deepcopy(base_zones) changed.setdefault(cam_name, set()).add("zones") @@ -195,9 +195,7 @@ class ProfileManager: if section == "zones": self.config_updater.publish_update( - CameraConfigUpdateTopic( - CameraConfigUpdateEnum.zones, cam_name - ), + CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, cam_name), cam_config.zones, ) continue diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7835ed1c3..3137f14f4 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -39,6 +39,7 @@ import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; import RolesView from "@/views/settings/RolesView"; import UiSettingsView from "@/views/settings/UiSettingsView"; +import ProfilesView from "@/views/settings/ProfilesView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView"; import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView"; @@ -100,6 +101,7 @@ import { useRestart } from "@/api/ws"; const allSettingsViews = [ "profileSettings", + "profiles", "globalDetect", "globalRecording", "globalSnapshots", @@ -310,7 +312,10 @@ const CameraTimestampStyleSettingsPage = createSectionPage( const settingsGroups = [ { label: "general", - items: [{ key: "profileSettings", component: UiSettingsView }], + items: [ + { key: "profileSettings", component: UiSettingsView }, + { key: "profiles", component: ProfilesView }, + ], }, { label: "globalConfig", diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx new file mode 100644 index 000000000..60f8e1694 --- /dev/null +++ b/web/src/views/settings/ProfilesView.tsx @@ -0,0 +1,379 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import axios from "axios"; +import { toast } from "sonner"; +import { Camera, Trash2 } from "lucide-react"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import type { ProfileState } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil"; +import { cn } from "@/lib/utils"; +import Heading from "@/components/ui/heading"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type ProfilesApiResponse = { + profiles: string[]; + active_profile: string | null; +}; + +type ProfilesViewProps = { + setUnsavedChanges?: React.Dispatch>; + profileState?: ProfileState; +}; + +export default function ProfilesView({ profileState }: ProfilesViewProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { data: config, mutate: updateConfig } = + useSWR("config"); + const { data: profilesData, mutate: updateProfiles } = + useSWR("profiles"); + + const [activating, setActivating] = useState(false); + const [deleteProfile, setDeleteProfile] = useState(null); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + document.title = t("documentTitle.profiles", { + ns: "views/settings", + }); + }, [t]); + + const allProfileNames = useMemo( + () => profileState?.allProfileNames ?? [], + [profileState?.allProfileNames], + ); + const activeProfile = profilesData?.active_profile ?? null; + + // Build overview data: for each profile, which cameras have which sections + const profileOverviewData = useMemo(() => { + if (!config || allProfileNames.length === 0) return {}; + + const data: Record> = {}; + const cameras = Object.keys(config.cameras).sort(); + + for (const profile of allProfileNames) { + data[profile] = {}; + for (const camera of cameras) { + const profileData = config.cameras[camera]?.profiles?.[profile]; + if (!profileData) continue; + + const sections: string[] = []; + for (const section of PROFILE_ELIGIBLE_SECTIONS) { + if ( + profileData[section as keyof typeof profileData] !== undefined && + profileData[section as keyof typeof profileData] !== null + ) { + sections.push(section); + } + } + if (profileData.enabled !== undefined && profileData.enabled !== null) { + sections.push("enabled"); + } + if (sections.length > 0) { + data[profile][camera] = sections; + } + } + } + return data; + }, [config, allProfileNames]); + + const cameraCount = useMemo(() => { + if (!config) return 0; + return Object.keys(profileOverviewData).reduce((max, profile) => { + const count = Object.keys(profileOverviewData[profile] ?? {}).length; + return Math.max(max, count); + }, 0); + }, [config, profileOverviewData]); + + const handleActivateProfile = useCallback( + async (profile: string | null) => { + setActivating(true); + try { + await axios.put("profile/set", { + profile: profile || null, + }); + await updateProfiles(); + toast.success( + profile + ? t("profiles.activated", { + ns: "views/settings", + profile, + }) + : t("profiles.deactivated", { ns: "views/settings" }), + { position: "top-center" }, + ); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" }), { + position: "top-center", + }); + } finally { + setActivating(false); + } + }, + [updateProfiles, t], + ); + + const handleDeleteProfile = useCallback(async () => { + if (!deleteProfile || !config) return; + setDeleting(true); + + try { + // If this profile is active, deactivate it first + if (activeProfile === deleteProfile) { + await axios.put("profile/set", { profile: null }); + } + + // Remove the profile from all cameras via config/set + const configData: Record = {}; + for (const camera of Object.keys(config.cameras)) { + if (config.cameras[camera]?.profiles?.[deleteProfile]) { + configData[camera] = { + profiles: { [deleteProfile]: "" }, + }; + } + } + + if (Object.keys(configData).length > 0) { + await axios.put("config/set", { + config_data: { cameras: configData }, + }); + } + + await updateConfig(); + await updateProfiles(); + + toast.success( + t("profiles.deleteSuccess", { + ns: "views/settings", + profile: deleteProfile, + }), + { position: "top-center" }, + ); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" }), { + position: "top-center", + }); + } finally { + setDeleting(false); + setDeleteProfile(null); + } + }, [deleteProfile, activeProfile, config, updateConfig, updateProfiles, t]); + + if (!config || !profilesData) { + return null; + } + + return ( +
+ + {t("profiles.title", { ns: "views/settings" })} + + + {/* Active Profile Section */} +
+
+ {t("profiles.activeProfile", { ns: "views/settings" })} +
+
+ + {activeProfile && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
+
+ + {/* Profile Cards */} + {allProfileNames.length === 0 ? ( +
+

{t("profiles.noProfiles", { ns: "views/settings" })}

+
+ ) : ( +
+ {allProfileNames.map((profile) => { + const color = getProfileColor(profile, allProfileNames); + const isActive = activeProfile === profile; + const cameraData = profileOverviewData[profile] ?? {}; + const cameras = Object.keys(cameraData).sort(); + + return ( +
+
+
+ + {profile} + {isActive && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
+ +
+ + {cameras.length === 0 ? ( +

+ {t("profiles.noOverrides", { ns: "views/settings" })} +

+ ) : ( +
+ {cameras.map((camera) => { + const sections = cameraData[camera]; + return ( +
+ +
+
+ {camera} +
+
+ {sections.map((section) => ( + + {section} + + ))} +
+
+
+ ); + })} +
+ )} +
+ ); + })} +
+ )} + + {/* Delete Profile Confirmation */} + { + if (!open) setDeleteProfile(null); + }} + > + + + + {t("profiles.deleteProfile", { ns: "views/settings" })} + + + {t("profiles.deleteProfileConfirm", { + ns: "views/settings", + profile: deleteProfile, + })} + + + + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common" })} + + + + +
+ ); +} From fa49e0e7b121021daf262e4ab6baf2844b1fc37d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:57:24 -0500 Subject: [PATCH 20/64] add active profile badge to settings toolbar --- web/src/pages/Settings.tsx | 45 ++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 3137f14f4..0ca568855 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -92,6 +92,8 @@ import { prepareSectionSavePayload, } from "@/utils/configUtil"; import type { ProfileState } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { Badge } from "@/components/ui/badge"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import SaveAllPreviewPopover, { @@ -609,6 +611,10 @@ export default function Settings() { >({}); const { data: config } = useSWR("config"); + const { data: profilesData } = useSWR<{ + profiles: string[]; + active_profile: string | null; + }>("profiles"); const [searchParams] = useSearchParams(); @@ -1217,10 +1223,27 @@ export default function Settings() { /> -
+

{t("menu.settings", { ns: "common" })}

+ {profilesData?.active_profile && ( + { + setPage("profiles"); + setContentMobileOpen(true); + }} + > + {profilesData.active_profile} + + )}
@@ -1405,9 +1428,23 @@ export default function Settings() {
- - {t("menu.settings", { ns: "common" })} - +
+ + {t("menu.settings", { ns: "common" })} + + {profilesData?.active_profile && ( + setPage("profiles")} + > + {profilesData.active_profile} + + )} +
{hasPendingChanges && (
Date: Mon, 9 Mar 2026 16:06:22 -0500 Subject: [PATCH 21/64] i18n --- web/public/locales/en/views/settings.json | 36 ++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index afbf27f82..17a60e9c5 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -13,7 +13,8 @@ "cameraConfig": "Camera Configuration - Frigate", "frigatePlus": "Frigate+ Settings - Frigate", "notifications": "Notification Settings - Frigate", - "maintenance": "Maintenance - Frigate" + "maintenance": "Maintenance - Frigate", + "profiles": "Profiles - Frigate" }, "menu": { "general": "General", @@ -23,6 +24,7 @@ "cameras": "Camera configuration", "ui": "UI", "profileSettings": "Profile settings", + "profiles": "Profiles", "globalDetect": "Object detection", "globalRecording": "Recording", "globalSnapshots": "Snapshots", @@ -101,6 +103,9 @@ "global": "Global", "camera": "Camera: {{cameraName}}" }, + "profile": { + "label": "Profile" + }, "field": { "label": "Field" }, @@ -473,6 +478,14 @@ "toast": { "success": "Camera {{cameraName}} saved successfully" } + }, + "profiles": { + "title": "Profile Camera Overrides", + "selectLabel": "Select profile", + "description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.", + "inherit": "Inherit", + "enabled": "Enabled", + "disabled": "Disabled" } }, "cameraReview": { @@ -1427,6 +1440,27 @@ "saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.", "saveAllFailure": "Failed to save all sections." }, + "profiles": { + "title": "Profiles", + "activeProfile": "Active Profile", + "noActiveProfile": "No active profile", + "active": "Active", + "activated": "Profile '{{profile}}' activated", + "deactivated": "Profile deactivated", + "noProfiles": "No profiles defined. Add a profile from any camera section.", + "noOverrides": "No overrides", + "baseConfig": "Base Config", + "addProfile": "Add Profile", + "newProfile": "New Profile", + "profileNamePlaceholder": "e.g., armed, away, night", + "nameInvalid": "Only lowercase letters, numbers, and underscores allowed", + "nameDuplicate": "A profile with this name already exists", + "deleteProfile": "Delete Profile", + "deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.", + "deleteSuccess": "Profile '{{profile}}' deleted", + "deleteSection": "Delete Section Overrides", + "deleteSectionConfirm": "Remove {{profile}}'s overrides for {{section}} on {{camera}}?" + }, "unsavedChanges": "You have unsaved changes", "confirmReset": "Confirm Reset", "resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.", From 6205a9d58800740b0c5164c4a958db868a0524cb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:08:50 -0500 Subject: [PATCH 22/64] add red dot for any pending changes including profiles --- web/src/pages/Settings.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 0ca568855..9bbb165c6 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1146,15 +1146,22 @@ export default function Settings() { Record> > = {}; + // Build a set of menu keys that have pending changes for this camera + const pendingMenuKeys = new Set(); + const cameraPrefix = `${selectedCamera}::`; + for (const key of Object.keys(pendingDataBySection)) { + if (key.startsWith(cameraPrefix)) { + const menuKey = pendingKeyToMenuKey(key); + if (menuKey) pendingMenuKeys.add(menuKey); + } + } + // Set override status for all camera sections using the shared mapping Object.entries(CAMERA_SECTION_MAPPING).forEach( ([sectionKey, settingsKey]) => { const isOverridden = cameraOverrides.includes(sectionKey); - // Check if there are pending changes for this camera and section - const pendingDataKey = `${selectedCamera}::${sectionKey}`; - const hasChanges = pendingDataKey in pendingDataBySection; overrideMap[settingsKey] = { - hasChanges, + hasChanges: pendingMenuKeys.has(settingsKey), isOverridden, }; }, @@ -1173,7 +1180,12 @@ export default function Settings() { }); return merged; }); - }, [selectedCamera, cameraOverrides, pendingDataBySection]); + }, [ + selectedCamera, + cameraOverrides, + pendingDataBySection, + pendingKeyToMenuKey, + ]); const renderMenuItemLabel = useCallback( (key: SettingsType) => { From 379247dee673b87f9690c9f245a221090fe54248 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:16:44 -0500 Subject: [PATCH 23/64] profile support for mask and zone editor --- web/public/locales/en/views/settings.json | 2 + .../settings/MotionMaskEditPane.tsx | 49 +- .../settings/ObjectMaskEditPane.tsx | 86 ++-- web/src/components/settings/PolygonItem.tsx | 154 ++++--- web/src/components/settings/ZoneEditPane.tsx | 215 +++++---- web/src/types/canvas.ts | 1 + web/src/views/settings/MasksAndZonesView.tsx | 424 ++++++++++++++++-- 7 files changed, 680 insertions(+), 251 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 17a60e9c5..92eb3b5ba 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -532,6 +532,8 @@ }, "restart_required": "Restart required (masks/zones changed)", "disabledInConfig": "Item is disabled in the config file", + "profileBase": "(base)", + "profileOverride": "(override)", "toast": { "success": { "copyCoordinates": "Copied coordinates for {{polyName}} to clipboard." diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 3857c4060..6a9786db9 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -44,6 +44,7 @@ type MotionMaskEditPaneProps = { onCancel?: () => void; snapPoints: boolean; setSnapPoints: React.Dispatch>; + editingProfile?: string | null; }; export default function MotionMaskEditPane({ @@ -58,6 +59,7 @@ export default function MotionMaskEditPane({ onCancel, snapPoints, setSnapPoints, + editingProfile, }: MotionMaskEditPaneProps) { const { t } = useTranslation(["views/settings"]); const { getLocaleDocUrl } = useDocDomain(); @@ -192,16 +194,28 @@ export default function MotionMaskEditPane({ coordinates: coordinates, }; + // Build config path based on profile mode + const motionMaskPath = editingProfile + ? { + profiles: { + [editingProfile]: { + motion: { mask: { [maskId]: maskConfig } }, + }, + }, + } + : { motion: { mask: { [maskId]: maskConfig } } }; + // If renaming, we need to delete the old mask first if (renamingMask) { + const deleteQueryPath = editingProfile + ? `cameras.${polygon.camera}.profiles.${editingProfile}.motion.mask.${polygon.name}` + : `cameras.${polygon.camera}.motion.mask.${polygon.name}`; + try { - await axios.put( - `config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`, - { - requires_restart: 0, - }, - ); - } catch (error) { + await axios.put(`config/set?${deleteQueryPath}`, { + requires_restart: 0, + }); + } catch { toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", }); @@ -210,22 +224,20 @@ export default function MotionMaskEditPane({ } } + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/motion`; + // Save the new/updated mask using JSON body axios .put("config/set", { config_data: { cameras: { - [polygon.camera]: { - motion: { - mask: { - [maskId]: maskConfig, - }, - }, - }, + [polygon.camera]: motionMaskPath, }, }, requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/motion`, + update_topic: updateTopic, }) .then((res) => { if (res.status === 200) { @@ -238,8 +250,10 @@ export default function MotionMaskEditPane({ }, ); updateConfig(); - // Publish the enabled state through websocket - sendMotionMaskState(enabled ? "ON" : "OFF"); + // Only publish WS state for base config + if (!editingProfile) { + sendMotionMaskState(enabled ? "ON" : "OFF"); + } } else { toast.error( t("toast.save.error.title", { @@ -277,6 +291,7 @@ export default function MotionMaskEditPane({ cameraConfig, t, sendMotionMaskState, + editingProfile, ], ); diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 380c40be1..6637d4834 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -51,6 +51,7 @@ type ObjectMaskEditPaneProps = { onCancel?: () => void; snapPoints: boolean; setSnapPoints: React.Dispatch>; + editingProfile?: string | null; }; export default function ObjectMaskEditPane({ @@ -65,6 +66,7 @@ export default function ObjectMaskEditPane({ onCancel, snapPoints, setSnapPoints, + editingProfile, }: ObjectMaskEditPaneProps) { const { t } = useTranslation(["views/settings"]); const { data: config, mutate: updateConfig } = @@ -190,14 +192,22 @@ export default function ObjectMaskEditPane({ // Determine if old mask was global or per-object const wasGlobal = polygon.objects.length === 0 || polygon.objects[0] === "all_labels"; - const oldPath = wasGlobal - ? `cameras.${polygon.camera}.objects.mask.${polygon.name}` - : `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + + let oldPath: string; + if (editingProfile) { + oldPath = wasGlobal + ? `cameras.${polygon.camera}.profiles.${editingProfile}.objects.mask.${polygon.name}` + : `cameras.${polygon.camera}.profiles.${editingProfile}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + } else { + oldPath = wasGlobal + ? `cameras.${polygon.camera}.objects.mask.${polygon.name}` + : `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + } await axios.put(`config/set?${oldPath}`, { requires_restart: 0, }); - } catch (error) { + } catch { toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", }); @@ -206,45 +216,32 @@ export default function ObjectMaskEditPane({ } } - // Build the config structure based on whether it's global or per-object - let configBody; - if (globalMask) { - configBody = { - config_data: { - cameras: { - [polygon.camera]: { - objects: { - mask: { - [maskId]: maskConfig, - }, - }, - }, + // Build config path based on profile mode + const objectsSection = globalMask + ? { objects: { mask: { [maskId]: maskConfig } } } + : { + objects: { + filters: { [form_objects]: { mask: { [maskId]: maskConfig } } }, }, + }; + + const cameraData = editingProfile + ? { profiles: { [editingProfile]: objectsSection } } + : objectsSection; + + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/objects`; + + const configBody = { + config_data: { + cameras: { + [polygon.camera]: cameraData, }, - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/objects`, - }; - } else { - configBody = { - config_data: { - cameras: { - [polygon.camera]: { - objects: { - filters: { - [form_objects]: { - mask: { - [maskId]: maskConfig, - }, - }, - }, - }, - }, - }, - }, - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/objects`, - }; - } + }, + requires_restart: 0, + update_topic: updateTopic, + }; axios .put("config/set", configBody) @@ -259,8 +256,10 @@ export default function ObjectMaskEditPane({ }, ); updateConfig(); - // Publish the enabled state through websocket - sendObjectMaskState(enabled ? "ON" : "OFF"); + // Only publish WS state for base config + if (!editingProfile) { + sendObjectMaskState(enabled ? "ON" : "OFF"); + } } else { toast.error( t("toast.save.error.title", { @@ -301,6 +300,7 @@ export default function ObjectMaskEditPane({ cameraConfig, t, sendObjectMaskState, + editingProfile, ], ); diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 13522f9fc..c3f1b7742 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next"; import ActivityIndicator from "../indicators/activity-indicator"; import { cn } from "@/lib/utils"; import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; +import { getProfileColor } from "@/utils/profileColors"; type PolygonItemProps = { polygon: Polygon; @@ -48,6 +49,8 @@ type PolygonItemProps = { setIsLoading: (loading: boolean) => void; loadingPolygonIndex: number | undefined; setLoadingPolygonIndex: (index: number | undefined) => void; + editingProfile?: string | null; + allProfileNames?: string[]; }; export default function PolygonItem({ @@ -62,6 +65,8 @@ export default function PolygonItem({ setIsLoading, loadingPolygonIndex, setLoadingPolygonIndex, + editingProfile, + allProfileNames, }: PolygonItemProps) { const { t } = useTranslation("views/settings"); const { data: config, mutate: updateConfig } = @@ -107,6 +112,8 @@ export default function PolygonItem({ const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; + const isBasePolygon = !!editingProfile && polygon.polygonSource === "base"; + const saveToConfig = useCallback( async (polygon: Polygon) => { if (!polygon || !cameraConfig) { @@ -122,25 +129,36 @@ export default function PolygonItem({ ? "objects" : polygon.type; + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/${updateTopicType}`; + setIsLoading(true); setLoadingPolygonIndex(index); if (polygon.type === "zone") { - // Zones use query string format - const { alertQueries, detectionQueries } = reviewQueries( - polygon.name, - false, - false, - polygon.camera, - cameraConfig?.review.alerts.required_zones || [], - cameraConfig?.review.detections.required_zones || [], - ); - const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + let url: string; + + if (editingProfile) { + // Profile mode: just delete the profile zone + url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`; + } else { + // Base mode: handle review queries + const { alertQueries, detectionQueries } = reviewQueries( + polygon.name, + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + ); + url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + } await axios .put(`config/set?${url}`, { requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, + update_topic: updateTopic, }) .then((res) => { if (res.status === 200) { @@ -178,64 +196,34 @@ export default function PolygonItem({ } // Motion masks and object masks use JSON body format - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let configUpdate: any = {}; - - if (polygon.type === "motion_mask") { - // Delete mask from motion.mask dict by setting it to undefined - configUpdate = { - cameras: { - [polygon.camera]: { - motion: { - mask: { - [polygon.name]: null, // Setting to null will delete the key - }, - }, - }, - }, - }; - } - - if (polygon.type === "object_mask") { - // Determine if this is a global mask or object-specific mask - const isGlobalMask = !polygon.objects.length; - - if (isGlobalMask) { - configUpdate = { - cameras: { - [polygon.camera]: { - objects: { - mask: { - [polygon.name]: null, // Setting to null will delete the key - }, - }, - }, - }, - }; - } else { - configUpdate = { - cameras: { - [polygon.camera]: { + const deleteSection = + polygon.type === "motion_mask" + ? { motion: { mask: { [polygon.name]: null } } } + : !polygon.objects.length + ? { objects: { mask: { [polygon.name]: null } } } + : { objects: { filters: { [polygon.objects[0]]: { - mask: { - [polygon.name]: null, // Setting to null will delete the key - }, + mask: { [polygon.name]: null }, }, }, }, - }, - }, - }; - } - } + }; + + const configUpdate = { + cameras: { + [polygon.camera]: editingProfile + ? { profiles: { [editingProfile]: deleteSection } } + : deleteSection, + }, + }; await axios .put("config/set", { config_data: configUpdate, requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, + update_topic: updateTopic, }) .then((res) => { if (res.status === 200) { @@ -278,6 +266,7 @@ export default function PolygonItem({ setIsLoading, index, setLoadingPolygonIndex, + editingProfile, ], ); @@ -289,14 +278,19 @@ export default function PolygonItem({ const handleToggleEnabled = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - // Prevent toggling if disabled in config - if (polygon.enabled_in_config === false) { + // Prevent toggling if disabled in config or if this is a base polygon in profile mode + if (polygon.enabled_in_config === false || isBasePolygon) { return; } if (!polygon) { return; } + // Don't toggle via WS in profile mode + if (editingProfile) { + return; + } + const isEnabled = isPolygonEnabled; const nextState = isEnabled ? "OFF" : "ON"; @@ -320,6 +314,8 @@ export default function PolygonItem({ sendZoneState, sendMotionMaskState, sendObjectMaskState, + isBasePolygon, + editingProfile, ], ); @@ -358,7 +354,12 @@ export default function PolygonItem({
setDeleteDialogOpen(true)} > {t("button.delete", { ns: "common" })} @@ -531,9 +554,12 @@ export default function PolygonItem({ "size-[15px] cursor-pointer", hoveredPolygonIndex === index && "fill-primary-variant text-primary-variant", - isLoading && "cursor-not-allowed opacity-50", + (isLoading || isBasePolygon) && + "cursor-not-allowed opacity-50", )} - onClick={() => !isLoading && setDeleteDialogOpen(true)} + onClick={() => + !isLoading && !isBasePolygon && setDeleteDialogOpen(true) + } /> diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index e52853972..c586febf1 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -50,6 +50,7 @@ type ZoneEditPaneProps = { setActiveLine: React.Dispatch>; snapPoints: boolean; setSnapPoints: React.Dispatch>; + editingProfile?: string | null; }; export default function ZoneEditPane({ @@ -65,6 +66,7 @@ export default function ZoneEditPane({ setActiveLine, snapPoints, setSnapPoints, + editingProfile, }: ZoneEditPaneProps) { const { t } = useTranslation(["views/settings"]); const { getLocaleDocUrl } = useDocDomain(); @@ -101,15 +103,23 @@ export default function ZoneEditPane({ }, [polygon, config]); const [lineA, lineB, lineC, lineD] = useMemo(() => { - const distances = - polygon?.camera && - polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name]?.distances; + if (!polygon?.camera || !polygon?.name || !config) { + return [undefined, undefined, undefined, undefined]; + } + + // Check profile zone first, then base + const profileZone = editingProfile + ? config.cameras[polygon.camera]?.profiles?.[editingProfile]?.zones?.[ + polygon.name + ] + : undefined; + const baseZone = config.cameras[polygon.camera]?.zones[polygon.name]; + const distances = profileZone?.distances ?? baseZone?.distances; return Array.isArray(distances) ? distances.map((value) => parseFloat(value) || 0) : [undefined, undefined, undefined, undefined]; - }, [polygon, config]); + }, [polygon, config, editingProfile]); const formSchema = z .object({ @@ -272,6 +282,17 @@ export default function ZoneEditPane({ }, ); + // Resolve zone data: profile zone takes priority over base + const resolvedZoneData = useMemo(() => { + if (!polygon?.camera || !polygon?.name || !config) return undefined; + const cam = config.cameras[polygon.camera]; + if (!cam) return undefined; + const profileZone = editingProfile + ? cam.profiles?.[editingProfile]?.zones?.[polygon.name] + : undefined; + return profileZone ?? cam.zones[polygon.name]; + }, [polygon, config, editingProfile]); + const form = useForm>({ resolver: zodResolver(formSchema), mode: "onBlur", @@ -279,20 +300,11 @@ export default function ZoneEditPane({ name: polygon?.name ?? "", friendly_name: polygon?.friendly_name ?? polygon?.name ?? "", enabled: - polygon?.camera && - polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled !== - undefined - ? config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled + resolvedZoneData?.enabled !== undefined + ? resolvedZoneData.enabled : (polygon?.enabled ?? true), - inertia: - polygon?.camera && - polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia, - loitering_time: - polygon?.camera && - polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, + inertia: resolvedZoneData?.inertia, + loitering_time: resolvedZoneData?.loitering_time, isFinished: polygon?.isFinished ?? false, objects: polygon?.objects ?? [], speedEstimation: !!(lineA || lineB || lineC || lineD), @@ -300,10 +312,7 @@ export default function ZoneEditPane({ lineB, lineC, lineD, - speed_threshold: - polygon?.camera && - polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold, + speed_threshold: resolvedZoneData?.speed_threshold, }, }); @@ -341,6 +350,16 @@ export default function ZoneEditPane({ if (!scaledWidth || !scaledHeight || !polygon) { return; } + + // Determine config path prefix based on profile mode + const pathPrefix = editingProfile + ? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}` + : `cameras.${polygon.camera}.zones.${zoneName}`; + + const oldPathPrefix = editingProfile + ? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}` + : `cameras.${polygon.camera}.zones.${polygon.name}`; + let mutatedConfig = config; let alertQueries = ""; let detectionQueries = ""; @@ -349,55 +368,74 @@ export default function ZoneEditPane({ if (renamingZone) { // rename - delete old zone and replace with new - const zoneInAlerts = - cameraConfig?.review.alerts.required_zones.includes(polygon.name) ?? - false; - const zoneInDetections = - cameraConfig?.review.detections.required_zones.includes( + let renameAlertQueries = ""; + let renameDetectionQueries = ""; + + // Only handle review queries for base config (not profiles) + if (!editingProfile) { + const zoneInAlerts = + cameraConfig?.review.alerts.required_zones.includes(polygon.name) ?? + false; + const zoneInDetections = + cameraConfig?.review.detections.required_zones.includes( + polygon.name, + ) ?? false; + + ({ + alertQueries: renameAlertQueries, + detectionQueries: renameDetectionQueries, + } = reviewQueries( polygon.name, - ) ?? false; + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + )); - const { - alertQueries: renameAlertQueries, - detectionQueries: renameDetectionQueries, - } = reviewQueries( - polygon.name, - false, - false, - polygon.camera, - cameraConfig?.review.alerts.required_zones || [], - cameraConfig?.review.detections.required_zones || [], - ); + try { + await axios.put( + `config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`, + { + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/zones`, + }, + ); - try { - await axios.put( - `config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`, - { + // Wait for the config to be updated + mutatedConfig = await updateConfig(); + } catch { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { + position: "top-center", + }); + return; + } + + // make sure new zone name is readded to review + ({ alertQueries, detectionQueries } = reviewQueries( + zoneName, + zoneInAlerts, + zoneInDetections, + polygon.camera, + mutatedConfig?.cameras[polygon.camera]?.review.alerts + .required_zones || [], + mutatedConfig?.cameras[polygon.camera]?.review.detections + .required_zones || [], + )); + } else { + // Profile mode: just delete the old profile zone path + try { + await axios.put(`config/set?${oldPathPrefix}`, { requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/zones`, - }, - ); - - // Wait for the config to be updated - mutatedConfig = await updateConfig(); - } catch (error) { - toast.error(t("toast.save.error.noMessage", { ns: "common" }), { - position: "top-center", - }); - return; + }); + mutatedConfig = await updateConfig(); + } catch { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { + position: "top-center", + }); + return; + } } - - // make sure new zone name is readded to review - ({ alertQueries, detectionQueries } = reviewQueries( - zoneName, - zoneInAlerts, - zoneInDetections, - polygon.camera, - mutatedConfig?.cameras[polygon.camera]?.review.alerts - .required_zones || [], - mutatedConfig?.cameras[polygon.camera]?.review.detections - .required_zones || [], - )); } const coordinates = flattenPoints( @@ -405,10 +443,7 @@ export default function ZoneEditPane({ ).join(","); let objectQueries = objects - .map( - (object) => - `&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`, - ) + .map((object) => `&${pathPrefix}.objects=${object}`) .join(""); const same_objects = @@ -419,55 +454,55 @@ export default function ZoneEditPane({ // deleting objects if (!objectQueries && !same_objects && !renamingZone) { - objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`; + objectQueries = `&${pathPrefix}.objects`; } let inertiaQuery = ""; if (inertia) { - inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`; + inertiaQuery = `&${pathPrefix}.inertia=${inertia}`; } let loiteringTimeQuery = ""; if (loitering_time >= 0) { - loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; + loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`; } let distancesQuery = ""; const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(","); if (speedEstimation) { - distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`; + distancesQuery = `&${pathPrefix}.distances=${distances}`; } else { if (distances != "") { - distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`; + distancesQuery = `&${pathPrefix}.distances`; } } let speedThresholdQuery = ""; if (speed_threshold >= 0 && speedEstimation) { - speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`; + speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`; } else { - if ( - polygon?.camera && - polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold - ) { - speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`; + if (resolvedZoneData?.speed_threshold) { + speedThresholdQuery = `&${pathPrefix}.speed_threshold`; } } let friendlyNameQuery = ""; if (friendly_name && friendly_name !== zoneName) { - friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`; + friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`; } - const enabledQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.enabled=${enabled ? "True" : "False"}`; + const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`; + + const updateTopic = editingProfile + ? undefined + : `config/cameras/${polygon.camera}/zones`; axios .put( - `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, + `config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, { requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/zones`, + update_topic: updateTopic, }, ) .then((res) => { @@ -481,8 +516,10 @@ export default function ZoneEditPane({ }, ); updateConfig(); - // Publish the enabled state through websocket - sendZoneState(enabled ? "ON" : "OFF"); + // Only publish WS state for base config (not profiles) + if (!editingProfile) { + sendZoneState(enabled ? "ON" : "OFF"); + } } else { toast.error( t("toast.save.error.title", { @@ -524,6 +561,8 @@ export default function ZoneEditPane({ cameraConfig, t, sendZoneState, + editingProfile, + resolvedZoneData, ], ); diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index c0c67e0f4..a43f751d9 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -14,6 +14,7 @@ export type Polygon = { friendly_name?: string; enabled?: boolean; enabled_in_config?: boolean; + polygonSource?: "base" | "profile" | "override"; }; export type ZoneFormValuesType = { diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index a80eab572..8e46b9a1e 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -1,4 +1,4 @@ -import { FrigateConfig } from "@/types/frigateConfig"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -35,21 +35,28 @@ import { useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { cn } from "@/lib/utils"; +import { ProfileState } from "@/types/profile"; +import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; +import axios from "axios"; +import { useSWRConfig } from "swr"; type MasksAndZoneViewProps = { selectedCamera: string; selectedZoneMask?: PolygonType[]; setUnsavedChanges: React.Dispatch>; + profileState?: ProfileState; }; export default function MasksAndZonesView({ selectedCamera, selectedZoneMask, setUnsavedChanges, + profileState, }: MasksAndZoneViewProps) { const { t } = useTranslation(["views/settings"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config } = useSWR("config"); + const { mutate } = useSWRConfig(); const [allPolygons, setAllPolygons] = useState([]); const [editingPolygons, setEditingPolygons] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -70,6 +77,63 @@ export default function MasksAndZonesView({ const [activeLine, setActiveLine] = useState(); const [snapPoints, setSnapPoints] = useState(false); + // Profile state + const profileSectionKey = `${selectedCamera}::masksAndZones`; + const currentEditingProfile = + profileState?.editingProfile[profileSectionKey] ?? null; + + const hasProfileData = useCallback( + (profileName: string) => { + if (!config || !selectedCamera) return false; + const profileData = + config.cameras[selectedCamera]?.profiles?.[profileName]; + if (!profileData) return false; + const hasZones = + profileData.zones && Object.keys(profileData.zones).length > 0; + const hasMotionMasks = + profileData.motion?.mask && + Object.keys(profileData.motion.mask).length > 0; + const hasObjectMasks = + (profileData.objects?.mask && + Object.keys(profileData.objects.mask).length > 0) || + (profileData.objects?.filters && + Object.values(profileData.objects.filters).some( + (f) => f.mask && Object.keys(f.mask).length > 0, + )); + return !!(hasZones || hasMotionMasks || hasObjectMasks); + }, + [config, selectedCamera], + ); + + const handleDeleteProfileMasksAndZones = useCallback( + async (profileName: string) => { + try { + // Delete zones, motion masks, and object masks from the profile + await axios.put("config/set", { + config_data: { + cameras: { + [selectedCamera]: { + profiles: { + [profileName]: { + zones: "", + motion: { mask: "" }, + objects: { mask: "", filters: "" }, + }, + }, + }, + }, + }, + }); + await mutate("config"); + profileState?.onSelectProfile(selectedCamera, "masksAndZones", null); + toast.success(t("toast.save.success", { ns: "common" })); + } catch { + toast.error(t("toast.save.error.noMessage", { ns: "common" })); + } + }, + [selectedCamera, mutate, profileState, t], + ); + const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -228,18 +292,84 @@ export default function MasksAndZonesView({ [allPolygons, scaledHeight, scaledWidth, t], ); + // Helper to dim colors for base polygons in profile mode + const dimColor = useCallback( + (color: number[]): number[] => { + if (!currentEditingProfile) return color; + return color.map((c) => Math.round(c * 0.4 + 153 * 0.6)); + }, + [currentEditingProfile], + ); + useEffect(() => { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { - const zones = Object.entries(cameraConfig.zones).map( - ([name, zoneData], index) => ({ + const profileData = currentEditingProfile + ? cameraConfig.profiles?.[currentEditingProfile] + : undefined; + + // Build base zone names set for source tracking + const baseZoneNames = new Set(Object.keys(cameraConfig.zones)); + const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {})); + const baseMotionMaskNames = new Set( + Object.keys(cameraConfig.motion.mask || {}), + ); + const profileMotionMaskNames = new Set( + Object.keys(profileData?.motion?.mask ?? {}), + ); + const baseGlobalObjectMaskNames = new Set( + Object.keys(cameraConfig.objects.mask || {}), + ); + const profileGlobalObjectMaskNames = new Set( + Object.keys(profileData?.objects?.mask ?? {}), + ); + + // Merge zones: profile zones override base zones with same name + const mergedZones = new Map< + string, + { + data: CameraConfig["zones"][string]; + source: "base" | "profile" | "override"; + } + >(); + + for (const [name, zoneData] of Object.entries(cameraConfig.zones)) { + if (currentEditingProfile && profileZoneNames.has(name)) { + // Profile overrides this base zone + mergedZones.set(name, { + data: profileData!.zones![name]!, + source: "override", + }); + } else { + mergedZones.set(name, { + data: zoneData, + source: currentEditingProfile ? "base" : "base", + }); + } + } + + // Add profile-only zones + if (profileData?.zones) { + for (const [name, zoneData] of Object.entries(profileData.zones)) { + if (!baseZoneNames.has(name)) { + mergedZones.set(name, { data: zoneData!, source: "profile" }); + } + } + } + + let zoneIndex = 0; + const zones: Polygon[] = []; + for (const [name, { data: zoneData, source }] of mergedZones) { + const isBase = source === "base" && !!currentEditingProfile; + const baseColor = zoneData.color ?? [128, 128, 0]; + zones.push({ type: "zone" as PolygonType, - typeIndex: index, + typeIndex: zoneIndex, camera: cameraConfig.name, name, friendly_name: zoneData.friendly_name, enabled: zoneData.enabled, enabled_in_config: zoneData.enabled_in_config, - objects: zoneData.objects, + objects: zoneData.objects ?? [], points: interpolatePoints( parseCoordinates(zoneData.coordinates), 1, @@ -248,21 +378,62 @@ export default function MasksAndZonesView({ scaledHeight, ), distances: - zoneData.distances?.map((distance) => parseFloat(distance)) ?? [], + zoneData.distances?.map((distance: string) => + parseFloat(distance), + ) ?? [], isFinished: true, - color: zoneData.color, - }), - ); + color: isBase ? dimColor(baseColor) : baseColor, + polygonSource: currentEditingProfile ? source : undefined, + }); + zoneIndex++; + } - let motionMasks: Polygon[] = []; - let globalObjectMasks: Polygon[] = []; - let objectMasks: Polygon[] = []; + // Merge motion masks + const mergedMotionMasks = new Map< + string, + { + data: CameraConfig["motion"]["mask"][string]; + source: "base" | "profile" | "override"; + } + >(); - // Motion masks are a dict with mask_id as key - motionMasks = Object.entries(cameraConfig.motion.mask || {}).map( - ([maskId, maskData], index) => ({ + for (const [maskId, maskData] of Object.entries( + cameraConfig.motion.mask || {}, + )) { + if (currentEditingProfile && profileMotionMaskNames.has(maskId)) { + mergedMotionMasks.set(maskId, { + data: profileData!.motion!.mask![maskId], + source: "override", + }); + } else { + mergedMotionMasks.set(maskId, { + data: maskData, + source: currentEditingProfile ? "base" : "base", + }); + } + } + + if (profileData?.motion?.mask) { + for (const [maskId, maskData] of Object.entries( + profileData.motion.mask, + )) { + if (!baseMotionMaskNames.has(maskId)) { + mergedMotionMasks.set(maskId, { + data: maskData, + source: "profile", + }); + } + } + } + + let motionMaskIndex = 0; + const motionMasks: Polygon[] = []; + for (const [maskId, { data: maskData, source }] of mergedMotionMasks) { + const isBase = source === "base" && !!currentEditingProfile; + const baseColor = [0, 0, 255]; + motionMasks.push({ type: "motion_mask" as PolygonType, - typeIndex: index, + typeIndex: motionMaskIndex, camera: cameraConfig.name, name: maskId, friendly_name: maskData.friendly_name, @@ -278,15 +449,61 @@ export default function MasksAndZonesView({ ), distances: [], isFinished: true, - color: [0, 0, 255], - }), - ); + color: isBase ? dimColor(baseColor) : baseColor, + polygonSource: currentEditingProfile ? source : undefined, + }); + motionMaskIndex++; + } - // Global object masks are a dict with mask_id as key - globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map( - ([maskId, maskData], index) => ({ + // Merge global object masks + const mergedGlobalObjectMasks = new Map< + string, + { + data: CameraConfig["objects"]["mask"][string]; + source: "base" | "profile" | "override"; + } + >(); + + for (const [maskId, maskData] of Object.entries( + cameraConfig.objects.mask || {}, + )) { + if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) { + mergedGlobalObjectMasks.set(maskId, { + data: profileData!.objects!.mask![maskId], + source: "override", + }); + } else { + mergedGlobalObjectMasks.set(maskId, { + data: maskData, + source: currentEditingProfile ? "base" : "base", + }); + } + } + + if (profileData?.objects?.mask) { + for (const [maskId, maskData] of Object.entries( + profileData.objects.mask, + )) { + if (!baseGlobalObjectMaskNames.has(maskId)) { + mergedGlobalObjectMasks.set(maskId, { + data: maskData, + source: "profile", + }); + } + } + } + + let objectMaskIndex = 0; + const globalObjectMasks: Polygon[] = []; + for (const [ + maskId, + { data: maskData, source }, + ] of mergedGlobalObjectMasks) { + const isBase = source === "base" && !!currentEditingProfile; + const baseColor = [128, 128, 128]; + globalObjectMasks.push({ type: "object_mask" as PolygonType, - typeIndex: index, + typeIndex: objectMaskIndex, camera: cameraConfig.name, name: maskId, friendly_name: maskData.friendly_name, @@ -302,13 +519,43 @@ export default function MasksAndZonesView({ ), distances: [], isFinished: true, - color: [128, 128, 128], - }), - ); + color: isBase ? dimColor(baseColor) : baseColor, + polygonSource: currentEditingProfile ? source : undefined, + }); + objectMaskIndex++; + } - let objectMaskIndex = globalObjectMasks.length; + objectMaskIndex = globalObjectMasks.length; - objectMasks = Object.entries(cameraConfig.objects.filters) + // Build per-object filter mask names for profile tracking + const baseFilterMaskNames = new Set(); + for (const [, filterConfig] of Object.entries( + cameraConfig.objects.filters, + )) { + for (const maskId of Object.keys(filterConfig.mask || {})) { + if (!maskId.startsWith("global_")) { + baseFilterMaskNames.add(maskId); + } + } + } + + const profileFilterMaskNames = new Set(); + if (profileData?.objects?.filters) { + for (const [, filterConfig] of Object.entries( + profileData.objects.filters, + )) { + if (filterConfig?.mask) { + for (const maskId of Object.keys(filterConfig.mask)) { + profileFilterMaskNames.add(maskId); + } + } + } + } + + // Per-object filter masks (base) + const objectMasks: Polygon[] = Object.entries( + cameraConfig.objects.filters, + ) .filter( ([, filterConfig]) => filterConfig.mask && Object.keys(filterConfig.mask).length > 0, @@ -316,22 +563,36 @@ export default function MasksAndZonesView({ .flatMap(([objectName, filterConfig]): Polygon[] => { return Object.entries(filterConfig.mask || {}).flatMap( ([maskId, maskData]) => { - // Skip if this mask is a global mask (prefixed with "global_") if (maskId.startsWith("global_")) { return []; } - const newMask = { + const source: "base" | "override" = currentEditingProfile + ? profileFilterMaskNames.has(maskId) + ? "override" + : "base" + : "base"; + const isBase = source === "base" && !!currentEditingProfile; + + // If override, use profile data + const finalData = + source === "override" && profileData?.objects?.filters + ? (profileData.objects.filters[objectName]?.mask?.[maskId] ?? + maskData) + : maskData; + + const baseColor = [128, 128, 128]; + const newMask: Polygon = { type: "object_mask" as PolygonType, typeIndex: objectMaskIndex, camera: cameraConfig.name, name: maskId, - friendly_name: maskData.friendly_name, - enabled: maskData.enabled, - enabled_in_config: maskData.enabled_in_config, + friendly_name: finalData.friendly_name, + enabled: finalData.enabled, + enabled_in_config: finalData.enabled_in_config, objects: [objectName], points: interpolatePoints( - parseCoordinates(maskData.coordinates), + parseCoordinates(finalData.coordinates), 1, 1, scaledWidth, @@ -339,7 +600,8 @@ export default function MasksAndZonesView({ ), distances: [], isFinished: true, - color: [128, 128, 128], + color: isBase ? dimColor(baseColor) : baseColor, + polygonSource: currentEditingProfile ? source : undefined, }; objectMaskIndex++; return [newMask]; @@ -347,6 +609,45 @@ export default function MasksAndZonesView({ ); }); + // Add profile-only per-object filter masks + if (profileData?.objects?.filters) { + for (const [objectName, filterConfig] of Object.entries( + profileData.objects.filters, + )) { + if (filterConfig?.mask) { + for (const [maskId, maskData] of Object.entries( + filterConfig.mask, + )) { + if (!baseFilterMaskNames.has(maskId) && maskData) { + const baseColor = [128, 128, 128]; + objectMasks.push({ + type: "object_mask" as PolygonType, + typeIndex: objectMaskIndex, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + enabled_in_config: maskData.enabled_in_config, + objects: [objectName], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: baseColor, + polygonSource: "profile", + }); + objectMaskIndex++; + } + } + } + } + } + setAllPolygons([ ...zones, ...motionMasks, @@ -386,7 +687,14 @@ export default function MasksAndZonesView({ } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cameraConfig, containerRef, scaledHeight, scaledWidth]); + }, [ + cameraConfig, + containerRef, + scaledHeight, + scaledWidth, + currentEditingProfile, + dimColor, + ]); useEffect(() => { if (editPane === undefined) { @@ -403,6 +711,15 @@ export default function MasksAndZonesView({ } }, [selectedCamera]); + // Cancel editing when profile selection changes + useEffect(() => { + if (editPaneRef.current !== undefined) { + handleCancel(); + } + // we only want to react to profile changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentEditingProfile]); + useSearchEffect("object_mask", (coordinates: string) => { if (!scaledWidth || !scaledHeight || isLoading) { return false; @@ -473,6 +790,7 @@ export default function MasksAndZonesView({ setActiveLine={setActiveLine} snapPoints={snapPoints} setSnapPoints={setSnapPoints} + editingProfile={currentEditingProfile} /> )} {editPane == "motion_mask" && ( @@ -488,6 +806,7 @@ export default function MasksAndZonesView({ onSave={handleSave} snapPoints={snapPoints} setSnapPoints={setSnapPoints} + editingProfile={currentEditingProfile} /> )} {editPane == "object_mask" && ( @@ -503,13 +822,34 @@ export default function MasksAndZonesView({ onSave={handleSave} snapPoints={snapPoints} setSnapPoints={setSnapPoints} + editingProfile={currentEditingProfile} /> )} {editPane === undefined && ( <> - - {t("menu.masksAndZones")} - +
+ {t("menu.masksAndZones")} + {profileState && selectedCamera && ( + + profileState.onSelectProfile( + selectedCamera, + "masksAndZones", + profile, + ) + } + onAddProfile={profileState.onAddProfile} + onDeleteProfileSection={(profileName) => + handleDeleteProfileMasksAndZones(profileName) + } + /> + )} +
{(selectedZoneMask === undefined || selectedZoneMask.includes("zone" as PolygonType)) && ( @@ -575,6 +915,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} loadingPolygonIndex={loadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex} + editingProfile={currentEditingProfile} + allProfileNames={profileState?.allProfileNames} /> ))}
@@ -649,6 +991,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} loadingPolygonIndex={loadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex} + editingProfile={currentEditingProfile} + allProfileNames={profileState?.allProfileNames} /> ))}
@@ -723,6 +1067,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} loadingPolygonIndex={loadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex} + editingProfile={currentEditingProfile} + allProfileNames={profileState?.allProfileNames} /> ))}
From cd5832979697d07fcacd75291214f0ec67dfa264 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:35:19 -0500 Subject: [PATCH 24/64] fix hidden field validation errors caused by lodash wildcard and schema gaps lodash unset does not support wildcard (*) segments, so hidden fields like filters.*.mask were never stripped from form data, leaving null raw_coordinates that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip hidden fields from the JSON schema itself as defense-in-depth. --- web/src/lib/config-schema/transformer.ts | 71 ++++++++++++++++++++++++ web/src/utils/configUtil.ts | 30 +++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index cafdf4e0b..34dd7e45d 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -538,6 +538,72 @@ function generateUiSchema( return uiSchema; } +/** + * Removes hidden field properties from the JSON schema itself so RJSF won't + * validate them. The existing ui:widget=hidden approach only hides rendering + * but still validates — fields with server-only values (e.g. raw_coordinates + * serialized as null) cause spurious validation errors. + * + * Supports dotted paths ("mask"), nested paths ("genai.enabled_in_config"), + * and wildcard segments ("filters.*.mask") where `*` matches + * additionalProperties. + */ +function stripHiddenFieldsFromSchema( + schema: RJSFSchema, + hiddenFields: string[], +): void { + for (const pattern of hiddenFields) { + if (!pattern) continue; + const segments = pattern.split("."); + removePropertyBySegments(schema, segments); + } +} + +function removePropertyBySegments( + schema: RJSFSchema, + segments: string[], +): void { + if (segments.length === 0 || !isSchemaObject(schema)) return; + + const [head, ...rest] = segments; + const props = schema.properties as + | Record + | undefined; + + if (rest.length === 0) { + // Terminal segment — delete the property + if (head === "*") { + // Wildcard at leaf: strip from additionalProperties + if (isSchemaObject(schema.additionalProperties)) { + // Nothing to delete — "*" as the last segment means "every dynamic key". + // The parent's additionalProperties schema IS the dynamic value, not a + // container. In practice hidden-field patterns always have a named leaf + // after the wildcard (e.g. "filters.*.mask"), so this branch is a no-op. + } + } else if (props && head in props) { + delete props[head]; + if (Array.isArray(schema.required)) { + schema.required = (schema.required as string[]).filter( + (r) => r !== head, + ); + } + } + return; + } + + if (head === "*") { + // Wildcard segment — descend into additionalProperties + if (isSchemaObject(schema.additionalProperties)) { + removePropertyBySegments( + schema.additionalProperties as RJSFSchema, + rest, + ); + } + } else if (props && head in props && isSchemaObject(props[head])) { + removePropertyBySegments(props[head], rest); + } +} + /** * Transforms a Pydantic JSON Schema to RJSF format * Resolves references and generates appropriate uiSchema @@ -550,6 +616,11 @@ export function transformSchema( const cleanSchema = resolveAndCleanSchema(rawSchema); const normalizedSchema = normalizeNullableSchema(cleanSchema); + // Remove hidden fields from schema so RJSF won't validate them + if (options.hiddenFields && options.hiddenFields.length > 0) { + stripHiddenFieldsFromSchema(normalizedSchema, options.hiddenFields); + } + // Generate uiSchema const uiSchema = generateUiSchema(normalizedSchema, options); diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 5320fca69..0932733e1 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -77,6 +77,8 @@ export const PROFILE_ELIGIBLE_SECTIONS = new Set([ "audio", "birdseye", "detect", + "face_recognition", + "lpr", "motion", "notifications", "objects", @@ -204,6 +206,32 @@ export function buildOverrides( // Normalize raw config data (strip internal fields) and remove any paths // listed in `hiddenFields` so they are not included in override computation. +// lodash `unset` treats `*` as a literal key. This helper expands wildcard +// segments so that e.g. `"filters.*.mask"` unsets `filters..mask`. +function unsetWithWildcard( + obj: Record, + path: string, +): void { + if (!path.includes("*")) { + unset(obj, path); + return; + } + const segments = path.split("."); + const starIndex = segments.indexOf("*"); + const prefix = segments.slice(0, starIndex).join("."); + const suffix = segments.slice(starIndex + 1).join("."); + const parent = prefix ? get(obj, prefix) : obj; + if (parent && typeof parent === "object") { + for (const key of Object.keys(parent as Record)) { + const fullPath = suffix ? `${key}.${suffix}` : key; + unsetWithWildcard( + parent as Record, + fullPath, + ); + } + } +} + export function sanitizeSectionData( data: ConfigSectionData, hiddenFields?: string[], @@ -215,7 +243,7 @@ export function sanitizeSectionData( const cleaned = cloneDeep(normalized) as ConfigSectionData; hiddenFields.forEach((path) => { if (!path) return; - unset(cleaned, path); + unsetWithWildcard(cleaned as Record, path); }); return cleaned; } From eccad7aa2115124760a272032a2862a8abbdb1ee Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:35:38 -0500 Subject: [PATCH 25/64] add face_recognition and lpr to profile-eligible sections --- frigate/config/camera/profile.py | 6 ++++++ frigate/config/camera/updater.py | 6 ++++++ frigate/config/profile_manager.py | 2 ++ web/src/types/frigateConfig.ts | 2 ++ 4 files changed, 16 insertions(+) diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py index f9510343d..6a52a9ad6 100644 --- a/frigate/config/camera/profile.py +++ b/frigate/config/camera/profile.py @@ -3,6 +3,10 @@ from typing import Optional from ..base import FrigateBaseModel +from ..classification import ( + CameraFaceRecognitionConfig, + CameraLicensePlateRecognitionConfig, +) from .audio import AudioConfig from .birdseye import BirdseyeCameraConfig from .detect import DetectConfig @@ -29,6 +33,8 @@ class CameraProfileConfig(FrigateBaseModel): audio: Optional[AudioConfig] = None birdseye: Optional[BirdseyeCameraConfig] = None detect: Optional[DetectConfig] = None + face_recognition: Optional[CameraFaceRecognitionConfig] = None + lpr: Optional[CameraLicensePlateRecognitionConfig] = None motion: Optional[MotionConfig] = None notifications: Optional[NotificationConfig] = None objects: Optional[ObjectConfig] = None diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 0c49ec465..261631212 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -27,6 +27,8 @@ class CameraConfigUpdateEnum(str, Enum): review = "review" review_genai = "review_genai" semantic_search = "semantic_search" # for semantic search triggers + face_recognition = "face_recognition" + lpr = "lpr" snapshots = "snapshots" zones = "zones" @@ -119,6 +121,10 @@ class CameraConfigUpdateSubscriber: config.review.genai = updated_config elif update_type == CameraConfigUpdateEnum.semantic_search: config.semantic_search = updated_config + elif update_type == CameraConfigUpdateEnum.face_recognition: + config.face_recognition = updated_config + elif update_type == CameraConfigUpdateEnum.lpr: + config.lpr = updated_config elif update_type == CameraConfigUpdateEnum.snapshots: config.snapshots = updated_config elif update_type == CameraConfigUpdateEnum.zones: diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index f05830693..1c390e526 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -21,6 +21,8 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "audio": CameraConfigUpdateEnum.audio, "birdseye": CameraConfigUpdateEnum.birdseye, "detect": CameraConfigUpdateEnum.detect, + "face_recognition": CameraConfigUpdateEnum.face_recognition, + "lpr": CameraConfigUpdateEnum.lpr, "motion": CameraConfigUpdateEnum.motion, "notifications": CameraConfigUpdateEnum.notifications, "objects": CameraConfigUpdateEnum.objects, diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index ffb86217e..9b5178694 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -313,6 +313,8 @@ export type CameraProfileConfig = { audio?: Partial; birdseye?: Partial; detect?: Partial; + face_recognition?: Partial; + lpr?: Partial; motion?: Partial; notifications?: Partial; objects?: Partial; From 096a13bce9edd5a84168c3baa737dba3560120bb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:01:51 -0500 Subject: [PATCH 26/64] move profile dropdown from section panes to settings header --- web/src/pages/Settings.tsx | 149 +++++++++++++++++++ web/src/types/frigateConfig.ts | 10 ++ web/src/views/settings/MasksAndZonesView.tsx | 77 ---------- web/src/views/settings/SingleSectionPage.tsx | 46 +----- 4 files changed, 160 insertions(+), 122 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 9bbb165c6..701009da1 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -90,9 +90,11 @@ import { buildConfigDataForPath, parseProfileFromSectionPath, prepareSectionSavePayload, + PROFILE_ELIGIBLE_SECTIONS, } from "@/utils/configUtil"; import type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; +import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; import { Badge } from "@/components/ui/badge"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; @@ -512,6 +514,24 @@ const CAMERA_SECTION_MAPPING: Record = { timestamp_style: "cameraTimestampStyle", }; +// Reverse mapping: page key → config section key +const REVERSE_CAMERA_SECTION_MAPPING: Record = Object.fromEntries( + Object.entries(CAMERA_SECTION_MAPPING).map(([section, page]) => [page, section]), +); +// masksAndZones is a composite page, not in CAMERA_SECTION_MAPPING +REVERSE_CAMERA_SECTION_MAPPING["masksAndZones"] = "masksAndZones"; + +// Pages where the profile dropdown should appear +const PROFILE_DROPDOWN_PAGES = new Set( + Object.entries(REVERSE_CAMERA_SECTION_MAPPING) + .filter( + ([, sectionKey]) => + PROFILE_ELIGIBLE_SECTIONS.has(sectionKey) || + sectionKey === "masksAndZones", + ) + .map(([pageKey]) => pageKey), +); + // keys for global sections const GLOBAL_SECTION_MAPPING: Record = { detect: "globalDetect", @@ -1092,6 +1112,97 @@ export default function Settings() { ], ); + // Header profile dropdown: derive section key from current page + const currentSectionKey = useMemo( + () => REVERSE_CAMERA_SECTION_MAPPING[pageToggle] ?? null, + [pageToggle], + ); + + const headerEditingProfile = useMemo(() => { + if (!selectedCamera || !currentSectionKey) return null; + const key = `${selectedCamera}::${currentSectionKey}`; + return editingProfile[key] ?? null; + }, [selectedCamera, currentSectionKey, editingProfile]); + + const showProfileDropdown = + PROFILE_DROPDOWN_PAGES.has(pageToggle) && + !!selectedCamera && + allProfileNames.length > 0; + + const headerHasProfileData = useCallback( + (profileName: string): boolean => { + if (!config || !selectedCamera || !currentSectionKey) return false; + const profileData = + config.cameras[selectedCamera]?.profiles?.[profileName]; + if (!profileData) return false; + + if (currentSectionKey === "masksAndZones") { + const hasZones = + profileData.zones && Object.keys(profileData.zones).length > 0; + const hasMotionMasks = + profileData.motion?.mask && + Object.keys(profileData.motion.mask).length > 0; + const hasObjectMasks = + (profileData.objects?.mask && + Object.keys(profileData.objects.mask).length > 0) || + (profileData.objects?.filters && + Object.values(profileData.objects.filters).some( + (f) => f.mask && Object.keys(f.mask).length > 0, + )); + return !!(hasZones || hasMotionMasks || hasObjectMasks); + } + + return !!profileData[ + currentSectionKey as keyof typeof profileData + ]; + }, + [config, selectedCamera, currentSectionKey], + ); + + const handleDeleteProfileForCurrentSection = useCallback( + async (profileName: string) => { + if (!selectedCamera || !currentSectionKey) return; + + if (currentSectionKey === "masksAndZones") { + try { + await axios.put("config/set", { + config_data: { + cameras: { + [selectedCamera]: { + profiles: { + [profileName]: { + zones: "", + motion: { mask: "" }, + objects: { mask: "", filters: "" }, + }, + }, + }, + }, + }, + }); + await mutate("config"); + handleSelectProfile(selectedCamera, "masksAndZones", null); + toast.success(t("toast.save.success", { ns: "common" })); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" })); + } + } else { + await handleDeleteProfileSection( + selectedCamera, + currentSectionKey, + profileName, + ); + } + }, + [ + selectedCamera, + currentSectionKey, + handleSelectProfile, + handleDeleteProfileSection, + t, + ], + ); + const handleSectionStatusChange = useCallback( (sectionKey: string, level: "global" | "camera", status: SectionStatus) => { // Map section keys to menu keys based on level @@ -1368,6 +1479,26 @@ export default function Settings() { updateZoneMaskFilter={setFilterZoneMask} /> )} + {showProfileDropdown && currentSectionKey && ( + + handleSelectProfile( + selectedCamera, + currentSectionKey, + profile, + ) + } + onAddProfile={handleAddProfile} + onDeleteProfileSection={ + handleDeleteProfileForCurrentSection + } + /> + )} )} + {showProfileDropdown && currentSectionKey && ( + + handleSelectProfile( + selectedCamera, + currentSectionKey, + profile, + ) + } + onAddProfile={handleAddProfile} + onDeleteProfileSection={handleDeleteProfileForCurrentSection} + /> + )} ("config"); - const { mutate } = useSWRConfig(); const [allPolygons, setAllPolygons] = useState([]); const [editingPolygons, setEditingPolygons] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -82,58 +77,6 @@ export default function MasksAndZonesView({ const currentEditingProfile = profileState?.editingProfile[profileSectionKey] ?? null; - const hasProfileData = useCallback( - (profileName: string) => { - if (!config || !selectedCamera) return false; - const profileData = - config.cameras[selectedCamera]?.profiles?.[profileName]; - if (!profileData) return false; - const hasZones = - profileData.zones && Object.keys(profileData.zones).length > 0; - const hasMotionMasks = - profileData.motion?.mask && - Object.keys(profileData.motion.mask).length > 0; - const hasObjectMasks = - (profileData.objects?.mask && - Object.keys(profileData.objects.mask).length > 0) || - (profileData.objects?.filters && - Object.values(profileData.objects.filters).some( - (f) => f.mask && Object.keys(f.mask).length > 0, - )); - return !!(hasZones || hasMotionMasks || hasObjectMasks); - }, - [config, selectedCamera], - ); - - const handleDeleteProfileMasksAndZones = useCallback( - async (profileName: string) => { - try { - // Delete zones, motion masks, and object masks from the profile - await axios.put("config/set", { - config_data: { - cameras: { - [selectedCamera]: { - profiles: { - [profileName]: { - zones: "", - motion: { mask: "" }, - objects: { mask: "", filters: "" }, - }, - }, - }, - }, - }, - }); - await mutate("config"); - profileState?.onSelectProfile(selectedCamera, "masksAndZones", null); - toast.success(t("toast.save.success", { ns: "common" })); - } catch { - toast.error(t("toast.save.error.noMessage", { ns: "common" })); - } - }, - [selectedCamera, mutate, profileState, t], - ); - const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -829,26 +772,6 @@ export default function MasksAndZonesView({ <>
{t("menu.masksAndZones")} - {profileState && selectedCamera && ( - - profileState.onSelectProfile( - selectedCamera, - "masksAndZones", - profile, - ) - } - onAddProfile={profileState.onAddProfile} - onDeleteProfileSection={(profileName) => - handleDeleteProfileMasksAndZones(profileName) - } - /> - )}
{(selectedZoneMask === undefined || diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index 5dd0feb14..b72b97a3e 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -1,22 +1,16 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import useSWR from "swr"; import type { SectionConfig } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; -import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; import type { ConfigSectionData } from "@/types/configForm"; import type { ProfileState } from "@/types/profile"; -import { - getSectionConfig, - PROFILE_ELIGIBLE_SECTIONS, -} from "@/utils/configUtil"; +import { getSectionConfig } from "@/utils/configUtil"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import Heading from "@/components/ui/heading"; -import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; export type SettingsPageProps = { selectedCamera?: string; @@ -74,7 +68,6 @@ export function SingleSectionPage({ "common", ]); const { getLocaleDocUrl } = useDocDomain(); - const { data: config } = useSWR("config"); const [sectionStatus, setSectionStatus] = useState({ hasChanges: false, isOverridden: false, @@ -88,13 +81,6 @@ export function SingleSectionPage({ ? getLocaleDocUrl(resolvedSectionConfig.sectionDocs) : undefined; - // Profile support: determine if this section supports profiles - const isProfileEligible = - level === "camera" && - selectedCamera && - profileState && - PROFILE_ELIGIBLE_SECTIONS.has(sectionKey); - const profileKey = selectedCamera ? `${selectedCamera}::${sectionKey}` : undefined; @@ -148,36 +134,6 @@ export function SingleSectionPage({
- {isProfileEligible && selectedCamera && profileState && ( - { - const profileData = - config?.cameras?.[selectedCamera]?.profiles?.[profile]; - return !!profileData?.[ - sectionKey as keyof typeof profileData - ]; - }} - onSelectProfile={(profile) => - profileState.onSelectProfile( - selectedCamera, - sectionKey, - profile, - ) - } - onAddProfile={profileState.onAddProfile} - onDeleteProfileSection={(profile) => - profileState.onDeleteProfileSection( - selectedCamera, - sectionKey, - profile, - ) - } - /> - )} {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && ( From a0849b104c4dca50559d833d4fce2ab798b058a8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:05:18 -0500 Subject: [PATCH 27/64] add profiles enable toggle and improve empty state --- web/public/locales/en/views/settings.json | 5 +- web/src/pages/Settings.tsx | 5 +- web/src/views/settings/ProfilesView.tsx | 137 ++++++++++++------- web/src/views/settings/SingleSectionPage.tsx | 2 + 4 files changed, 96 insertions(+), 53 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 92eb3b5ba..7f2ce01e0 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1461,7 +1461,10 @@ "deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.", "deleteSuccess": "Profile '{{profile}}' deleted", "deleteSection": "Delete Section Overrides", - "deleteSectionConfirm": "Remove {{profile}}'s overrides for {{section}} on {{camera}}?" + "deleteSectionConfirm": "Remove {{profile}}'s overrides for {{section}} on {{camera}}?", + "enableSwitch": "Enable Profiles", + "enabledDescription": "Profiles are enabled. Navigate to a camera config section, create a new profile from the dropdown in the header, and save for changes to take effect.", + "disabledDescription": "Profiles allow you to define named sets of camera config overrides (e.g., armed, away, night) that can be activated on demand. Enable profiles to get started." }, "unsavedChanges": "You have unsaved changes", "confirmReset": "Confirm Reset", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 701009da1..e70a0b691 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -659,6 +659,7 @@ export default function Settings() { Record >({}); const [newProfiles, setNewProfiles] = useState([]); + const [profilesUIEnabled, setProfilesUIEnabled] = useState(false); const allProfileNames = useMemo(() => { if (!config) return []; @@ -1127,7 +1128,7 @@ export default function Settings() { const showProfileDropdown = PROFILE_DROPDOWN_PAGES.has(pageToggle) && !!selectedCamera && - allProfileNames.length > 0; + (allProfileNames.length > 0 || profilesUIEnabled); const headerHasProfileData = useCallback( (profileName: string): boolean => { @@ -1527,6 +1528,8 @@ export default function Settings() { pendingDataBySection={pendingDataBySection} onPendingDataChange={handlePendingDataChange} profileState={profileState} + profilesUIEnabled={profilesUIEnabled} + setProfilesUIEnabled={setProfilesUIEnabled} /> ); })()} diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx index 60f8e1694..07883ab38 100644 --- a/web/src/views/settings/ProfilesView.tsx +++ b/web/src/views/settings/ProfilesView.tsx @@ -29,6 +29,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; type ProfilesApiResponse = { profiles: string[]; @@ -38,9 +40,15 @@ type ProfilesApiResponse = { type ProfilesViewProps = { setUnsavedChanges?: React.Dispatch>; profileState?: ProfileState; + profilesUIEnabled?: boolean; + setProfilesUIEnabled?: React.Dispatch>; }; -export default function ProfilesView({ profileState }: ProfilesViewProps) { +export default function ProfilesView({ + profileState, + profilesUIEnabled, + setProfilesUIEnabled, +}: ProfilesViewProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config, mutate: updateConfig } = useSWR("config"); @@ -182,68 +190,95 @@ export default function ProfilesView({ profileState }: ProfilesViewProps) { return null; } + const hasProfiles = allProfileNames.length > 0; + return (
{t("profiles.title", { ns: "views/settings" })} - {/* Active Profile Section */} -
-
- {t("profiles.activeProfile", { ns: "views/settings" })} + {/* Enable Profiles Toggle — shown only when no profiles exist */} + {!hasProfiles && setProfilesUIEnabled && ( +
+
+ + +
+

+ {profilesUIEnabled + ? t("profiles.enabledDescription", { ns: "views/settings" }) + : t("profiles.disabledDescription", { ns: "views/settings" })} +

-
- - {activeProfile && ( - +
+ {t("profiles.activeProfile", { ns: "views/settings" })} +
+
+ + {activeProfile && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
-
+ )} {/* Profile Cards */} - {allProfileNames.length === 0 ? ( + {!hasProfiles ? (
-

{t("profiles.noProfiles", { ns: "views/settings" })}

+ {!profilesUIEnabled && ( +

{t("profiles.noProfiles", { ns: "views/settings" })}

+ )}
) : (
diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index b72b97a3e..82c1f3a29 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -28,6 +28,8 @@ export type SettingsPageProps = { data: ConfigSectionData | null, ) => void; profileState?: ProfileState; + profilesUIEnabled?: boolean; + setProfilesUIEnabled?: React.Dispatch>; }; export type SectionStatus = { From 98e9e798813aa5cbf7dff05c14df981f58fd5669 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:07:23 -0500 Subject: [PATCH 28/64] formatting --- web/src/lib/config-schema/transformer.ts | 9 ++------- web/src/pages/Settings.tsx | 14 ++++++++------ web/src/utils/configUtil.ts | 10 ++-------- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index 34dd7e45d..b7c0e8c35 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -566,9 +566,7 @@ function removePropertyBySegments( if (segments.length === 0 || !isSchemaObject(schema)) return; const [head, ...rest] = segments; - const props = schema.properties as - | Record - | undefined; + const props = schema.properties as Record | undefined; if (rest.length === 0) { // Terminal segment — delete the property @@ -594,10 +592,7 @@ function removePropertyBySegments( if (head === "*") { // Wildcard segment — descend into additionalProperties if (isSchemaObject(schema.additionalProperties)) { - removePropertyBySegments( - schema.additionalProperties as RJSFSchema, - rest, - ); + removePropertyBySegments(schema.additionalProperties as RJSFSchema, rest); } } else if (props && head in props && isSchemaObject(props[head])) { removePropertyBySegments(props[head], rest); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index e70a0b691..d1cf9a21c 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -515,9 +515,13 @@ const CAMERA_SECTION_MAPPING: Record = { }; // Reverse mapping: page key → config section key -const REVERSE_CAMERA_SECTION_MAPPING: Record = Object.fromEntries( - Object.entries(CAMERA_SECTION_MAPPING).map(([section, page]) => [page, section]), -); +const REVERSE_CAMERA_SECTION_MAPPING: Record = + Object.fromEntries( + Object.entries(CAMERA_SECTION_MAPPING).map(([section, page]) => [ + page, + section, + ]), + ); // masksAndZones is a composite page, not in CAMERA_SECTION_MAPPING REVERSE_CAMERA_SECTION_MAPPING["masksAndZones"] = "masksAndZones"; @@ -1153,9 +1157,7 @@ export default function Settings() { return !!(hasZones || hasMotionMasks || hasObjectMasks); } - return !!profileData[ - currentSectionKey as keyof typeof profileData - ]; + return !!profileData[currentSectionKey as keyof typeof profileData]; }, [config, selectedCamera, currentSectionKey], ); diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 0932733e1..980095696 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -208,10 +208,7 @@ export function buildOverrides( // lodash `unset` treats `*` as a literal key. This helper expands wildcard // segments so that e.g. `"filters.*.mask"` unsets `filters..mask`. -function unsetWithWildcard( - obj: Record, - path: string, -): void { +function unsetWithWildcard(obj: Record, path: string): void { if (!path.includes("*")) { unset(obj, path); return; @@ -224,10 +221,7 @@ function unsetWithWildcard( if (parent && typeof parent === "object") { for (const key of Object.keys(parent as Record)) { const fullPath = suffix ? `${key}.${suffix}` : key; - unsetWithWildcard( - parent as Record, - fullPath, - ); + unsetWithWildcard(parent as Record, fullPath); } } } From 18d413fbee0c87d7472026ec1a2a99bf398b434c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:35:12 -0500 Subject: [PATCH 29/64] tweaks --- web/src/pages/Settings.tsx | 31 +++++++++++++++ web/src/types/profile.ts | 1 + web/src/views/settings/ProfilesView.tsx | 53 +++++++++++++++++-------- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index d1cf9a21c..54a9de362 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1067,6 +1067,33 @@ export default function Settings() { setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name])); }, []); + const handleRemoveNewProfile = useCallback((name: string) => { + setNewProfiles((prev) => prev.filter((p) => p !== name)); + // Clear any editing state for this profile + setEditingProfile((prev) => { + const updated = { ...prev }; + for (const key of Object.keys(updated)) { + if (updated[key] === name) { + delete updated[key]; + } + } + return updated; + }); + // Clear any pending data for this profile + setPendingDataBySection((prev) => { + const profileSegment = `profiles.${name}.`; + const updated = { ...prev }; + let changed = false; + for (const key of Object.keys(updated)) { + if (key.includes(profileSegment)) { + delete updated[key]; + changed = true; + } + } + return changed ? updated : prev; + }); + }, []); + const handleDeleteProfileSection = useCallback( async (camera: string, section: string, profile: string) => { try { @@ -1105,6 +1132,7 @@ export default function Settings() { allProfileNames, onSelectProfile: handleSelectProfile, onAddProfile: handleAddProfile, + onRemoveNewProfile: handleRemoveNewProfile, onDeleteProfileSection: handleDeleteProfileSection, }), [ @@ -1113,6 +1141,7 @@ export default function Settings() { allProfileNames, handleSelectProfile, handleAddProfile, + handleRemoveNewProfile, handleDeleteProfileSection, ], ); @@ -1780,6 +1809,8 @@ export default function Settings() { pendingDataBySection={pendingDataBySection} onPendingDataChange={handlePendingDataChange} profileState={profileState} + profilesUIEnabled={profilesUIEnabled} + setProfilesUIEnabled={setProfilesUIEnabled} /> ); })()} diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts index 2c96e51da..3a6441a26 100644 --- a/web/src/types/profile.ts +++ b/web/src/types/profile.ts @@ -15,6 +15,7 @@ export type ProfileState = { profile: string | null, ) => void; onAddProfile: (name: string) => void; + onRemoveNewProfile: (name: string) => void; onDeleteProfileSection: ( camera: string, section: string, diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx index 07883ab38..cc63619d2 100644 --- a/web/src/views/settings/ProfilesView.tsx +++ b/web/src/views/settings/ProfilesView.tsx @@ -142,6 +142,15 @@ export default function ProfilesView({ const handleDeleteProfile = useCallback(async () => { if (!deleteProfile || !config) return; + + // If this is an unsaved (new) profile, just remove it from local state + const isNewProfile = profileState?.newProfiles.includes(deleteProfile); + if (isNewProfile) { + profileState?.onRemoveNewProfile(deleteProfile); + setDeleteProfile(null); + return; + } + setDeleting(true); try { @@ -184,7 +193,15 @@ export default function ProfilesView({ setDeleting(false); setDeleteProfile(null); } - }, [deleteProfile, activeProfile, config, updateConfig, updateProfiles, t]); + }, [ + deleteProfile, + activeProfile, + config, + profileState, + updateConfig, + updateProfiles, + t, + ]); if (!config || !profilesData) { return null; @@ -193,14 +210,15 @@ export default function ProfilesView({ const hasProfiles = allProfileNames.length > 0; return ( -
- - {t("profiles.title", { ns: "views/settings" })} - +
+ {t("profiles.title", { ns: "views/settings" })} +
+ {t("profiles.disabledDescription", { ns: "views/settings" })} +
{/* Enable Profiles Toggle — shown only when no profiles exist */} {!hasProfiles && setProfilesUIEnabled && ( -
+
-

- {profilesUIEnabled - ? t("profiles.enabledDescription", { ns: "views/settings" }) - : t("profiles.disabledDescription", { ns: "views/settings" })} -

)} + {profilesUIEnabled && ( +

+ {t("profiles.enabledDescription", { ns: "views/settings" })} +

+ )} + {/* Active Profile Section — only when profiles exist */} {hasProfiles && (
@@ -275,11 +294,13 @@ export default function ProfilesView({ {/* Profile Cards */} {!hasProfiles ? ( -
- {!profilesUIEnabled && ( -

{t("profiles.noProfiles", { ns: "views/settings" })}

- )} -
+ profilesUIEnabled ? ( +

+ {t("profiles.noProfiles", { ns: "views/settings" })} +

+ ) : ( +
+ ) ) : (
{allProfileNames.map((profile) => { From 7925d120aed6189c7274d05e3c398587a15b105d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:36:15 -0500 Subject: [PATCH 30/64] tweak colors and switch --- web/public/locales/en/views/settings.json | 2 +- .../settings/ProfileSectionDropdown.tsx | 6 +---- web/src/utils/profileColors.ts | 24 +++++++++---------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7f2ce01e0..975bbddc5 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1464,7 +1464,7 @@ "deleteSectionConfirm": "Remove {{profile}}'s overrides for {{section}} on {{camera}}?", "enableSwitch": "Enable Profiles", "enabledDescription": "Profiles are enabled. Navigate to a camera config section, create a new profile from the dropdown in the header, and save for changes to take effect.", - "disabledDescription": "Profiles allow you to define named sets of camera config overrides (e.g., armed, away, night) that can be activated on demand. Enable profiles to get started." + "disabledDescription": "Profiles allow you to define named sets of camera config overrides (e.g., armed, away, night) that can be activated on demand." }, "unsavedChanges": "You have unsaved changes", "confirmReset": "Confirm Reset", diff --git a/web/src/components/settings/ProfileSectionDropdown.tsx b/web/src/components/settings/ProfileSectionDropdown.tsx index 10ad0d437..e3f81bb79 100644 --- a/web/src/components/settings/ProfileSectionDropdown.tsx +++ b/web/src/components/settings/ProfileSectionDropdown.tsx @@ -114,11 +114,7 @@ export function ProfileSectionDropdown({ <> -
)} - {/* Profile Cards */} + {/* Profile List */} {!hasProfiles ? ( profilesUIEnabled ? (

@@ -314,104 +367,171 @@ export default function ProfilesView({

) ) : ( -
+
{allProfileNames.map((profile) => { const color = getProfileColor(profile, allProfileNames); const isActive = activeProfile === profile; const cameraData = profileOverviewData[profile] ?? {}; const cameras = Object.keys(cameraData).sort(); + const isExpanded = expandedProfiles.has(profile); return ( -
toggleExpanded(profile)} > -
-
- - {profile} - {isActive && ( - - {t("profiles.active", { ns: "views/settings" })} - - )} -
- -
- - {cameras.length === 0 ? ( -

- {t("profiles.noOverrides", { ns: "views/settings" })} -

- ) : ( -
- {cameras.map((camera) => { - const sections = cameraData[camera]; - return ( -
+ +
+
+ {isExpanded ? ( + + ) : ( + + )} + + {profile} + {isActive && ( + + {t("profiles.active", { ns: "views/settings" })} + + )} +
+
+ + {cameras.length > 0 + ? t("profiles.cameraCount", { + ns: "views/settings", + count: cameras.length, + }) + : t("profiles.noOverrides", { + ns: "views/settings", + })} + + +
+
+
+ + {cameras.length > 0 ? ( +
+ {cameras.map((camera) => { + const sections = cameraData[camera]; + return ( +
+ + {resolveCameraName(config, camera)} + + + {sections + .map((section) => + t(`configForm.sections.${section}`, { + ns: "views/settings", + defaultValue: section, + }), + ) + .join(", ")} +
-
- {sections.map((section) => ( - - {t(`configForm.sections.${section}`, { - ns: "views/settings", - defaultValue: section, - })} - - ))} -
-
-
- ); - })} -
- )} -
+ ); + })} +
+ ) : ( +
+ {t("profiles.noOverrides", { ns: "views/settings" })} +
+ )} + +
+ ); })}
)} + {/* Add Profile Dialog */} + { + setAddDialogOpen(open); + if (!open) { + setNewProfileName(""); + setNameError(null); + } + }} + > + + + + {t("profiles.newProfile", { ns: "views/settings" })} + + +
+ { + setNewProfileName(e.target.value); + setNameError(validateName(e.target.value)); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddSubmit(); + } + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ {/* Delete Profile Confirmation */} + currentEditingProfile && profileState?.allProfileNames + ? getProfileColor(currentEditingProfile, profileState.allProfileNames) + : undefined, + [currentEditingProfile, profileState?.allProfileNames], + ); + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -136,15 +153,40 @@ export function SingleSectionPage({ {level === "camera" && showOverrideIndicator && sectionStatus.isOverridden && ( - - {t("button.overridden", { - ns: "common", - defaultValue: "Overridden", - })} - + + + + {sectionStatus.overrideSource === "profile" + ? t("button.overriddenBaseConfig", { + ns: "views/settings", + defaultValue: "Overridden (Base Config)", + }) + : t("button.overriddenGlobal", { + ns: "views/settings", + defaultValue: "Overridden (Global)", + })} + + + + {sectionStatus.overrideSource === "profile" + ? t("button.overriddenBaseConfigTooltip", { + ns: "common", + profile: currentEditingProfile, + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {sectionStatus.hasChanges && (
); From 5c235558825cd515a66f2148d9bf3a7a1d664153 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:57:27 -0500 Subject: [PATCH 36/64] implement an update_config method for profile manager --- frigate/api/app.py | 2 +- frigate/config/profile_manager.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index da2097f4a..71b9dbc74 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -629,7 +629,7 @@ def config_set(request: Request, body: AppConfigSetBody): request.app.genai_manager.update_config(config) if request.app.profile_manager is not None: - request.app.profile_manager.config = config + request.app.profile_manager.update_config(config) if request.app.stats_emitter is not None: request.app.stats_emitter.config = config diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 1c390e526..e5b2c8703 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -62,6 +62,38 @@ class ProfileManager: if section_config is not None: self._base_configs[cam_name][section] = section_config.model_dump() + def update_config(self, new_config) -> None: + """Update config reference after config/set replaces the in-memory config. + + Preserves active profile state: re-snapshots base configs from the new + (freshly parsed) config, then re-applies profile overrides if a profile + was active. + """ + current_active = self.config.active_profile + self.config = new_config + + # Re-snapshot base configs from the new config (which has base values) + self._base_configs.clear() + self._base_enabled.clear() + self._base_zones.clear() + self._snapshot_base_configs() + + # 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: + changed: dict[str, set[str]] = {} + self._apply_profile_overrides(current_active, changed) + self.config.active_profile = current_active + else: + # Profile was deleted — deactivate + self.config.active_profile = None + self._persist_active_profile(None) + def activate_profile(self, profile_name: Optional[str]) -> Optional[str]: """Activate a profile by name, or deactivate if None. From 210d203fa4ffea767cc3ac09f919ad95e4adb2cc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:04:17 -0500 Subject: [PATCH 37/64] fix mask deletion --- web/src/pages/Settings.tsx | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 5f273e93c..f0f1d66e2 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1205,16 +1205,39 @@ export default function Settings() { config?.cameras?.[selectedCamera]?.profiles?.[profileName]; if (!profileData) return; - // Only delete top-level keys that exist in the profile - const deletePayload: Record = {}; + // Build a targeted delete payload that only removes mask-related + // sub-keys, not the entire motion/objects sections + const deletePayload: Record = {}; + if (profileData.zones !== undefined) { deletePayload.zones = ""; } - if (profileData.motion !== undefined) { - deletePayload.motion = ""; + + if (profileData.motion?.mask !== undefined) { + deletePayload.motion = { mask: "" }; } - if (profileData.objects !== undefined) { - deletePayload.objects = ""; + + if (profileData.objects) { + const objDelete: Record = {}; + if (profileData.objects.mask !== undefined) { + objDelete.mask = ""; + } + if (profileData.objects.filters) { + const filtersDelete: Record = {}; + for (const [filterName, filterVal] of Object.entries( + profileData.objects.filters, + )) { + if (filterVal.mask !== undefined) { + filtersDelete[filterName] = { mask: "" }; + } + } + if (Object.keys(filtersDelete).length > 0) { + objDelete.filters = filtersDelete; + } + } + if (Object.keys(objDelete).length > 0) { + deletePayload.objects = objDelete; + } } if (Object.keys(deletePayload).length === 0) return; From dace54734bb559b62853cb2867aaaa7be6aaa417 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:21:41 -0500 Subject: [PATCH 38/64] more unique colors --- web/src/utils/profileColors.ts | 136 +++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/web/src/utils/profileColors.ts b/web/src/utils/profileColors.ts index 26a3ac609..554b52b01 100644 --- a/web/src/utils/profileColors.ts +++ b/web/src/utils/profileColors.ts @@ -2,60 +2,116 @@ import type { ProfileColor } from "@/types/profile"; const PROFILE_COLORS: ProfileColor[] = [ { - bg: "bg-amber-500", - text: "text-amber-500", - dot: "bg-amber-500", - border: "border-amber-500", - bgMuted: "bg-amber-500/20", + bg: "bg-violet-500", + text: "text-violet-500", + dot: "bg-violet-500", + border: "border-violet-500", + bgMuted: "bg-violet-500/20", }, { - bg: "bg-purple-500", - text: "text-purple-500", - dot: "bg-purple-500", - border: "border-purple-500", - bgMuted: "bg-purple-500/20", + bg: "bg-teal-400", + text: "text-teal-400", + dot: "bg-teal-400", + border: "border-teal-400", + bgMuted: "bg-teal-400/20", }, { - bg: "bg-rose-500", - text: "text-rose-500", - dot: "bg-rose-500", - border: "border-rose-500", - bgMuted: "bg-rose-500/20", + bg: "bg-fuchsia-500", + text: "text-fuchsia-500", + dot: "bg-fuchsia-500", + border: "border-fuchsia-500", + bgMuted: "bg-fuchsia-500/20", }, { - bg: "bg-cyan-500", - text: "text-cyan-500", - dot: "bg-cyan-500", - border: "border-cyan-500", - bgMuted: "bg-cyan-500/20", + bg: "bg-lime-500", + text: "text-lime-500", + dot: "bg-lime-500", + border: "border-lime-500", + bgMuted: "bg-lime-500/20", }, { - bg: "bg-orange-500", - text: "text-orange-500", - dot: "bg-orange-500", - border: "border-orange-500", - bgMuted: "bg-orange-500/20", + bg: "bg-sky-400", + text: "text-sky-400", + dot: "bg-sky-400", + border: "border-sky-400", + bgMuted: "bg-sky-400/20", }, { - bg: "bg-teal-500", - text: "text-teal-500", - dot: "bg-teal-500", - border: "border-teal-500", - bgMuted: "bg-teal-500/20", + bg: "bg-pink-400", + text: "text-pink-400", + dot: "bg-pink-400", + border: "border-pink-400", + bgMuted: "bg-pink-400/20", }, { - bg: "bg-emerald-500", - text: "text-emerald-500", - dot: "bg-emerald-500", - border: "border-emerald-500", - bgMuted: "bg-emerald-500/20", + bg: "bg-emerald-400", + text: "text-emerald-400", + dot: "bg-emerald-400", + border: "border-emerald-400", + bgMuted: "bg-emerald-400/20", }, { - bg: "bg-blue-500", - text: "text-blue-500", - dot: "bg-blue-500", - border: "border-blue-500", - bgMuted: "bg-blue-500/20", + bg: "bg-indigo-400", + text: "text-indigo-400", + dot: "bg-indigo-400", + border: "border-indigo-400", + bgMuted: "bg-indigo-400/20", + }, + { + bg: "bg-rose-400", + text: "text-rose-400", + dot: "bg-rose-400", + border: "border-rose-400", + bgMuted: "bg-rose-400/20", + }, + { + bg: "bg-cyan-300", + text: "text-cyan-300", + dot: "bg-cyan-300", + border: "border-cyan-300", + bgMuted: "bg-cyan-300/20", + }, + { + bg: "bg-purple-400", + text: "text-purple-400", + dot: "bg-purple-400", + border: "border-purple-400", + bgMuted: "bg-purple-400/20", + }, + { + bg: "bg-green-400", + text: "text-green-400", + dot: "bg-green-400", + border: "border-green-400", + bgMuted: "bg-green-400/20", + }, + { + bg: "bg-amber-400", + text: "text-amber-400", + dot: "bg-amber-400", + border: "border-amber-400", + bgMuted: "bg-amber-400/20", + }, + { + bg: "bg-slate-400", + text: "text-slate-400", + dot: "bg-slate-400", + border: "border-slate-400", + bgMuted: "bg-slate-400/20", + }, + { + bg: "bg-orange-300", + text: "text-orange-300", + dot: "bg-orange-300", + border: "border-orange-300", + bgMuted: "bg-orange-300/20", + }, + { + bg: "bg-blue-300", + text: "text-blue-300", + dot: "bg-blue-300", + border: "border-blue-300", + bgMuted: "bg-blue-300/20", }, ]; From 0b3c6ed22eff5baa0ed17606852772ace0f6dd71 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:50:31 -0500 Subject: [PATCH 39/64] add top-level profiles config section with friendly names --- frigate/comms/mqtt.py | 11 ++-- frigate/config/config.py | 16 ++++++ frigate/config/profile.py | 20 +++++++ frigate/config/profile_manager.py | 25 ++++----- frigate/test/test_profiles.py | 89 +++++++++++++++++++++++++------ 5 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 frigate/config/profile.py 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): From 39500b20a0651257c239bc9b5406597f7850a3f2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:55:56 -0500 Subject: [PATCH 40/64] implement profile friendly names and improve profile UI - Add ProfileDefinitionConfig type and profiles field to FrigateConfig - Use ProfilesApiResponse type with friendly_name support throughout - Replace Record with proper JsonObject/JsonValue types - Add profile creation form matching zone pattern (Zod + NameAndIdFields) - Add pencil icon for renaming profile friendly names in ProfilesView - Move Profiles menu item to first under Camera Configuration - Add activity indicators on save/rename/delete buttons - Display friendly names in CameraManagementView profile selector - Fix duplicate colored dots in management profile dropdown - Fix i18n namespace for overridden base config tooltips - Move profile override deletion from dropdown trash icon to footer button with confirmation dialog, matching Reset to Global pattern - Remove Add Profile from section header dropdown to prevent saving camera overrides before top-level profile definition exists - Clean up newProfiles state after API profile deletion - Refresh profiles SWR cache after saving profile definitions --- web/public/locales/en/views/settings.json | 13 +- .../config-form/sections/BaseSection.tsx | 80 ++++- .../settings/ProfileSectionDropdown.tsx | 323 ++++-------------- web/src/pages/Settings.tsx | 135 +++++--- web/src/types/configForm.ts | 2 +- web/src/types/frigateConfig.ts | 6 + web/src/types/profile.ts | 13 +- web/src/utils/configUtil.ts | 6 +- .../views/settings/CameraManagementView.tsx | 19 +- web/src/views/settings/ProfilesView.tsx | 312 ++++++++++++----- web/src/views/settings/SingleSectionPage.tsx | 28 +- 11 files changed, 519 insertions(+), 418 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 2513e58d2..2402e76ea 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1464,12 +1464,23 @@ "baseConfig": "Base Config", "addProfile": "Add Profile", "newProfile": "New Profile", - "profileNamePlaceholder": "e.g., armed, away, night", + "profileNamePlaceholder": "e.g., Armed, Away, Night Mode", + "friendlyNameLabel": "Profile Name", + "profileIdLabel": "Profile ID", + "profileIdDescription": "Internal identifier used in config and automations", "nameInvalid": "Only lowercase letters, numbers, and underscores allowed", "nameDuplicate": "A profile with this name already exists", + "error": { + "mustBeAtLeastTwoCharacters": "Must be at least 2 characters", + "mustNotContainPeriod": "Must not contain periods", + "alreadyExists": "A profile with this ID already exists" + }, + "renameProfile": "Rename Profile", + "renameSuccess": "Profile renamed to '{{profile}}'", "deleteProfile": "Delete Profile", "deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.", "deleteSuccess": "Profile '{{profile}}' deleted", + "removeOverride": "Remove Profile Override", "deleteSection": "Delete Section Overrides", "deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?", "deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index ed2c624ae..f8a22b43c 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -136,7 +136,7 @@ export interface BaseSectionProps { hasValidationErrors: boolean; }) => void; /** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */ - pendingDataBySection?: Record; + pendingDataBySection?: Record; /** Callback to update pending data for a section */ onPendingDataChange?: ( sectionKey: string, @@ -145,8 +145,12 @@ export interface BaseSectionProps { ) => void; /** When set, editing this profile's overrides instead of the base config */ profileName?: string; + /** Display name for the profile (friendly name) */ + profileFriendlyName?: string; /** Border color class for profile override badge (e.g., "border-amber-500") */ profileBorderColor?: string; + /** Callback to delete the current profile's overrides for this section */ + onDeleteProfileSection?: () => void; } export interface CreateSectionOptions { @@ -178,7 +182,9 @@ export function ConfigSection({ pendingDataBySection, onPendingDataChange, profileName, + profileFriendlyName, profileBorderColor, + onDeleteProfileSection, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -243,6 +249,8 @@ export function ConfigSection({ const [extraHasChanges, setExtraHasChanges] = useState(false); const [formKey, setFormKey] = useState(0); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + const [isDeleteProfileDialogOpen, setIsDeleteProfileDialogOpen] = + useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const isResettingRef = useRef(false); const isInitializingRef = useRef(true); @@ -932,6 +940,23 @@ export function ConfigSection({ })} )} + {profileName && + profileOverridesSection && + !hasChanges && + !skipSave && + onDeleteProfileSection && ( + + )} {hasChanges && (
); @@ -1028,7 +1094,7 @@ export function ConfigSection({ {overrideSource === "profile" ? t("button.overriddenBaseConfig", { - ns: "common", + ns: "views/settings", defaultValue: "Overridden (Base Config)", }) : t("button.overriddenGlobal", { @@ -1040,8 +1106,8 @@ export function ConfigSection({ {overrideSource === "profile" ? t("button.overriddenBaseConfigTooltip", { - ns: "common", - profile: profileName, + ns: "views/settings", + profile: profileFriendlyName ?? profileName, }) : t("button.overriddenGlobalTooltip", { ns: "views/settings", @@ -1099,7 +1165,7 @@ export function ConfigSection({ > {overrideSource === "profile" ? t("button.overriddenBaseConfig", { - ns: "common", + ns: "views/settings", defaultValue: "Overridden (Base Config)", }) : t("button.overriddenGlobal", { @@ -1111,8 +1177,8 @@ export function ConfigSection({ {overrideSource === "profile" ? t("button.overriddenBaseConfigTooltip", { - ns: "common", - profile: profileName, + ns: "views/settings", + profile: profileFriendlyName ?? profileName, }) : t("button.overriddenGlobalTooltip", { ns: "views/settings", diff --git a/web/src/components/settings/ProfileSectionDropdown.tsx b/web/src/components/settings/ProfileSectionDropdown.tsx index fbee6aec7..895ce4992 100644 --- a/web/src/components/settings/ProfileSectionDropdown.tsx +++ b/web/src/components/settings/ProfileSectionDropdown.tsx @@ -1,9 +1,7 @@ -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, ChevronDown, Plus, Trash2 } from "lucide-react"; +import { Check, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { getProfileColor } from "@/utils/profileColors"; -import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { DropdownMenu, DropdownMenuContent, @@ -11,281 +9,100 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; type ProfileSectionDropdownProps = { - cameraName: string; - sectionKey: string; allProfileNames: string[]; + profileFriendlyNames: Map; editingProfile: string | null; hasProfileData: (profileName: string) => boolean; onSelectProfile: (profileName: string | null) => void; - onAddProfile: (name: string) => void; - onDeleteProfileSection: (profileName: string) => void; }; export function ProfileSectionDropdown({ - cameraName, - sectionKey, allProfileNames, + profileFriendlyNames, editingProfile, hasProfileData, onSelectProfile, - onAddProfile, - onDeleteProfileSection, }: ProfileSectionDropdownProps) { - const { t } = useTranslation(["views/settings", "common"]); - const friendlyCameraName = useCameraFriendlyName(cameraName); - const friendlySectionName = t(`configForm.sections.${sectionKey}`, { - ns: "views/settings", - defaultValue: sectionKey, - }); - const [addDialogOpen, setAddDialogOpen] = useState(false); - const [deleteConfirmProfile, setDeleteConfirmProfile] = useState< - string | null - >(null); - const [newProfileName, setNewProfileName] = useState(""); - const [nameError, setNameError] = useState(null); - - const validateName = useCallback( - (name: string): string | null => { - if (!name.trim()) return null; - if (!/^[a-z0-9_]+$/.test(name)) { - return t("profiles.nameInvalid", { - ns: "views/settings", - }); - } - if (allProfileNames.includes(name)) { - return t("profiles.nameDuplicate", { - ns: "views/settings", - }); - } - return null; - }, - [allProfileNames, t], - ); - - const handleAddSubmit = useCallback(() => { - const name = newProfileName.trim(); - if (!name) return; - const error = validateName(name); - if (error) { - setNameError(error); - return; - } - onAddProfile(name); - onSelectProfile(name); - setAddDialogOpen(false); - setNewProfileName(""); - setNameError(null); - }, [newProfileName, validateName, onAddProfile, onSelectProfile]); - - const handleDeleteConfirm = useCallback(() => { - if (!deleteConfirmProfile) return; - onDeleteProfileSection(deleteConfirmProfile); - if (editingProfile === deleteConfirmProfile) { - onSelectProfile(null); - } - setDeleteConfirmProfile(null); - }, [ - deleteConfirmProfile, - editingProfile, - onDeleteProfileSection, - onSelectProfile, - ]); + const { t } = useTranslation(["views/settings"]); const activeColor = editingProfile ? getProfileColor(editingProfile, allProfileNames) : null; + const editingFriendlyName = editingProfile + ? (profileFriendlyNames.get(editingProfile) ?? editingProfile) + : null; + return ( - <> - - - - - - onSelectProfile(null)}> -
- {editingProfile === null && ( - - )} - - {t("profiles.baseConfig", { ns: "views/settings" })} - -
-
- - {allProfileNames.length > 0 && } - - {allProfileNames.map((profile) => { - const color = getProfileColor(profile, allProfileNames); - const hasData = hasProfileData(profile); - const isActive = editingProfile === profile; - - return ( - onSelectProfile(profile)} - > -
-
- {isActive && } - - {profile} -
- {!hasData && ( - - {t("profiles.noOverrides", { ns: "views/settings" })} - - )} -
- {hasData && ( - + + + + + + onSelectProfile(null)}> +
+ {editingProfile === null && ( + )} + + {t("profiles.baseConfig", { ns: "views/settings" })} +
- - - - - - +
- { - if (!open) setDeleteConfirmProfile(null); - }} - > - - - - {t("profiles.deleteSection", { ns: "views/settings" })} - - - {t("profiles.deleteSectionConfirm", { - ns: "views/settings", - profile: deleteConfirmProfile, - section: friendlySectionName, - camera: friendlyCameraName, - })} - - - - - {t("button.cancel", { ns: "common" })} - - 0 && } + + {allProfileNames.map((profile) => { + const color = getProfileColor(profile, allProfileNames); + const hasData = hasProfileData(profile); + const isActive = editingProfile === profile; + + return ( + onSelectProfile(profile)} > - {t("button.delete", { ns: "common" })} - - - - - +
+
+ {isActive && } + + {profileFriendlyNames.get(profile) ?? profile} +
+ {!hasData && ( + + {t("profiles.noOverrides", { ns: "views/settings" })} + + )} +
+
+ ); + })} +
+
); } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index f0f1d66e2..e20037388 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -28,7 +28,11 @@ import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; -import type { ConfigSectionData } from "@/types/configForm"; +import type { + ConfigSectionData, + JsonObject, + JsonValue, +} from "@/types/configForm"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; @@ -92,7 +96,7 @@ import { prepareSectionSavePayload, PROFILE_ELIGIBLE_SECTIONS, } from "@/utils/configUtil"; -import type { ProfileState } from "@/types/profile"; +import type { ProfileState, ProfilesApiResponse } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; import { Badge } from "@/components/ui/badge"; @@ -186,15 +190,15 @@ const parsePendingDataKey = (pendingDataKey: string) => { }; const flattenOverrides = ( - value: unknown, + value: JsonValue | undefined, path: string[] = [], -): Array<{ path: string; value: unknown }> => { +): Array<{ path: string; value: JsonValue }> => { if (value === undefined) return []; if (value === null || typeof value !== "object" || Array.isArray(value)) { return [{ path: path.join("."), value }]; } - const entries = Object.entries(value as Record); + const entries = Object.entries(value); if (entries.length === 0) { return [{ path: path.join("."), value: {} }]; } @@ -316,10 +320,7 @@ const CameraTimestampStyleSettingsPage = createSectionPage( const settingsGroups = [ { label: "general", - items: [ - { key: "uiSettings", component: UiSettingsView }, - { key: "profiles", component: ProfilesView }, - ], + items: [{ key: "uiSettings", component: UiSettingsView }], }, { label: "globalConfig", @@ -345,6 +346,7 @@ const settingsGroups = [ { label: "cameras", items: [ + { key: "profiles", component: ProfilesView }, { key: "cameraManagement", component: CameraManagementView }, { key: "cameraDetect", component: CameraDetectSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage }, @@ -635,10 +637,7 @@ export default function Settings() { >({}); const { data: config } = useSWR("config"); - const { data: profilesData } = useSWR<{ - profiles: string[]; - active_profile: string | null; - }>("profiles"); + const { data: profilesData } = useSWR("profiles"); const [searchParams] = useSearchParams(); @@ -655,7 +654,7 @@ export default function Settings() { // Store pending form data keyed by "sectionKey" or "cameraName::sectionKey" const [pendingDataBySection, setPendingDataBySection] = useState< - Record + Record >({}); // Profile editing state @@ -666,15 +665,29 @@ export default function Settings() { const [profilesUIEnabled, setProfilesUIEnabled] = useState(false); const allProfileNames = useMemo(() => { - if (!config) return []; const names = new Set(); - Object.values(config.cameras).forEach((cam) => { - Object.keys(cam.profiles ?? {}).forEach((p) => names.add(p)); - }); + if (config?.profiles) { + Object.keys(config.profiles).forEach((p) => names.add(p)); + } newProfiles.forEach((p) => names.add(p)); return [...names].sort(); }, [config, newProfiles]); + const profileFriendlyNames = useMemo(() => { + const map = new Map(); + if (profilesData?.profiles) { + profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name)); + } + // Include pending (unsaved) profile definitions + for (const [key, data] of Object.entries(pendingDataBySection)) { + if (key.startsWith("__profile_def__.") && data?.friendly_name) { + const id = key.slice("__profile_def__.".length); + map.set(id, String(data.friendly_name)); + } + } + return map; + }, [profilesData, pendingDataBySection]); + const navigate = useNavigate(); const cameras = useMemo(() => { @@ -756,7 +769,9 @@ export default function Settings() { items.push({ scope, cameraName, - profileName: isProfile ? profileName : undefined, + profileName: isProfile + ? (profileFriendlyNames.get(profileName!) ?? profileName) + : undefined, fieldPath, value, }); @@ -773,7 +788,7 @@ export default function Settings() { if (cameraCompare !== 0) return cameraCompare; return left.fieldPath.localeCompare(right.fieldPath); }); - }, [config, fullSchema, pendingDataBySection]); + }, [config, fullSchema, pendingDataBySection, profileFriendlyNames]); // Map a pendingDataKey to SettingsType menu key for clearing section status const pendingKeyToMenuKey = useCallback( @@ -827,6 +842,28 @@ export default function Settings() { for (const key of pendingKeys) { const pendingData = pendingDataBySection[key]; + + // Handle top-level profile definition saves + if (key.startsWith("__profile_def__.")) { + const profileId = key.replace("__profile_def__.", ""); + try { + const configData = { profiles: { [profileId]: pendingData } }; + await axios.put("config/set", { + requires_restart: 0, + config_data: configData, + }); + setPendingDataBySection((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + savedKeys.push(key); + successCount++; + } catch { + failCount++; + } + continue; + } + try { const payload = prepareSectionSavePayload({ pendingDataKey: key, @@ -876,6 +913,11 @@ export default function Settings() { // Refresh config from server once await mutate("config"); + // If any profile definitions were saved, refresh profiles data too + if (savedKeys.some((key) => key.startsWith("__profile_def__."))) { + await mutate("profiles"); + } + // Clear hasChanges in sidebar for all successfully saved sections if (savedKeys.length > 0) { setSectionStatusByKey((prev) => { @@ -954,13 +996,10 @@ export default function Settings() { setUnsavedChanges(false); setEditingProfile({}); - // Clear new profiles that don't exist in saved config + // Clear new profiles that now exist in top-level config if (config) { - const savedNames = new Set(); - Object.values(config.cameras).forEach((cam) => { - Object.keys(cam.profiles ?? {}).forEach((p) => savedNames.add(p)); - }); - setNewProfiles((prev) => prev.filter((p) => savedNames.has(p))); + const savedNames = new Set(Object.keys(config.profiles ?? {})); + setNewProfiles((prev) => prev.filter((p) => !savedNames.has(p))); } setSectionStatusByKey((prev) => { @@ -1062,8 +1101,14 @@ export default function Settings() { [], ); - const handleAddProfile = useCallback((name: string) => { - setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name])); + const handleAddProfile = useCallback((id: string, friendlyName: string) => { + setNewProfiles((prev) => (prev.includes(id) ? prev : [...prev, id])); + + // Stage the top-level profile definition for saving + setPendingDataBySection((prev) => ({ + ...prev, + [`__profile_def__.${id}`]: { friendly_name: friendlyName }, + })); }, []); const handleRemoveNewProfile = useCallback((name: string) => { @@ -1120,14 +1165,14 @@ export default function Settings() { ns: "views/settings", defaultValue: section, }), - profile, + profile: profileFriendlyNames.get(profile) ?? profile, }), ); } catch { toast.error(t("toast.save.error.title", { ns: "common" })); } }, - [handleSelectProfile, t], + [handleSelectProfile, profileFriendlyNames, t], ); const profileState: ProfileState = useMemo( @@ -1135,6 +1180,7 @@ export default function Settings() { editingProfile, newProfiles, allProfileNames, + profileFriendlyNames, onSelectProfile: handleSelectProfile, onAddProfile: handleAddProfile, onRemoveNewProfile: handleRemoveNewProfile, @@ -1144,6 +1190,7 @@ export default function Settings() { editingProfile, newProfiles, allProfileNames, + profileFriendlyNames, handleSelectProfile, handleAddProfile, handleRemoveNewProfile, @@ -1207,7 +1254,7 @@ export default function Settings() { // Build a targeted delete payload that only removes mask-related // sub-keys, not the entire motion/objects sections - const deletePayload: Record = {}; + const deletePayload: JsonObject = {}; if (profileData.zones !== undefined) { deletePayload.zones = ""; @@ -1218,12 +1265,12 @@ export default function Settings() { } if (profileData.objects) { - const objDelete: Record = {}; + const objDelete: JsonObject = {}; if (profileData.objects.mask !== undefined) { objDelete.mask = ""; } if (profileData.objects.filters) { - const filtersDelete: Record = {}; + const filtersDelete: JsonObject = {}; for (const [filterName, filterVal] of Object.entries( profileData.objects.filters, )) { @@ -1262,7 +1309,7 @@ export default function Settings() { section: t("configForm.sections.masksAndZones", { ns: "views/settings", }), - profile: profileName, + profile: profileFriendlyNames.get(profileName) ?? profileName, }), ); } catch { @@ -1282,6 +1329,7 @@ export default function Settings() { config, handleSelectProfile, handleDeleteProfileSection, + profileFriendlyNames, t, ], ); @@ -1490,7 +1538,8 @@ export default function Settings() { setContentMobileOpen(true); }} > - {profilesData.active_profile} + {profileFriendlyNames.get(profilesData.active_profile) ?? + profilesData.active_profile}
)}
@@ -1607,9 +1656,8 @@ export default function Settings() { )} {showProfileDropdown && currentSectionKey && ( @@ -1619,10 +1667,6 @@ export default function Settings() { profile, ) } - onAddProfile={handleAddProfile} - onDeleteProfileSection={ - handleDeleteProfileForCurrentSection - } /> )} @@ -1771,9 +1818,8 @@ export default function Settings() { )} {showProfileDropdown && currentSectionKey && ( @@ -1783,8 +1829,6 @@ export default function Settings() { profile, ) } - onAddProfile={handleAddProfile} - onDeleteProfileSection={handleDeleteProfileForCurrentSection} /> )} diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index 0782d677f..f228de430 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -23,7 +23,7 @@ export type ConfigFormContext = { extraHasChanges?: boolean; setExtraHasChanges?: (hasChanges: boolean) => void; formData?: JsonObject; - pendingDataBySection?: Record; + pendingDataBySection?: Record; onPendingDataChange?: ( sectionKey: string, cameraName: string | undefined, diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index f64aa6197..13a7acfe7 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -334,6 +334,10 @@ export type CameraProfileConfig = { zones?: Partial; }; +export type ProfileDefinitionConfig = { + friendly_name: string; +}; + export type CameraGroupConfig = { cameras: string[]; icon: IconName; @@ -488,6 +492,8 @@ export interface FrigateConfig { camera_groups: { [groupName: string]: CameraGroupConfig }; + profiles: { [profileName: string]: ProfileDefinitionConfig }; + lpr: { enabled: boolean; }; diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts index c4dc66513..0bcb95032 100644 --- a/web/src/types/profile.ts +++ b/web/src/types/profile.ts @@ -6,16 +6,27 @@ export type ProfileColor = { bgMuted: string; }; +export type ProfileInfo = { + name: string; + friendly_name: string; +}; + +export type ProfilesApiResponse = { + profiles: ProfileInfo[]; + active_profile: string | null; +}; + export type ProfileState = { editingProfile: Record; newProfiles: string[]; allProfileNames: string[]; + profileFriendlyNames: Map; onSelectProfile: ( camera: string, section: string, profile: string | null, ) => void; - onAddProfile: (name: string) => void; + onAddProfile: (id: string, friendlyName: string) => void; onRemoveNewProfile: (name: string) => void; onDeleteProfileSection: ( camera: string, diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 3fa4affda..0be14059b 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -374,7 +374,7 @@ export function requiresRestartForFieldPath( export interface SectionSavePayload { basePath: string; - sanitizedOverrides: Record; + sanitizedOverrides: JsonObject; updateTopic: string | undefined; needsRestart: boolean; pendingDataKey: string; @@ -561,7 +561,7 @@ export function prepareSectionSavePayload(opts: { if ( !sanitizedOverrides || typeof sanitizedOverrides !== "object" || - Object.keys(sanitizedOverrides as Record).length === 0 + Object.keys(sanitizedOverrides as JsonObject).length === 0 ) { return null; } @@ -597,7 +597,7 @@ export function prepareSectionSavePayload(opts: { return { basePath, - sanitizedOverrides: sanitizedOverrides as Record, + sanitizedOverrides: sanitizedOverrides as JsonObject, updateTopic, needsRestart, pendingDataKey, diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 46c1632f8..8cd13c33b 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -474,10 +474,6 @@ function ProfileCameraEnableSection({ [config, selectedProfile, localOverrides], ); - const profileColor = selectedProfile - ? getProfileColor(selectedProfile, profileState.allProfileNames) - : null; - if (!selectedProfile) return null; return ( @@ -502,17 +498,7 @@ function ProfileCameraEnableSection({
{ - setNewProfileName(e.target.value); - setNameError(validateName(e.target.value)); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddSubmit(); - } - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - + + control={addForm.control} + type="profile" + nameField="friendly_name" + idField="name" + nameLabel={t("profiles.friendlyNameLabel", { + ns: "views/settings", + })} + idLabel={t("profiles.profileIdLabel", { + ns: "views/settings", + })} + idDescription={t("profiles.profileIdDescription", { + ns: "views/settings", + })} + placeholderName={t("profiles.profileNamePlaceholder", { + ns: "views/settings", + })} + /> + + + + + + @@ -547,7 +638,9 @@ export default function ProfilesView({ {t("profiles.deleteProfileConfirm", { ns: "views/settings", - profile: deleteProfile, + profile: deleteProfile + ? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile) + : "", })} @@ -560,11 +653,54 @@ export default function ProfilesView({ onClick={handleDeleteProfile} disabled={deleting} > + {deleting && } {t("button.delete", { ns: "common" })} + + {/* Rename Profile Dialog */} + { + if (!open) setRenameProfile(null); + }} + > + + + + {t("profiles.renameProfile", { ns: "views/settings" })} + + +
+ setRenameValue(e.target.value)} + placeholder={t("profiles.profileNamePlaceholder", { + ns: "views/settings", + })} + /> + + + + +
+
+
); } diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index abd7dac84..535a5dee6 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -28,13 +28,15 @@ export type SettingsPageProps = { level: "global" | "camera", status: SectionStatus, ) => void; - pendingDataBySection?: Record; + pendingDataBySection?: Record; onPendingDataChange?: ( sectionKey: string, cameraName: string | undefined, data: ConfigSectionData | null, ) => void; profileState?: ProfileState; + /** Callback to delete the current profile's overrides for the current section */ + onDeleteProfileSection?: (profileName: string) => void; profilesUIEnabled?: boolean; setProfilesUIEnabled?: React.Dispatch>; }; @@ -70,6 +72,7 @@ export function SingleSectionPage({ pendingDataBySection, onPendingDataChange, profileState, + onDeleteProfileSection, }: SingleSectionPageProps) { const sectionNamespace = level === "camera" ? "config/cameras" : "config/global"; @@ -104,6 +107,12 @@ export function SingleSectionPage({ [currentEditingProfile, profileState?.allProfileNames], ); + const handleDeleteProfileSection = useCallback(() => { + if (currentEditingProfile && onDeleteProfileSection) { + onDeleteProfileSection(currentEditingProfile); + } + }, [currentEditingProfile, onDeleteProfileSection]); + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -179,8 +188,12 @@ export function SingleSectionPage({ {sectionStatus.overrideSource === "profile" ? t("button.overriddenBaseConfigTooltip", { - ns: "common", - profile: currentEditingProfile, + ns: "views/settings", + profile: currentEditingProfile + ? (profileState?.profileFriendlyNames.get( + currentEditingProfile, + ) ?? currentEditingProfile) + : "", }) : t("button.overriddenGlobalTooltip", { ns: "views/settings", @@ -212,7 +225,16 @@ export function SingleSectionPage({ requiresRestart={requiresRestart} onStatusChange={handleSectionStatusChange} profileName={currentEditingProfile ?? undefined} + profileFriendlyName={ + currentEditingProfile + ? (profileState?.profileFriendlyNames.get(currentEditingProfile) ?? + currentEditingProfile) + : undefined + } profileBorderColor={profileColor?.border} + onDeleteProfileSection={ + currentEditingProfile ? handleDeleteProfileSection : undefined + } />
); From 5a1ec5d729f6bf5b7604a841cf7f74a7d4ce29ef Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:32:23 -0500 Subject: [PATCH 41/64] remove profile badge in settings and add profiles to main menu --- web/public/locales/en/common.json | 1 + web/src/components/menu/GeneralSettings.tsx | 153 ++++++++++++++++++++ web/src/pages/Settings.tsx | 31 ---- 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 37566117a..8becd0c7f 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -168,6 +168,7 @@ "systemMetrics": "System metrics", "configuration": "Configuration", "systemLogs": "System logs", + "profiles": "Profiles", "settings": "Settings", "configurationEditor": "Configuration Editor", "languages": "Languages", diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 245ee8a72..7353a3035 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -2,6 +2,7 @@ import { LuActivity, LuGithub, LuLanguages, + LuLayers, LuLifeBuoy, LuList, LuLogOut, @@ -69,6 +70,9 @@ import SetPasswordDialog from "../overlay/SetPasswordDialog"; import { toast } from "sonner"; import axios from "axios"; import { FrigateConfig } from "@/types/frigateConfig"; +import type { ProfilesApiResponse } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import { supportedLanguageKeys } from "@/lib/const"; @@ -84,6 +88,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const { getLocaleDocUrl } = useDocDomain(); const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); + const { data: profilesData, mutate: updateProfiles } = + useSWR("profiles"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; // languages @@ -105,6 +111,41 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { }); }, [t]); + // profiles + + const allProfileNames = useMemo( + () => profilesData?.profiles?.map((p) => p.name) ?? [], + [profilesData], + ); + + const profileFriendlyNames = useMemo(() => { + const map = new Map(); + profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name)); + return map; + }, [profilesData]); + + const hasProfiles = allProfileNames.length > 0; + + const handleActivateProfile = async (profileName: string | null) => { + try { + await axios.put("profile/set", { profile: profileName || null }); + await updateProfiles(); + toast.success( + profileName + ? t("profiles.activated", { + ns: "views/settings", + profile: profileFriendlyNames.get(profileName) ?? profileName, + }) + : t("profiles.deactivated", { ns: "views/settings" }), + { position: "top-center" }, + ); + } catch { + toast.error(t("profiles.activateFailed", { ns: "views/settings" }), { + position: "top-center", + }); + } + }; + // settings const { language, setLanguage } = useLanguage(); @@ -285,6 +326,118 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { {t("menu.systemLogs")} + {hasProfiles && ( + + + + {t("menu.profiles")} + + + + {!isDesktop && ( + <> + + {t("menu.profiles")} + + + {t("menu.profiles")} + + + )} + + handleActivateProfile(null)} + > +
+ + {t("profiles.baseConfig", { + ns: "views/settings", + })} + + {!profilesData?.active_profile && ( + + {t("profiles.active", { + ns: "views/settings", + })} + + )} +
+
+ {allProfileNames.map((profileName) => { + const color = getProfileColor( + profileName, + allProfileNames, + ); + const isActive = + profilesData?.active_profile === profileName; + return ( + + handleActivateProfile(profileName) + } + > +
+
+ + + {profileFriendlyNames.get(profileName) ?? + profileName} + +
+ {isActive && ( + + {t("profiles.active", { + ns: "views/settings", + })} + + )} +
+
+ ); + })} +
+
+
+ )} )} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index e20037388..789a5ce0c 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -99,7 +99,6 @@ import { import type { ProfileState, ProfilesApiResponse } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; -import { Badge } from "@/components/ui/badge"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import SaveAllPreviewPopover, { @@ -1524,24 +1523,6 @@ export default function Settings() {

{t("menu.settings", { ns: "common" })}

- {profilesData?.active_profile && ( - { - setPage("profiles"); - setContentMobileOpen(true); - }} - > - {profileFriendlyNames.get(profilesData.active_profile) ?? - profilesData.active_profile} - - )}
@@ -1750,18 +1731,6 @@ export default function Settings() { {t("menu.settings", { ns: "common" })} - {profilesData?.active_profile && ( - setPage("profiles")} - > - {profilesData.active_profile} - - )}
{hasPendingChanges && ( From 0f735bea37c72720e3ba798c6870ef0fb2886a16 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:57:57 -0500 Subject: [PATCH 42/64] use icon only on mobile --- .../config-form/sections/BaseSection.tsx | 2 +- .../settings/ProfileSectionDropdown.tsx | 42 ++++++++++++------- web/src/pages/Settings.tsx | 1 + web/src/views/settings/ProfilesView.tsx | 2 +- web/src/views/settings/SingleSectionPage.tsx | 2 +- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index f8a22b43c..9918836bd 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -1157,7 +1157,7 @@ export function ConfigSection({ boolean; onSelectProfile: (profileName: string | null) => void; + /** When true, show only an icon as the trigger (for mobile) */ + iconOnly?: boolean; }; export function ProfileSectionDropdown({ @@ -25,6 +28,7 @@ export function ProfileSectionDropdown({ editingProfile, hasProfileData, onSelectProfile, + iconOnly = false, }: ProfileSectionDropdownProps) { const { t } = useTranslation(["views/settings"]); @@ -39,22 +43,28 @@ export function ProfileSectionDropdown({ return ( - + {iconOnly ? ( + + ) : ( + + )} onSelectProfile(null)}> diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 789a5ce0c..33941cf9d 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1648,6 +1648,7 @@ export default function Settings() { profile, ) } + iconOnly /> )} - {activating && } + {activating && }
)}
@@ -650,7 +655,10 @@ export default function ProfilesView({ { + e.preventDefault(); + handleDeleteProfile(); + }} disabled={deleting} > {deleting && } From e92fa2b4ba9af6ea86eed227e5da170a5839a55b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:47:31 -0500 Subject: [PATCH 46/64] tweak language --- web/public/locales/en/views/settings.json | 4 ++-- web/src/pages/Settings.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 2402e76ea..2d0e78d11 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1457,7 +1457,7 @@ "activated": "Profile '{{profile}}' activated", "activateFailed": "Failed to set profile", "deactivated": "Profile deactivated", - "noProfiles": "No profiles defined. Add a profile from any camera section.", + "noProfiles": "No profiles defined.", "noOverrides": "No overrides", "cameraCount_one": "{{count}} camera", "cameraCount_other": "{{count}} cameras", @@ -1485,7 +1485,7 @@ "deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?", "deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}", "enableSwitch": "Enable Profiles", - "enabledDescription": "Profiles are enabled. Navigate to a camera config section, create a new profile from the dropdown in the header, and save for changes to take effect.", + "enabledDescription": "Profiles are enabled. Create a new profile below, navigate to a camera config section to make your changes, and save for changes to take effect.", "disabledDescription": "Profiles allow you to define named sets of camera config overrides (e.g., armed, away, night) that can be activated on demand." }, "unsavedChanges": "You have unsaved changes", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 33941cf9d..99e7941e1 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1769,7 +1769,7 @@ export default function Settings() { > {isSavingAll ? ( <> - + {t("button.savingAll", { ns: "common" })} ) : ( From 611316906a87be99d568862448c4a3e92c2b665b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:53:53 -0500 Subject: [PATCH 47/64] immediately create profiles on backend instead of deferring to Save All --- web/public/locales/en/views/settings.json | 1 + web/src/pages/Settings.tsx | 97 ++--------------------- web/src/types/profile.ts | 3 - web/src/views/settings/ProfilesView.tsx | 53 +++++++++---- 4 files changed, 42 insertions(+), 112 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 2d0e78d11..745606ba2 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1480,6 +1480,7 @@ "deleteProfile": "Delete Profile", "deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.", "deleteSuccess": "Profile '{{profile}}' deleted", + "createSuccess": "Profile '{{profile}}' created", "removeOverride": "Remove Profile Override", "deleteSection": "Delete Section Overrides", "deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 99e7941e1..22f25855b 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -660,32 +660,20 @@ export default function Settings() { const [editingProfile, setEditingProfile] = useState< Record >({}); - const [newProfiles, setNewProfiles] = useState([]); const [profilesUIEnabled, setProfilesUIEnabled] = useState(false); const allProfileNames = useMemo(() => { - const names = new Set(); - if (config?.profiles) { - Object.keys(config.profiles).forEach((p) => names.add(p)); - } - newProfiles.forEach((p) => names.add(p)); - return [...names].sort(); - }, [config, newProfiles]); + if (!config?.profiles) return []; + return Object.keys(config.profiles).sort(); + }, [config]); const profileFriendlyNames = useMemo(() => { const map = new Map(); if (profilesData?.profiles) { profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name)); } - // Include pending (unsaved) profile definitions - for (const [key, data] of Object.entries(pendingDataBySection)) { - if (key.startsWith("__profile_def__.") && data?.friendly_name) { - const id = key.slice("__profile_def__.".length); - map.set(id, String(data.friendly_name)); - } - } return map; - }, [profilesData, pendingDataBySection]); + }, [profilesData]); const navigate = useNavigate(); @@ -842,27 +830,6 @@ export default function Settings() { for (const key of pendingKeys) { const pendingData = pendingDataBySection[key]; - // Handle top-level profile definition saves - if (key.startsWith("__profile_def__.")) { - const profileId = key.replace("__profile_def__.", ""); - try { - const configData = { profiles: { [profileId]: pendingData } }; - await axios.put("config/set", { - requires_restart: 0, - config_data: configData, - }); - setPendingDataBySection((prev) => { - const { [key]: _, ...rest } = prev; - return rest; - }); - savedKeys.push(key); - successCount++; - } catch { - failCount++; - } - continue; - } - try { const payload = prepareSectionSavePayload({ pendingDataKey: key, @@ -912,11 +879,6 @@ export default function Settings() { // Refresh config from server once await mutate("config"); - // If any profile definitions were saved, refresh profiles data too - if (savedKeys.some((key) => key.startsWith("__profile_def__."))) { - await mutate("profiles"); - } - // Clear hasChanges in sidebar for all successfully saved sections if (savedKeys.length > 0) { setSectionStatusByKey((prev) => { @@ -995,12 +957,6 @@ export default function Settings() { setUnsavedChanges(false); setEditingProfile({}); - // Clear new profiles that now exist in top-level config - if (config) { - const savedNames = new Set(Object.keys(config.profiles ?? {})); - setNewProfiles((prev) => prev.filter((p) => !savedNames.has(p))); - } - setSectionStatusByKey((prev) => { const updated = { ...prev }; for (const key of pendingKeys) { @@ -1015,7 +971,7 @@ export default function Settings() { } return updated; }); - }, [pendingDataBySection, pendingKeyToMenuKey, config]); + }, [pendingDataBySection, pendingKeyToMenuKey]); const handleDialog = useCallback( (save: boolean) => { @@ -1100,43 +1056,6 @@ export default function Settings() { [], ); - const handleAddProfile = useCallback((id: string, friendlyName: string) => { - setNewProfiles((prev) => (prev.includes(id) ? prev : [...prev, id])); - - // Stage the top-level profile definition for saving - setPendingDataBySection((prev) => ({ - ...prev, - [`__profile_def__.${id}`]: { friendly_name: friendlyName }, - })); - }, []); - - const handleRemoveNewProfile = useCallback((name: string) => { - setNewProfiles((prev) => prev.filter((p) => p !== name)); - // Clear any editing state for this profile - setEditingProfile((prev) => { - const updated = { ...prev }; - for (const key of Object.keys(updated)) { - if (updated[key] === name) { - delete updated[key]; - } - } - return updated; - }); - // Clear any pending data for this profile - setPendingDataBySection((prev) => { - const profileSegment = `profiles.${name}.`; - const updated = { ...prev }; - let changed = false; - for (const key of Object.keys(updated)) { - if (key.includes(profileSegment)) { - delete updated[key]; - changed = true; - } - } - return changed ? updated : prev; - }); - }, []); - const handleDeleteProfileSection = useCallback( async (camera: string, section: string, profile: string) => { try { @@ -1177,22 +1096,16 @@ export default function Settings() { const profileState: ProfileState = useMemo( () => ({ editingProfile, - newProfiles, allProfileNames, profileFriendlyNames, onSelectProfile: handleSelectProfile, - onAddProfile: handleAddProfile, - onRemoveNewProfile: handleRemoveNewProfile, onDeleteProfileSection: handleDeleteProfileSection, }), [ editingProfile, - newProfiles, allProfileNames, profileFriendlyNames, handleSelectProfile, - handleAddProfile, - handleRemoveNewProfile, handleDeleteProfileSection, ], ); diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts index 0bcb95032..ea3273eca 100644 --- a/web/src/types/profile.ts +++ b/web/src/types/profile.ts @@ -18,7 +18,6 @@ export type ProfilesApiResponse = { export type ProfileState = { editingProfile: Record; - newProfiles: string[]; allProfileNames: string[]; profileFriendlyNames: Map; onSelectProfile: ( @@ -26,8 +25,6 @@ export type ProfileState = { section: string, profile: string | null, ) => void; - onAddProfile: (id: string, friendlyName: string) => void; - onRemoveNewProfile: (name: string) => void; onDeleteProfileSection: ( camera: string, section: string, diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx index eafc9903f..b9c00b6d5 100644 --- a/web/src/views/settings/ProfilesView.tsx +++ b/web/src/views/settings/ProfilesView.tsx @@ -165,16 +165,42 @@ export default function ProfilesView({ return data; }, [config, allProfileNames]); + const [addingProfile, setAddingProfile] = useState(false); + const handleAddSubmit = useCallback( - (data: AddProfileForm) => { + async (data: AddProfileForm) => { const id = data.name.trim(); const friendlyName = data.friendly_name.trim(); if (!id || !friendlyName) return; - profileState?.onAddProfile(id, friendlyName); - setAddDialogOpen(false); - addForm.reset(); + + setAddingProfile(true); + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { + profiles: { [id]: { friendly_name: friendlyName } }, + }, + }); + await updateConfig(); + await updateProfiles(); + toast.success( + t("profiles.createSuccess", { + ns: "views/settings", + profile: friendlyName, + }), + { position: "top-center" }, + ); + setAddDialogOpen(false); + addForm.reset(); + } catch { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { + position: "top-center", + }); + } finally { + setAddingProfile(false); + } }, - [profileState, addForm], + [updateConfig, updateProfiles, addForm, t], ); const handleActivateProfile = useCallback( @@ -213,14 +239,6 @@ export default function ProfilesView({ const handleDeleteProfile = useCallback(async () => { if (!deleteProfile || !config) return; - // If this is an unsaved (new) profile, just remove it from local state - const isNewProfile = profileState?.newProfiles.includes(deleteProfile); - if (isNewProfile) { - profileState?.onRemoveNewProfile(deleteProfile); - setDeleteProfile(null); - return; - } - setDeleting(true); try { @@ -254,9 +272,6 @@ export default function ProfilesView({ await updateConfig(); await updateProfiles(); - // Also clean up local newProfiles state if this profile was in it - profileState?.onRemoveNewProfile(deleteProfile); - toast.success( t("profiles.deleteSuccess", { ns: "views/settings", @@ -281,7 +296,6 @@ export default function ProfilesView({ deleteProfile, activeProfile, config, - profileState, profileFriendlyNames, updateConfig, updateProfiles, @@ -609,6 +623,7 @@ export default function ProfilesView({ type="button" variant="outline" onClick={() => setAddDialogOpen(false)} + disabled={addingProfile} > {t("button.cancel", { ns: "common" })} @@ -616,10 +631,14 @@ export default function ProfilesView({ type="submit" variant="select" disabled={ + addingProfile || !addForm.watch("friendly_name").trim() || !addForm.watch("name").trim() } > + {addingProfile && ( + + )} {t("button.add", { ns: "common" })} From 074876671313232766c12793bc6c626ffecf7247 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:56:08 -0500 Subject: [PATCH 48/64] hide restart-required fields when editing a profile section fields that require a restart cannot take effect via profile switching, so they are merged into hiddenFields when profileName is set --- .../config-form/sections/BaseSection.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 9918836bd..dd88f9d19 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -335,10 +335,20 @@ export function ConfigSection({ return rawSectionValue; }, [config, rawSectionValue]); + // When editing a profile, hide fields that require a restart since they + // cannot take effect via profile switching alone. + const effectiveHiddenFields = useMemo(() => { + if (!profileName || !sectionConfig.restartRequired?.length) { + return sectionConfig.hiddenFields; + } + const base = sectionConfig.hiddenFields ?? []; + return [...new Set([...base, ...sectionConfig.restartRequired])]; + }, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]); + const sanitizeSectionData = useCallback( (data: ConfigSectionData) => - sharedSanitizeSectionData(data, sectionConfig.hiddenFields), - [sectionConfig.hiddenFields], + sharedSanitizeSectionData(data, effectiveHiddenFields), + [effectiveHiddenFields], ); const formData = useMemo(() => { @@ -850,7 +860,7 @@ export function ConfigSection({ onValidationChange={setHasValidationErrors} fieldOrder={sectionConfig.fieldOrder} fieldGroups={sectionConfig.fieldGroups} - hiddenFields={sectionConfig.hiddenFields} + hiddenFields={effectiveHiddenFields} advancedFields={sectionConfig.advancedFields} liveValidate={sectionConfig.liveValidate} uiSchema={sectionConfig.uiSchema} @@ -889,7 +899,7 @@ export function ConfigSection({ renderers: wrappedRenderers, sectionDocs: sectionConfig.sectionDocs, fieldDocs: sectionConfig.fieldDocs, - hiddenFields: sectionConfig.hiddenFields, + hiddenFields: effectiveHiddenFields, restartRequired: sectionConfig.restartRequired, requiresRestart, }} From 091e0b80d2e49ae1ec587bcf6e1d992f783cddc3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:58:16 -0500 Subject: [PATCH 49/64] show active profile indicator in desktop status bar --- web/src/components/Statusbar.tsx | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index ab22a1143..d1035dd60 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -4,8 +4,12 @@ import { StatusMessage, } from "@/context/statusbar-provider"; import useStats, { useAutoFrigateStats } from "@/hooks/use-stats"; +import { cn } from "@/lib/utils"; +import type { ProfilesApiResponse } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; import { useContext, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import useSWR from "swr"; import { FaCheck } from "react-icons/fa"; import { IoIosWarning } from "react-icons/io"; @@ -46,6 +50,21 @@ export default function Statusbar() { }); }, [potentialProblems, addMessage, clearMessages]); + const { data: profilesData } = useSWR("profiles"); + + const activeProfile = useMemo(() => { + if (!profilesData?.active_profile || !profilesData.profiles) return null; + const info = profilesData.profiles.find( + (p) => p.name === profilesData.active_profile, + ); + const allNames = profilesData.profiles.map((p) => p.name).sort(); + return { + name: profilesData.active_profile, + friendlyName: info?.friendly_name ?? profilesData.active_profile, + color: getProfileColor(profilesData.active_profile, allNames), + }; + }, [profilesData]); + const { payload: reindexState } = useEmbeddingsReindexProgress(); useEffect(() => { @@ -136,6 +155,21 @@ export default function Statusbar() { ); })} + {activeProfile && ( + +
+ + + {activeProfile.friendlyName} + +
+ + )}
{Object.entries(messages).length === 0 ? ( From 12e9bb3944af75b3c099f6682945771efb7b0454 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:20:03 -0500 Subject: [PATCH 50/64] fix profile config inheritance bug where Pydantic defaults override base values The /config API was dumping profile overrides with model_dump() which included all Pydantic defaults. When the frontend merged these over the camera's base config, explicitly-set base values were lost. Now profile overrides are re-dumped with exclude_unset=True so only user-specified fields are returned. Also fixes the Save All path generating spurious deletion markers for restart-required fields that are hidden during profile editing but not excluded from the raw data sanitization in prepareSectionSavePayload. --- frigate/api/app.py | 16 +++++++++++++--- frigate/config/profile_manager.py | 4 +++- web/src/utils/configUtil.ts | 13 ++++++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 71b9dbc74..383b76151 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -158,6 +158,18 @@ def config(request: Request): for zone_name, zone in config_obj.cameras[camera_name].zones.items(): camera_dict["zones"][zone_name]["color"] = zone.color + # Re-dump profile overrides with exclude_unset so that only + # explicitly-set fields are returned (not Pydantic defaults). + # Without this, the frontend merges defaults (e.g. threshold=30) + # over the camera's actual base values (e.g. threshold=20). + if camera.profiles: + for profile_name, profile_config in camera.profiles.items(): + camera_dict.setdefault("profiles", {})[profile_name] = ( + profile_config.model_dump( + mode="json", warnings="none", exclude_unset=True + ) + ) + # remove go2rtc stream passwords go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( mode="json", warnings="none", exclude_none=True @@ -229,9 +241,7 @@ def set_profile(request: Request, body: ProfileSetBody): content={"success": False, "message": err}, status_code=400, ) - request.app.dispatcher.publish( - "profile/state", body.profile or "none", retain=True - ) + request.app.dispatcher.publish("profile/state", body.profile or "none", retain=True) return JSONResponse( content={ "success": True, diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index ac07cf54c..d5cd6f921 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -101,7 +101,9 @@ class ProfileManager: """ if profile_name is not None: if profile_name not in self.config.profiles: - return f"Profile '{profile_name}' is not defined in the profiles section" + 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]] = {} diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 0be14059b..1707bc720 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -533,10 +533,21 @@ export function prepareSectionSavePayload(opts: { ? {} : rawSectionValue; + // For profile sections, also hide restart-required fields to match + // effectiveHiddenFields in BaseSection (prevents spurious deletion markers + // for fields that are hidden from the form during profile editing). + let hiddenFieldsForSanitize = sectionConfig.hiddenFields; + if (profileInfo.isProfile && sectionConfig.restartRequired?.length) { + const base = sectionConfig.hiddenFields ?? []; + hiddenFieldsForSanitize = [ + ...new Set([...base, ...sectionConfig.restartRequired]), + ]; + } + // Sanitize raw form data const rawData = sanitizeSectionData( rawFormData as ConfigSectionData, - sectionConfig.hiddenFields, + hiddenFieldsForSanitize, ); // Compute schema defaults From 79da95bf88482ea79c64b2f9af8ab95ce9c506b5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:31:16 -0500 Subject: [PATCH 51/64] docs tweaks --- docs/docs/configuration/profiles.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md index cbbf37442..356c1c8d0 100644 --- a/docs/docs/configuration/profiles.md +++ b/docs/docs/configuration/profiles.md @@ -24,7 +24,16 @@ Profile changes are applied in-memory and take effect immediately — no restart The easiest way to define profiles is to use the Frigate UI. Profiles can also be configured manually in your configuration file. -### Defining Profiles +### Using the UI + +To create and manage profiles from the UI, open **Settings**. From there you can: + +1. **Create a profile** — Navigate to **Profiles**. Click the **Add Profile** button, enter a name (and optionally a profile ID). +2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button +3. **Activate a profile** — Navigate to **Profiles**, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers. +4. **Delete a profile** — Navigate to **Profiles**, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it. + +### Defining Profiles in YAML First, define your profiles at the top level of your Frigate config. Every profile name referenced by a camera must be defined here. From cbfefd6df56e97dc99f4fbf8c4ab251f974baedf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:45:32 -0500 Subject: [PATCH 52/64] docs tweak --- docs/docs/configuration/profiles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md index 356c1c8d0..ef0778e18 100644 --- a/docs/docs/configuration/profiles.md +++ b/docs/docs/configuration/profiles.md @@ -30,7 +30,7 @@ To create and manage profiles from the UI, open **Settings**. From there you can 1. **Create a profile** — Navigate to **Profiles**. Click the **Add Profile** button, enter a name (and optionally a profile ID). 2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button -3. **Activate a profile** — Navigate to **Profiles**, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers. +3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to **Profiles**, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers. 4. **Delete a profile** — Navigate to **Profiles**, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it. ### Defining Profiles in YAML From b657f04d0ed0b79b86d1cc45ded76ea3c1552058 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:36:06 -0500 Subject: [PATCH 53/64] formatting --- frigate/app.py | 4 +--- frigate/test/test_profiles.py | 40 +++++++++++------------------------ 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index 1a24b23b5..202190c68 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -351,9 +351,7 @@ class FrigateApp: ) def init_profile_manager(self) -> None: - self.profile_manager = ProfileManager( - self.config, self.inter_config_updater - ) + self.profile_manager = ProfileManager(self.config, self.inter_config_updater) self.dispatcher.profile_manager = self.profile_manager persisted = ProfileManager.load_persisted_profile() diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 9beed1bf5..430aca97b 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -48,9 +48,7 @@ class TestCameraProfileConfig(unittest.TestCase): def test_partial_review(self): """Profile with nested review.alerts.labels.""" - profile = CameraProfileConfig( - review={"alerts": {"labels": ["person", "car"]}} - ) + profile = CameraProfileConfig(review={"alerts": {"labels": ["person", "car"]}}) assert profile.review is not None assert profile.review.alerts.labels == ["person", "car"] @@ -116,9 +114,7 @@ class TestCameraProfileConfig(unittest.TestCase): from pydantic import ValidationError with self.assertRaises(ValidationError): - CameraProfileConfig( - review={"alerts": {"labels": "not_a_list"}} - ) + CameraProfileConfig(review={"alerts": {"labels": "not_a_list"}}) def test_invalid_profile_in_camera_config(self): """Invalid profile section in full config is caught at parse time.""" @@ -410,14 +406,14 @@ class TestProfileManager(unittest.TestCase): self.manager.activate_profile("disarmed") # Back camera has no "disarmed" profile, should be unchanged - assert self.config.cameras["back"].notifications.enabled == back_base_notifications + 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.""" - self.config.profiles["away"] = ProfileDefinitionConfig( - friendly_name="Away" - ) + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") self.config.cameras["front"].profiles["away"] = CameraProfileConfig( enabled=False ) @@ -431,9 +427,7 @@ 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.""" - self.config.profiles["away"] = ProfileDefinitionConfig( - friendly_name="Away" - ) + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") self.config.cameras["front"].profiles["away"] = CameraProfileConfig( enabled=False ) @@ -450,9 +444,7 @@ class TestProfileManager(unittest.TestCase): """Profile with zones adds/overrides zones on camera.""" from frigate.config.camera.zone import ZoneConfig - self.config.profiles["away"] = ProfileDefinitionConfig( - friendly_name="Away" - ) + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") self.config.cameras["front"].profiles["away"] = CameraProfileConfig( zones={ "driveway": ZoneConfig( @@ -474,9 +466,7 @@ class TestProfileManager(unittest.TestCase): """Deactivating a profile restores base zones.""" from frigate.config.camera.zone import ZoneConfig - self.config.profiles["away"] = ProfileDefinitionConfig( - friendly_name="Away" - ) + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") self.config.cameras["front"].profiles["away"] = CameraProfileConfig( zones={ "driveway": ZoneConfig( @@ -502,9 +492,7 @@ class TestProfileManager(unittest.TestCase): ) from frigate.config.camera.zone import ZoneConfig - self.config.profiles["away"] = ProfileDefinitionConfig( - friendly_name="Away" - ) + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") self.config.cameras["front"].profiles["away"] = CameraProfileConfig( zones={ "driveway": ZoneConfig( @@ -534,9 +522,7 @@ class TestProfileManager(unittest.TestCase): CameraConfigUpdateTopic, ) - self.config.profiles["away"] = ProfileDefinitionConfig( - friendly_name="Away" - ) + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") self.config.cameras["front"].profiles["away"] = CameraProfileConfig( enabled=False ) @@ -598,9 +584,7 @@ class TestProfilePersistence(unittest.TestCase): try: with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True): - with patch.object( - type(PERSISTENCE_FILE), "read_text", return_value="" - ): + with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""): result = ProfileManager.load_persisted_profile() assert result is None finally: From 3c3cf11da4b671364070550fb4ef0cc818686775 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:36:25 -0500 Subject: [PATCH 54/64] formatting --- frigate/comms/dispatcher.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index ad512e6ab..edc60e069 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -569,7 +569,9 @@ class Dispatcher: logger.error("Profile manager not initialized") return - profile_name = payload.strip() if payload.strip() not in ("", "none", "None") else None + profile_name = ( + payload.strip() if payload.strip() not in ("", "none", "None") else None + ) err = self.profile_manager.activate_profile(profile_name) if err: logger.error("Failed to activate profile: %s", err) From 0835aa7ea5256fe406174a74b22a3b82d0b3f9d7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:54:35 -0500 Subject: [PATCH 55/64] fix typing --- frigate/app.py | 1 + frigate/comms/dispatcher.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frigate/app.py b/frigate/app.py index 202190c68..9d60d2a08 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -119,6 +119,7 @@ class FrigateApp: self.ptz_metrics: dict[str, PTZMetrics] = {} self.processes: dict[str, int] = {} self.embeddings: Optional[EmbeddingsContext] = None + self.profile_manager: Optional[ProfileManager] = None self.config = config def ensure_dirs(self) -> None: diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index edc60e069..4eeb76396 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -16,6 +16,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig +from frigate.config.profile_manager import ProfileManager from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, EXPIRE_AUDIO_ACTIVITY, @@ -93,7 +94,7 @@ class Dispatcher: "notifications": self._on_global_notification_command, "profile": self._on_profile_command, } - self.profile_manager = None + self.profile_manager: Optional[ProfileManager] = None for comm in self.comms: comm.subscribe(self._receive) From 67604eb61d3b79f4e2c12c2ba90ed0a8614e3b50 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:31:36 -0500 Subject: [PATCH 56/64] fix test pollution test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed --- frigate/test/test_maintainer.py | 23 ++++++++++++++++++----- test.db-journal | Bin 0 -> 12824 bytes 2 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 test.db-journal diff --git a/frigate/test/test_maintainer.py b/frigate/test/test_maintainer.py index d978cfd9f..49712749e 100644 --- a/frigate/test/test_maintainer.py +++ b/frigate/test/test_maintainer.py @@ -2,16 +2,29 @@ import sys import unittest from unittest.mock import MagicMock, patch -# Mock complex imports before importing maintainer -sys.modules["frigate.comms.inter_process"] = MagicMock() -sys.modules["frigate.comms.detections_updater"] = MagicMock() -sys.modules["frigate.comms.recordings_updater"] = MagicMock() -sys.modules["frigate.config.camera.updater"] = MagicMock() +# Mock complex imports before importing maintainer, saving originals so we can +# restore them after import and avoid polluting sys.modules for other tests. +_MOCKED_MODULES = [ + "frigate.comms.inter_process", + "frigate.comms.detections_updater", + "frigate.comms.recordings_updater", + "frigate.config.camera.updater", +] +_originals = {name: sys.modules.get(name) for name in _MOCKED_MODULES} +for name in _MOCKED_MODULES: + sys.modules[name] = MagicMock() # Now import the class under test from frigate.config import FrigateConfig # noqa: E402 from frigate.record.maintainer import RecordingMaintainer # noqa: E402 +# Restore original modules (or remove mock if there was no original) +for name, orig in _originals.items(): + if orig is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = orig + class TestMaintainer(unittest.IsolatedAsyncioTestCase): async def test_move_files_survives_bad_filename(self): diff --git a/test.db-journal b/test.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..9d13ac887a1124877686e4f2e60cfb8b90c099d1 GIT binary patch literal 12824 zcmeGiU2oggm9%MnSdx`AX_+Q*CMyUME=$Z0krI=+sp@L38{1hNXKh++94*l{8%dN& zI!&@CrOAK*1v(5HFks!o2IOVf)86*dVGr9dVB1?)6noj8hW&tH=z?K8yqA|071B%4 zq*zc1uu0x~&pr2i-gC~qAAdOUN$JPUk0^>7U%vOOPT?1YSMqE5qs#~SA7=hEc7FUE z^@p)N)EmkCxwF5P-geV$^*G(%Yj#`4b=$aR8gFZkvDq=YuGZ8$hOOg9zwOZcYNNi^ zC@rqM+<3iIjs+-}F07C)AFNzDQO2{Cxmz#VY4-SW`t~_jUvC?c=_4P{`?%KXFD*4< zCNx{+(%0&%^RLubPe?phTDh=RTDiQmRQl@b;&OfUO6h##%9lz6Z!lCG7z*8vtJ|*T znjNEDdKpMtTU>4gQ$fGcZ4LE8xR+Q@PZ9k$>PddW1+D+2X#4i)3 zakq9l>sn8Dx56Q!EQh4Pn3nG9<0>Phuymq`{=?5nBuW&$JSlb>Xu8Oq)R~$ zH@lr;=ckj{MQGFn@^;|%mXT&zmcBcSm;_?%y69gj${y7A2)t?Q>qdJ()KN|J4dk&Z zs%!N$r)k-s+#XMWNgI0GF|?lLn67yZnti>6o|{(JaE4S3nvs%rbrTl8)_5IE%r`UW zdN@Me~Q+5i40xO#-_83d!y~4QG&s|NkhYr#Gj&Gcs;6NSQK#zS<{`##00t$#6=y5gFpK>8af_ffLsHTSn7`4I$hiIu_nu z!Yj1@b50nnIeL1#yqZi#z0U=6^_d7ol?f2|mqAfMtFdi{6Ai z5CXIiPdX6~8M_N6PkjcX$;PF5Xd*;&(rk)dm`Msl!Zm0RvJoXJg3X?I7^`^X80YgC z2wE0oJD>>rh7}+7_9j6Qfy!h`x@}d8D2T@#KDvrNs&T&vWcrthJIuU z2wK?T!P2->?G#%-lx$vTNAqk8z3lIEbeiQj`c?tC_Kvv;$%CL zw3E1b-=ziN@z5^VgHtrw4&ar;nr#zutzj<+itBYcUA^aQS>W7a24IhjGXNOBZe0)Fq4)^J zFCPZ_Y{S*fuI6n?8)g@LVH=8TF;hA_>)KGUMc`3}hAKosF{IbtaUjVK9Wzejq7{>l z)iT-|kTFoDlSD7wYZ)$t$d)~n@!?Jk7R?2Hu$YqGs19I*q(^lCuLvJRg zfLDP$E_!9+@cItfvvD-Yi=1)Ws17iAdJ~xcU>zX;9>qLN9RMBNeMZ$Wi0kvHT6fXx zjxt^UKs1ovlgo5(y^5=nx3y7K5?s@#s!DQCNgV)e?XV4XfM3DOSONZ(Ge4n%3Ia^j1qVuJ~}04-@vC;-Ccw#WqDC8Bs( zK@^Rl8UhY*Egm8=vP+lHzzs;FL%gA-I)5e4-iVydX1MRwsD zz=qjGckMu)SB-8geS#{eK;iQYRR#KFf)bIK$WfuDsFEZfV5qYIR3SjV0_*}N)|A>Q zmZ44pn1q(;pY+92RaHevn0t<)&ckq#U?C#&@4qHvtIASMu0G39=KwrV7jl0vSzm;z z0C?5?n47$REe3Oc;5ayT$%?G1lClp8P*B4q<8#gAwJLl~kiYm0LmjL0LJe`IwZ0KA zF}W&Ks{CGt5@2jKvOFUJLK47E_aGSy3Y=rFqmmAg(?!uWf9= z9Sh5jCB9Or2vubY6JPNm@TDS0gY!^rqZbP&NtK#dEg(S&fjc1=#dZ;t2&PsM^9&^e zC$KLWyz3H+Oi-)9y&NV>co^;p5{Oiq#N6YNZ19mpN8j{r|HY!=Rd7~n7U>X=-3C6k z*EYv24Nz3N(KD!^1bhK{{^q?10( Date: Mon, 16 Mar 2026 07:38:49 -0500 Subject: [PATCH 57/64] remove --- test.db-journal | Bin 12824 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test.db-journal diff --git a/test.db-journal b/test.db-journal deleted file mode 100644 index 9d13ac887a1124877686e4f2e60cfb8b90c099d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12824 zcmeGiU2oggm9%MnSdx`AX_+Q*CMyUME=$Z0krI=+sp@L38{1hNXKh++94*l{8%dN& zI!&@CrOAK*1v(5HFks!o2IOVf)86*dVGr9dVB1?)6noj8hW&tH=z?K8yqA|071B%4 zq*zc1uu0x~&pr2i-gC~qAAdOUN$JPUk0^>7U%vOOPT?1YSMqE5qs#~SA7=hEc7FUE z^@p)N)EmkCxwF5P-geV$^*G(%Yj#`4b=$aR8gFZkvDq=YuGZ8$hOOg9zwOZcYNNi^ zC@rqM+<3iIjs+-}F07C)AFNzDQO2{Cxmz#VY4-SW`t~_jUvC?c=_4P{`?%KXFD*4< zCNx{+(%0&%^RLubPe?phTDh=RTDiQmRQl@b;&OfUO6h##%9lz6Z!lCG7z*8vtJ|*T znjNEDdKpMtTU>4gQ$fGcZ4LE8xR+Q@PZ9k$>PddW1+D+2X#4i)3 zakq9l>sn8Dx56Q!EQh4Pn3nG9<0>Phuymq`{=?5nBuW&$JSlb>Xu8Oq)R~$ zH@lr;=ckj{MQGFn@^;|%mXT&zmcBcSm;_?%y69gj${y7A2)t?Q>qdJ()KN|J4dk&Z zs%!N$r)k-s+#XMWNgI0GF|?lLn67yZnti>6o|{(JaE4S3nvs%rbrTl8)_5IE%r`UW zdN@Me~Q+5i40xO#-_83d!y~4QG&s|NkhYr#Gj&Gcs;6NSQK#zS<{`##00t$#6=y5gFpK>8af_ffLsHTSn7`4I$hiIu_nu z!Yj1@b50nnIeL1#yqZi#z0U=6^_d7ol?f2|mqAfMtFdi{6Ai z5CXIiPdX6~8M_N6PkjcX$;PF5Xd*;&(rk)dm`Msl!Zm0RvJoXJg3X?I7^`^X80YgC z2wE0oJD>>rh7}+7_9j6Qfy!h`x@}d8D2T@#KDvrNs&T&vWcrthJIuU z2wK?T!P2->?G#%-lx$vTNAqk8z3lIEbeiQj`c?tC_Kvv;$%CL zw3E1b-=ziN@z5^VgHtrw4&ar;nr#zutzj<+itBYcUA^aQS>W7a24IhjGXNOBZe0)Fq4)^J zFCPZ_Y{S*fuI6n?8)g@LVH=8TF;hA_>)KGUMc`3}hAKosF{IbtaUjVK9Wzejq7{>l z)iT-|kTFoDlSD7wYZ)$t$d)~n@!?Jk7R?2Hu$YqGs19I*q(^lCuLvJRg zfLDP$E_!9+@cItfvvD-Yi=1)Ws17iAdJ~xcU>zX;9>qLN9RMBNeMZ$Wi0kvHT6fXx zjxt^UKs1ovlgo5(y^5=nx3y7K5?s@#s!DQCNgV)e?XV4XfM3DOSONZ(Ge4n%3Ia^j1qVuJ~}04-@vC;-Ccw#WqDC8Bs( zK@^Rl8UhY*Egm8=vP+lHzzs;FL%gA-I)5e4-iVydX1MRwsD zz=qjGckMu)SB-8geS#{eK;iQYRR#KFf)bIK$WfuDsFEZfV5qYIR3SjV0_*}N)|A>Q zmZ44pn1q(;pY+92RaHevn0t<)&ckq#U?C#&@4qHvtIASMu0G39=KwrV7jl0vSzm;z z0C?5?n47$REe3Oc;5ayT$%?G1lClp8P*B4q<8#gAwJLl~kiYm0LmjL0LJe`IwZ0KA zF}W&Ks{CGt5@2jKvOFUJLK47E_aGSy3Y=rFqmmAg(?!uWf9= z9Sh5jCB9Or2vubY6JPNm@TDS0gY!^rqZbP&NtK#dEg(S&fjc1=#dZ;t2&PsM^9&^e zC$KLWyz3H+Oi-)9y&NV>co^;p5{Oiq#N6YNZ19mpN8j{r|HY!=Rd7~n7U>X=-3C6k z*EYv24Nz3N(KD!^1bhK{{^q?10( Date: Mon, 16 Mar 2026 12:23:07 -0500 Subject: [PATCH 58/64] fix settings showing profile-merged values when editing base config When a profile is active, the in-memory config contains effective (profile-merged) values. The settings UI was displaying these merged values even when the "Base Config" view was selected. Backend: snapshot pre-profile base configs in ProfileManager and expose them via a `base_config` key in the /api/config camera response when a profile is active. The top-level sections continue to reflect the effective running config. Frontend: read from `base_config` when available in BaseSection, useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload. Include formData labels in Object/Audio switches widgets so that labels added only by a profile override remain visible when editing that profile. --- frigate/api/app.py | 15 ++++++ frigate/config/profile_manager.py | 20 +++++++- frigate/test/test_profiles.py | 28 +++++++++++ .../config-form/sections/BaseSection.tsx | 13 ++++- .../widgets/AudioLabelSwitchesWidget.tsx | 44 +++++++---------- .../widgets/ObjectLabelSwitchesWidget.tsx | 49 +++++++++---------- .../theme/widgets/SwitchesWidget.tsx | 1 + web/src/hooks/use-config-override.ts | 13 ++++- web/src/types/frigateConfig.ts | 2 + web/src/utils/configUtil.ts | 31 ++++++++++-- 10 files changed, 158 insertions(+), 58 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 383b76151..d7abb8e47 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -170,6 +170,21 @@ def config(request: Request): ) ) + # When a profile is active, the top-level camera sections contain + # profile-merged (effective) values. Include the original base + # configs so the frontend settings can display them separately. + if ( + config_obj.active_profile is not None + and request.app.profile_manager is not None + ): + base_sections = ( + request.app.profile_manager.get_base_configs_for_api( + camera_name + ) + ) + if base_sections: + camera_dict["base_config"] = base_sections + # remove go2rtc stream passwords go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( mode="json", warnings="none", exclude_none=True diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index d5cd6f921..36c299989 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -47,6 +47,7 @@ class ProfileManager: self.config: FrigateConfig = config self.config_updater = config_updater self._base_configs: dict[str, dict[str, dict]] = {} + self._base_api_configs: dict[str, dict[str, dict]] = {} self._base_enabled: dict[str, bool] = {} self._base_zones: dict[str, dict[str, ZoneConfig]] = {} self._snapshot_base_configs() @@ -55,12 +56,20 @@ class ProfileManager: """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_api_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: - self._base_configs[cam_name][section] = section_config.model_dump() + self._base_configs[cam_name][section] = ( + section_config.model_dump() + ) + self._base_api_configs[cam_name][section] = ( + section_config.model_dump( + mode="json", warnings="none", exclude_none=True + ) + ) def update_config(self, new_config) -> None: """Update config reference after config/set replaces the in-memory config. @@ -74,6 +83,7 @@ class ProfileManager: # Re-snapshot base configs from the new config (which has base values) self._base_configs.clear() + self._base_api_configs.clear() self._base_enabled.clear() self._base_zones.clear() self._snapshot_base_configs() @@ -260,6 +270,14 @@ class ProfileManager: logger.exception("Failed to load persisted profile") return None + def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]: + """Return base (pre-profile) section configs for a camera. + + These are JSON-serializable dicts suitable for direct inclusion in + the /api/config response, with None values already excluded. + """ + return self._base_api_configs.get(camera_name, {}) + def get_available_profiles(self) -> list[dict[str, str]]: """Get list of all profile definitions from the top-level config.""" return [ diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 430aca97b..b77d3ebb6 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -557,6 +557,34 @@ class TestProfileManager(unittest.TestCase): assert "armed" in names assert "disarmed" in names + @patch.object(ProfileManager, "_persist_active_profile") + def test_base_configs_for_api_unchanged_after_activation(self, mock_persist): + """API base configs reflect pre-profile values after activation.""" + base_track = self.config.cameras["front"].objects.track[:] + assert base_track == ["person"] + + self.manager.activate_profile("armed") + + # In-memory config has the profile-merged values + assert self.config.cameras["front"].objects.track == [ + "person", + "car", + "package", + ] + + # But the API base configs still return the original base values + api_base = self.manager.get_base_configs_for_api("front") + assert "objects" in api_base + assert api_base["objects"]["track"] == ["person"] + + def test_base_configs_for_api_are_json_serializable(self): + """API base configs are JSON-serializable (mode='json').""" + import json + + api_base = self.manager.get_base_configs_for_api("front") + # Should not raise + json.dumps(api_base) + class TestProfilePersistence(unittest.TestCase): """Test profile persistence to disk.""" diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index dd88f9d19..d2be6ded4 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -65,6 +65,7 @@ import { globalCameraDefaultSections, buildOverrides, buildConfigDataForPath, + getBaseCameraSectionValue, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, } from "@/utils/configUtil"; @@ -303,12 +304,20 @@ export function ConfigSection({ profileOverridesSection ? "profile" : isOverridden ? "global" : undefined; // Get current form data - // When editing a profile, show base camera config deep-merged with profile overrides + // When a profile is active the top-level camera sections contain the + // effective (profile-merged) values. For the base-config view we read + // from `base_config` (original values before the profile was applied). + // When editing a profile, we merge the base value with profile overrides. const rawSectionValue = useMemo(() => { if (!config) return undefined; if (effectiveLevel === "camera" && cameraName) { - const baseValue = get(config.cameras?.[cameraName], sectionPath); + // Base value: prefer base_config (pre-profile) over effective value + const baseValue = getBaseCameraSectionValue( + config, + cameraName, + sectionPath, + ); if (profileName) { const profileOverrides = get( config.cameras?.[cameraName], diff --git a/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx index 7b82a3836..9a79ebce1 100644 --- a/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/AudioLabelSwitchesWidget.tsx @@ -7,41 +7,35 @@ import type { FormContext } from "./SwitchesWidget"; import { getTranslatedLabel } from "@/utils/i18n"; import { JsonObject } from "@/types/configForm"; +function extractListenLabels(value: unknown): string[] { + if (value && typeof value === "object" && !Array.isArray(value)) { + const listenValue = (value as JsonObject).listen; + if (Array.isArray(listenValue)) { + return listenValue.filter( + (item): item is string => typeof item === "string", + ); + } + } + return []; +} + function getEnabledAudioLabels(context: FormContext): string[] { let cameraLabels: string[] = []; let globalLabels: string[] = []; + let formDataLabels: string[] = []; if (context) { // context.cameraValue and context.globalValue should be the entire audio section - if ( - context.cameraValue && - typeof context.cameraValue === "object" && - !Array.isArray(context.cameraValue) - ) { - const listenValue = (context.cameraValue as JsonObject).listen; - if (Array.isArray(listenValue)) { - cameraLabels = listenValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + cameraLabels = extractListenLabels(context.cameraValue); + globalLabels = extractListenLabels(context.globalValue); - if ( - context.globalValue && - typeof context.globalValue === "object" && - !Array.isArray(context.globalValue) - ) { - const globalListenValue = (context.globalValue as JsonObject).listen; - if (Array.isArray(globalListenValue)) { - globalLabels = globalListenValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + // Include labels from the current form data so that labels added via + // profile overrides (or user edits) are always visible as switches. + formDataLabels = extractListenLabels(context.formData); } const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels; - return [...sourceLabels].sort(); + return [...new Set([...sourceLabels, ...formDataLabels])].sort(); } function getAudioLabelDisplayName(label: string): string { diff --git a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx index bb54c9529..d3d6bdbe3 100644 --- a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx @@ -40,43 +40,42 @@ function getLabelmapLabels(context: FormContext): string[] { return [...labels]; } +// Extract track labels from an objects section value. +function extractTrackLabels(value: unknown): string[] { + if (value && typeof value === "object" && !Array.isArray(value)) { + const trackValue = (value as JsonObject).track; + if (Array.isArray(trackValue)) { + return trackValue.filter( + (item): item is string => typeof item === "string", + ); + } + } + return []; +} + // Build the list of labels for switches (labelmap + configured track list). function getObjectLabels(context: FormContext): string[] { const labelmapLabels = getLabelmapLabels(context); let cameraLabels: string[] = []; let globalLabels: string[] = []; + let formDataLabels: string[] = []; if (context) { // context.cameraValue and context.globalValue should be the entire objects section - if ( - context.cameraValue && - typeof context.cameraValue === "object" && - !Array.isArray(context.cameraValue) - ) { - const trackValue = (context.cameraValue as JsonObject).track; - if (Array.isArray(trackValue)) { - cameraLabels = trackValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + cameraLabels = extractTrackLabels(context.cameraValue); + globalLabels = extractTrackLabels(context.globalValue); - if ( - context.globalValue && - typeof context.globalValue === "object" && - !Array.isArray(context.globalValue) - ) { - const globalTrackValue = (context.globalValue as JsonObject).track; - if (Array.isArray(globalTrackValue)) { - globalLabels = globalTrackValue.filter( - (item): item is string => typeof item === "string", - ); - } - } + // Include labels from the current form data so that labels added via + // profile overrides (or user edits) are always visible as switches. + formDataLabels = extractTrackLabels(context.formData); } const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels; - const combinedLabels = new Set([...labelmapLabels, ...sourceLabels]); + const combinedLabels = new Set([ + ...labelmapLabels, + ...sourceLabels, + ...formDataLabels, + ]); return [...combinedLabels].sort(); } diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx index 0a56c47b8..272629a1a 100644 --- a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -20,6 +20,7 @@ type FormContext = Pick< | "globalValue" | "fullCameraConfig" | "fullConfig" + | "formData" | "t" | "level" > & { diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index d0577a6c6..cd878e08f 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -6,6 +6,7 @@ import set from "lodash/set"; import { FrigateConfig } from "@/types/frigateConfig"; import { JsonObject, JsonValue } from "@/types/configForm"; import { isJsonObject } from "@/lib/utils"; +import { getBaseCameraSectionValue } from "@/utils/configUtil"; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; @@ -144,7 +145,13 @@ export function useConfigOverride({ }; } - const cameraValue = get(cameraConfig, sectionPath); + // Prefer the base (pre-profile) value so that override detection and + // widget context reflect the camera's own config, not profile effects. + const cameraValue = getBaseCameraSectionValue( + config, + cameraName, + sectionPath, + ); const normalizedGlobalValue = normalizeConfigValue(globalValue); const normalizedCameraValue = normalizeConfigValue(cameraValue); @@ -256,7 +263,9 @@ export function useAllCameraOverrides( for (const { key, compareFields } of sectionsToCheck) { const globalValue = normalizeConfigValue(get(config, key)); - const cameraValue = normalizeConfigValue(get(cameraConfig, key)); + const cameraValue = normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, key), + ); const comparisonGlobal = compareFields ? pickFields(globalValue, compareFields) diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 13a7acfe7..1efa47fbc 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -316,6 +316,8 @@ export interface CameraConfig { }; }; profiles?: Record; + /** Pre-profile base section configs, present only when a profile is active */ + base_config?: Record>; } export type CameraProfileConfig = { diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 1707bc720..bbd73fdab 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -73,6 +73,25 @@ export const globalCameraDefaultSections = new Set([ // Profile helpers // --------------------------------------------------------------------------- +/** + * Get the base (pre-profile) value for a camera section. + * + * When a profile is active the API populates `base_config` with original + * section values. This helper returns that value when available, falling + * back to the top-level (effective) value otherwise. + */ +export function getBaseCameraSectionValue( + config: FrigateConfig | undefined, + cameraName: string | undefined, + sectionPath: string, +): unknown { + if (!config || !cameraName) return undefined; + const cam = config.cameras?.[cameraName]; + if (!cam) return undefined; + const base = cam.base_config?.[sectionPath]; + return base !== undefined ? base : get(cam, sectionPath); +} + /** Sections that can appear inside a camera profile definition. */ export const PROFILE_ELIGIBLE_SECTIONS = new Set([ "audio", @@ -504,8 +523,9 @@ export function prepareSectionSavePayload(opts: { let rawSectionValue: unknown; if (level === "camera" && cameraName) { if (profileInfo.isProfile) { - const baseValue = get( - config.cameras?.[cameraName], + const baseValue = getBaseCameraSectionValue( + config, + cameraName, profileInfo.actualSection, ); const profileOverrides = get(config.cameras?.[cameraName], sectionPath); @@ -523,7 +543,12 @@ export function prepareSectionSavePayload(opts: { rawSectionValue = baseValue; } } else { - rawSectionValue = get(config.cameras?.[cameraName], sectionPath); + // Use base (pre-profile) value so the diff matches what the form shows + rawSectionValue = getBaseCameraSectionValue( + config, + cameraName, + sectionPath, + ); } } else { rawSectionValue = get(config, sectionPath); From d41d328a9b43ebfd8f1bc3dd8678153f1bff4995 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:43:45 -0500 Subject: [PATCH 59/64] use rasterized_mask as field makes it easier to exclude from the schema with exclude=True prevents leaking of the field when using model_dump for profiles --- frigate/config/config.py | 27 ++------------------------- frigate/util/config.py | 6 ++---- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/frigate/config/config.py b/frigate/config/config.py index a57bd42ff..ea21fa831 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -12,7 +12,6 @@ from pydantic import ( Field, TypeAdapter, ValidationInfo, - field_serializer, field_validator, model_validator, ) @@ -98,8 +97,7 @@ stream_info_retriever = StreamInfoRetriever() class RuntimeMotionConfig(MotionConfig): """Runtime version of MotionConfig with rasterized masks.""" - # The rasterized numpy mask (combination of all enabled masks) - rasterized_mask: np.ndarray = None + rasterized_mask: np.ndarray = Field(default=None, exclude=True) def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) @@ -145,24 +143,13 @@ class RuntimeMotionConfig(MotionConfig): empty_mask[:] = 255 self.rasterized_mask = empty_mask - def dict(self, **kwargs): - ret = super().model_dump(**kwargs) - if "rasterized_mask" in ret: - ret.pop("rasterized_mask") - return ret - - @field_serializer("rasterized_mask", when_used="json") - def serialize_rasterized_mask(self, value: Any, info): - return None - model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") class RuntimeFilterConfig(FilterConfig): """Runtime version of FilterConfig with rasterized masks.""" - # The rasterized numpy mask (combination of all enabled masks) - rasterized_mask: Optional[np.ndarray] = None + rasterized_mask: Optional[np.ndarray] = Field(default=None, exclude=True) def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) @@ -226,16 +213,6 @@ class RuntimeFilterConfig(FilterConfig): else: self.rasterized_mask = None - def dict(self, **kwargs): - ret = super().model_dump(**kwargs) - if "rasterized_mask" in ret: - ret.pop("rasterized_mask") - return ret - - @field_serializer("rasterized_mask", when_used="json") - def serialize_rasterized_mask(self, value: Any, info): - return None - model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") diff --git a/frigate/util/config.py b/frigate/util/config.py index 238671563..47b10d2de 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -717,7 +717,7 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[ if section == "motion": merged = deep_merge( - current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}), + current.model_dump(exclude_unset=True), update, override=True, ) @@ -727,9 +727,7 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[ elif section == "objects": merged = deep_merge( - current.model_dump( - exclude={"filters": {"__all__": {"rasterized_mask"}}} - ), + current.model_dump(), update, override=True, ) From 3a08b3d54b71d575416cb693bee88fc273e15423 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:14:58 -0500 Subject: [PATCH 60/64] fix zones - Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field - Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view - Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation) - Inherit base zone color and generate contours for profile zone overrides in profile manager --- frigate/config/profile_manager.py | 48 +++++++++++++++++--- web/src/views/settings/MasksAndZonesView.tsx | 38 +++++++++------- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 36c299989..37dc1c909 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -29,6 +29,7 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "record": CameraConfigUpdateEnum.record, "review": CameraConfigUpdateEnum.review, "snapshots": CameraConfigUpdateEnum.snapshots, + "zones": CameraConfigUpdateEnum.zones, } PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" @@ -60,14 +61,36 @@ class ProfileManager: 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: + section_value = getattr(cam_config, section, None) + if section_value is None: + continue + + if section == "zones": + # zones is a dict of ZoneConfig models + self._base_configs[cam_name][section] = { + name: zone.model_dump() + for name, zone in section_value.items() + } + self._base_api_configs[cam_name][section] = { + name: { + **zone.model_dump( + mode="json", + warnings="none", + exclude_none=True, + ), + "color": zone.color, + } + for name, zone in section_value.items() + } + else: self._base_configs[cam_name][section] = ( - section_config.model_dump() + section_value.model_dump() ) self._base_api_configs[cam_name][section] = ( - section_config.model_dump( - mode="json", warnings="none", exclude_none=True + section_value.model_dump( + mode="json", + warnings="none", + exclude_none=True, ) ) @@ -154,9 +177,11 @@ class ProfileManager: cam_config.zones = copy.deepcopy(base_zones) changed.setdefault(cam_name, set()).add("zones") - # Restore section configs + # Restore section configs (zones handled above) base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: + if section == "zones": + continue base_data = base.get(section) if base_data is None: continue @@ -190,12 +215,23 @@ class ProfileManager: base_zones = self._base_zones.get(cam_name, {}) merged_zones = copy.deepcopy(base_zones) merged_zones.update(profile.zones) + # Profile zone objects are parsed without colors or contours + # (those are set during CameraConfig init / post-validation). + # Inherit the base zone's color when available, and ensure + # every zone has a valid contour for rendering. + for name, zone in merged_zones.items(): + if zone.contour.size == 0: + zone.generate_contour(cam_config.frame_shape) + if zone.color == (0, 0, 0) and name in base_zones: + zone._color = base_zones[name].color 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: + if section == "zones": + continue profile_section = getattr(profile, section, None) if profile_section is None: continue diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 1b7614a8e..8291a6a82 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -249,17 +249,26 @@ export default function MasksAndZonesView({ ? cameraConfig.profiles?.[currentEditingProfile] : undefined; + // When a profile is active, the top-level sections contain + // effective (profile-merged) values. Use base_config for the + // original base values so the "Base Config" view is accurate and + // the base layer for profile merging is correct. + const baseMotion = (cameraConfig.base_config?.motion ?? + cameraConfig.motion) as typeof cameraConfig.motion; + const baseObjects = (cameraConfig.base_config?.objects ?? + cameraConfig.objects) as typeof cameraConfig.objects; + const baseZones = (cameraConfig.base_config?.zones ?? + cameraConfig.zones) as typeof cameraConfig.zones; + // Build base zone names set for source tracking - const baseZoneNames = new Set(Object.keys(cameraConfig.zones)); + const baseZoneNames = new Set(Object.keys(baseZones)); const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {})); - const baseMotionMaskNames = new Set( - Object.keys(cameraConfig.motion.mask || {}), - ); + const baseMotionMaskNames = new Set(Object.keys(baseMotion.mask || {})); const profileMotionMaskNames = new Set( Object.keys(profileData?.motion?.mask ?? {}), ); const baseGlobalObjectMaskNames = new Set( - Object.keys(cameraConfig.objects.mask || {}), + Object.keys(baseObjects.mask || {}), ); const profileGlobalObjectMaskNames = new Set( Object.keys(profileData?.objects?.mask ?? {}), @@ -274,7 +283,7 @@ export default function MasksAndZonesView({ } >(); - for (const [name, zoneData] of Object.entries(cameraConfig.zones)) { + for (const [name, zoneData] of Object.entries(baseZones)) { if (currentEditingProfile && profileZoneNames.has(name)) { // Profile overrides this base zone mergedZones.set(name, { @@ -302,7 +311,8 @@ export default function MasksAndZonesView({ const zones: Polygon[] = []; for (const [name, { data: zoneData, source }] of mergedZones) { const isBase = source === "base" && !!currentEditingProfile; - const baseColor = zoneData.color ?? [128, 128, 0]; + const baseColor = + zoneData.color ?? baseZones[name]?.color ?? [128, 128, 0]; zones.push({ type: "zone" as PolygonType, typeIndex: zoneIndex, @@ -339,9 +349,7 @@ export default function MasksAndZonesView({ } >(); - for (const [maskId, maskData] of Object.entries( - cameraConfig.motion.mask || {}, - )) { + for (const [maskId, maskData] of Object.entries(baseMotion.mask || {})) { if (currentEditingProfile && profileMotionMaskNames.has(maskId)) { mergedMotionMasks.set(maskId, { data: profileData!.motion!.mask![maskId], @@ -406,9 +414,7 @@ export default function MasksAndZonesView({ } >(); - for (const [maskId, maskData] of Object.entries( - cameraConfig.objects.mask || {}, - )) { + for (const [maskId, maskData] of Object.entries(baseObjects.mask || {})) { if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) { mergedGlobalObjectMasks.set(maskId, { data: profileData!.objects!.mask![maskId], @@ -472,7 +478,7 @@ export default function MasksAndZonesView({ // Build per-object filter mask names for profile tracking const baseFilterMaskNames = new Set(); for (const [, filterConfig] of Object.entries( - cameraConfig.objects.filters, + baseObjects.filters || {}, )) { for (const maskId of Object.keys(filterConfig.mask || {})) { if (!maskId.startsWith("global_")) { @@ -495,9 +501,7 @@ export default function MasksAndZonesView({ } // Per-object filter masks (base) - const objectMasks: Polygon[] = Object.entries( - cameraConfig.objects.filters, - ) + const objectMasks: Polygon[] = Object.entries(baseObjects.filters || {}) .filter( ([, filterConfig]) => filterConfig.mask && Object.keys(filterConfig.mask).length > 0, From b1081d7217e20172e694ec975a67ceb51cfb4d12 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:17:17 -0500 Subject: [PATCH 61/64] formatting --- web/src/views/settings/MasksAndZonesView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 8291a6a82..6bb2fde9a 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -311,8 +311,8 @@ export default function MasksAndZonesView({ const zones: Polygon[] = []; for (const [name, { data: zoneData, source }] of mergedZones) { const isBase = source === "base" && !!currentEditingProfile; - const baseColor = - zoneData.color ?? baseZones[name]?.color ?? [128, 128, 0]; + const baseColor = zoneData.color ?? + baseZones[name]?.color ?? [128, 128, 0]; zones.push({ type: "zone" as PolygonType, typeIndex: zoneIndex, From eeeec2db86e73c00f141e04314ec328e12653fe8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:35:26 -0500 Subject: [PATCH 62/64] don't require restart for camera enabled change for profiles --- web/src/views/settings/CameraManagementView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 8cd13c33b..86e9b7a31 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -435,7 +435,10 @@ function ProfileCameraEnableSection({ }, }; - await axios.put("config/set", { config_data: configData }); + await axios.put("config/set", { + requires_restart: 0, + config_data: configData, + }); await onConfigChanged(); setLocalOverrides((prev) => ({ From 56679a041b587916b37f9cbf4961ad2741d99f08 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:41:30 -0500 Subject: [PATCH 63/64] publish camera state when changing profiles --- frigate/app.py | 4 +++- frigate/config/profile_manager.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frigate/app.py b/frigate/app.py index 9d60d2a08..fef37813a 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -352,7 +352,9 @@ class FrigateApp: ) def init_profile_manager(self) -> None: - self.profile_manager = ProfileManager(self.config, self.inter_config_updater) + self.profile_manager = ProfileManager( + self.config, self.inter_config_updater, self.dispatcher + ) self.dispatcher.profile_manager = self.profile_manager persisted = ProfileManager.load_persisted_profile() diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 37dc1c909..f60cd9a03 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -42,11 +42,13 @@ class ProfileManager: self, config, config_updater: CameraConfigUpdatePublisher, + dispatcher=None, ): from frigate.config.config import FrigateConfig self.config: FrigateConfig = config self.config_updater = config_updater + self.dispatcher = dispatcher self._base_configs: dict[str, dict[str, dict]] = {} self._base_api_configs: dict[str, dict[str, dict]] = {} self._base_enabled: dict[str, bool] = {} @@ -266,6 +268,12 @@ class ProfileManager: ), cam_config.enabled, ) + if self.dispatcher is not None: + self.dispatcher.publish( + f"{cam_name}/enabled/state", + "ON" if cam_config.enabled else "OFF", + retain=True, + ) continue if section == "zones": From 78bc11d7e0a01eeef1363778c88ccbaeae7052dc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:46:04 -0500 Subject: [PATCH 64/64] formatting --- frigate/api/app.py | 6 ++---- frigate/config/profile_manager.py | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index d7abb8e47..cb408b447 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -177,10 +177,8 @@ def config(request: Request): config_obj.active_profile is not None and request.app.profile_manager is not None ): - base_sections = ( - request.app.profile_manager.get_base_configs_for_api( - camera_name - ) + base_sections = request.app.profile_manager.get_base_configs_for_api( + camera_name ) if base_sections: camera_dict["base_config"] = base_sections diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index f60cd9a03..bb122cc1a 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -70,8 +70,7 @@ class ProfileManager: if section == "zones": # zones is a dict of ZoneConfig models self._base_configs[cam_name][section] = { - name: zone.model_dump() - for name, zone in section_value.items() + name: zone.model_dump() for name, zone in section_value.items() } self._base_api_configs[cam_name][section] = { name: { @@ -85,9 +84,7 @@ class ProfileManager: for name, zone in section_value.items() } else: - self._base_configs[cam_name][section] = ( - section_value.model_dump() - ) + self._base_configs[cam_name][section] = section_value.model_dump() self._base_api_configs[cam_name][section] = ( section_value.model_dump( mode="json",