diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md new file mode 100644 index 000000000..ef0778e18 --- /dev/null +++ b/docs/docs/configuration/profiles.md @@ -0,0 +1,188 @@ +--- +id: profiles +title: Profiles +--- + +Profiles allow you to define named sets of camera configuration overrides that can be activated and deactivated at runtime without restarting Frigate. This is useful for scenarios like switching between "Home" and "Away" modes, daytime and nighttime configurations, or any situation where you want to quickly change how multiple cameras behave. + +## How Profiles Work + +Profiles operate as a two-level system: + +1. **Profile definitions** are declared at the top level of your config under `profiles`. Each definition has a machine name (the key) and a `friendly_name` for display in the UI. +2. **Camera profile overrides** are declared under each camera's `profiles` section, keyed by the profile name. Only the settings you want to change need to be specified — everything else is inherited from the camera's base configuration. + +When a profile is activated, Frigate merges each camera's profile overrides on top of its base config. When the profile is deactivated, all cameras revert to their original settings. Only one profile can be active at a time. + +:::info + +Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.active_profile` file). + +::: + +## Configuration + +The easiest way to define profiles is to use the Frigate UI. Profiles can also be configured manually in your configuration file. + +### 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** — 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 + +First, define your profiles at the top level of your Frigate config. Every profile name referenced by a camera must be defined here. + +```yaml +profiles: + home: + friendly_name: Home + away: + friendly_name: Away + night: + friendly_name: Night Mode +``` + +### Camera Profile Overrides + +Under each camera, add a `profiles` section with overrides for each profile. You only need to include the settings you want to change. + +```yaml +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://camera:554/stream + roles: + - detect + - record + detect: + enabled: true + record: + enabled: true + profiles: + away: + detect: + enabled: true + notifications: + enabled: true + objects: + track: + - person + - car + - package + review: + alerts: + labels: + - person + - car + - package + home: + detect: + enabled: true + notifications: + enabled: false + objects: + track: + - person +``` + +### Supported Override Sections + +The following camera configuration sections can be overridden in a profile: + +| Section | Description | +| ------------------ | ----------------------------------------- | +| `enabled` | Enable or disable the camera entirely | +| `audio` | Audio detection settings | +| `birdseye` | Birdseye view settings | +| `detect` | Object detection settings | +| `face_recognition` | Face recognition settings | +| `lpr` | License plate recognition settings | +| `motion` | Motion detection settings | +| `notifications` | Notification settings | +| `objects` | Object tracking and filter settings | +| `record` | Recording settings | +| `review` | Review alert and detection settings | +| `snapshots` | Snapshot settings | +| `zones` | Zone definitions (merged with base zones) | + +:::note + +Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For zones, profile zones are merged with the camera's base zones — any zone defined in the profile will override or add to the base zones. + +::: + +## Activating Profiles + +Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect. + +## Example: Home / Away Setup + +A common use case is having different detection and notification settings based on whether you are home or away. + +```yaml +profiles: + home: + friendly_name: Home + away: + friendly_name: Away + +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://camera:554/stream + roles: + - detect + - record + detect: + enabled: true + record: + enabled: true + notifications: + enabled: false + profiles: + away: + notifications: + enabled: true + review: + alerts: + labels: + - person + - car + home: + notifications: + enabled: false + + indoor_cam: + ffmpeg: + inputs: + - path: rtsp://camera:554/indoor + roles: + - detect + - record + detect: + enabled: false + record: + enabled: false + profiles: + away: + enabled: true + detect: + enabled: true + record: + enabled: true + home: + enabled: false +``` + +In this example: + +- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording. +- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy. +- **No profile active**: All cameras use their base configuration values. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index cac508195..1b6f8758a 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -1026,6 +1026,49 @@ cameras: actions: - notification + # Optional: Named config profiles with partial overrides that can be activated at runtime. + # NOTE: Profile names must be defined in the top-level 'profiles' section. + profiles: + # Required: name of the profile (must match a top-level profile definition) + away: + # Optional: Enable or disable the camera when this profile is active (default: not set, inherits base) + enabled: true + # Optional: Override audio settings + audio: + enabled: true + # Optional: Override birdseye settings + # birdseye: + # Optional: Override detect settings + detect: + enabled: true + # Optional: Override face_recognition settings + # face_recognition: + # Optional: Override lpr settings + # lpr: + # Optional: Override motion settings + # motion: + # Optional: Override notification settings + notifications: + enabled: true + # Optional: Override objects settings + objects: + track: + - person + - car + # Optional: Override record settings + record: + enabled: true + # Optional: Override review settings + review: + alerts: + labels: + - person + - car + # Optional: Override snapshot settings + # snapshots: + # Optional: Override or add zones (merged with base zones) + # zones: + # Optional ui: # Optional: Set a timezone to use in the UI (default: use browser local time) @@ -1092,4 +1135,14 @@ camera_groups: icon: LuCar # Required: index of this group order: 0 + +# Optional: Profile definitions for named config overrides +# NOTE: Profile names defined here can be referenced in camera profiles sections +profiles: + # Required: name of the profile (machine name used internally) + home: + # Required: display name shown in the UI + friendly_name: Home + away: + friendly_name: Away ``` diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 0ef6774a8..49d1a20e8 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -11,7 +11,8 @@ These are the MQTT messages generated by Frigate. The default topic_prefix is `f Designed to be used as an availability topic with Home Assistant. Possible message are: "online": published when Frigate is running (on startup) -"offline": published after Frigate has stopped +"stopped": published when Frigate is stopped normally +"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message) ### `frigate/restart` @@ -275,6 +276,14 @@ Same data available at `/api/stats` published at a configurable interval. Returns data about each camera, its current features, and if it is detecting motion, objects, etc. Can be triggered by publising to `frigate/onConnect` +### `frigate/profile/set` + +Topic to activate or deactivate a [profile](/configuration/profiles). Publish a profile name to activate it, or `none` to deactivate the current profile. + +### `frigate/profile/state` + +Topic with the currently active profile name. Published value is the profile name or `none` if no profile is active. This topic is retained. + ### `frigate/notifications/set` Topic to turn notifications on and off. Expected values are `ON` and `OFF`. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index a4c1bca9d..2d1085af1 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -94,6 +94,7 @@ const sidebars: SidebarsConfig = { "Extra Configuration": [ "configuration/authentication", "configuration/notifications", + "configuration/profiles", "configuration/ffmpeg_presets", "configuration/pwa", "configuration/tls", diff --git a/frigate/api/app.py b/frigate/api/app.py index 4c0d3ead2..379cc7278 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -31,7 +31,10 @@ 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, +) from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig from frigate.config.camera.updater import ( @@ -154,6 +157,31 @@ 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 + ) + ) + + # 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 @@ -201,6 +229,20 @@ 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.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())]) def ffmpeg_presets(): """Return available ffmpeg preset keys for config UI usage.""" @@ -589,6 +631,9 @@ def config_set(request: Request, body: AppConfigSetBody): request.app.frigate_config = config request.app.genai_manager.update_config(config) + if request.app.profile_manager is not None: + 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/api/camera.py b/frigate/api/camera.py index cb69a56e3..c99126e64 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -23,6 +23,7 @@ from frigate.api.auth import ( require_camera_access, require_role, ) +from frigate.api.defs.request.app_body import CameraSetBody from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig from frigate.config.camera.updater import ( @@ -1155,3 +1156,76 @@ async def delete_camera( }, status_code=200, ) + + +_SUB_COMMAND_FEATURES = {"motion_mask", "object_mask", "zone"} + + +@router.put( + "/camera/{camera_name}/set/{feature}", + dependencies=[Depends(require_role(["admin"]))], +) +@router.put( + "/camera/{camera_name}/set/{feature}/{sub_command}", + dependencies=[Depends(require_role(["admin"]))], +) +def camera_set( + request: Request, + camera_name: str, + feature: str, + body: CameraSetBody, + sub_command: str | None = None, +): + """Set a camera feature state. Use camera_name='*' to target all cameras.""" + dispatcher = request.app.dispatcher + frigate_config: FrigateConfig = request.app.frigate_config + + if feature == "profile": + if camera_name != "*": + return JSONResponse( + content={ + "success": False, + "message": "Profile feature requires camera_name='*'", + }, + status_code=400, + ) + dispatcher._receive("profile/set", body.value) + return JSONResponse(content={"success": True}) + + if feature not in dispatcher._camera_settings_handlers: + return JSONResponse( + content={"success": False, "message": f"Unknown feature: {feature}"}, + status_code=400, + ) + + if sub_command and feature not in _SUB_COMMAND_FEATURES: + return JSONResponse( + content={ + "success": False, + "message": f"Feature '{feature}' does not support sub-commands", + }, + status_code=400, + ) + + if camera_name == "*": + cameras = list(frigate_config.cameras.keys()) + elif camera_name not in frigate_config.cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Camera '{camera_name}' not found", + }, + status_code=404, + ) + else: + cameras = [camera_name] + + for cam in cameras: + topic = ( + f"{cam}/{feature}/{sub_command}/set" + if sub_command + else f"{cam}/{feature}/set" + ) + dispatcher._receive(topic, body.value) + + return JSONResponse(content={"success": True}) diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 7957ab7af..b4eee6a9d 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -140,6 +140,62 @@ def get_tool_definitions() -> List[Dict[str, Any]]: "required": [], }, }, + { + "type": "function", + "function": { + "name": "set_camera_state", + "description": ( + "Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). " + "Use camera='*' to apply to all cameras at once. " + "Only call this tool when the user explicitly asks to change a camera setting. " + "Requires admin privileges." + ), + "parameters": { + "type": "object", + "properties": { + "camera": { + "type": "string", + "description": "Camera name to target, or '*' to target all cameras.", + }, + "feature": { + "type": "string", + "enum": [ + "detect", + "record", + "snapshots", + "audio", + "motion", + "enabled", + "birdseye", + "birdseye_mode", + "improve_contrast", + "ptz_autotracker", + "motion_contour_area", + "motion_threshold", + "notifications", + "audio_transcription", + "review_alerts", + "review_detections", + "object_descriptions", + "review_descriptions", + "profile", + ], + "description": ( + "The feature to change. Most features accept ON or OFF. " + "birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. " + "motion_contour_area and motion_threshold accept a number. " + "profile accepts a profile name or 'none' to deactivate (requires camera='*')." + ), + }, + "value": { + "type": "string", + "description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.", + }, + }, + "required": ["camera", "feature", "value"], + }, + }, + }, { "type": "function", "function": { @@ -255,6 +311,7 @@ async def _execute_search_objects( description="Execute a tool function call from an LLM.", ) async def execute_tool( + request: Request, body: ToolExecuteRequest = Body(...), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), ) -> JSONResponse: @@ -272,6 +329,12 @@ async def execute_tool( if tool_name == "search_objects": return await _execute_search_objects(arguments, allowed_cameras) + if tool_name == "set_camera_state": + result = await _execute_set_camera_state(request, arguments) + return JSONResponse( + content=result, status_code=200 if result.get("success") else 400 + ) + return JSONResponse( content={ "success": False, @@ -374,6 +437,46 @@ async def _get_live_frame_image_url( return None +async def _execute_set_camera_state( + request: Request, + arguments: Dict[str, Any], +) -> Dict[str, Any]: + role = request.headers.get("remote-role", "") + if "admin" not in [r.strip() for r in role.split(",")]: + return {"error": "Admin privileges required to change camera settings."} + + camera = arguments.get("camera", "").strip() + feature = arguments.get("feature", "").strip() + value = arguments.get("value", "").strip() + + if not camera or not feature or not value: + return {"error": "camera, feature, and value are all required."} + + dispatcher = request.app.dispatcher + frigate_config = request.app.frigate_config + + if feature == "profile": + if camera != "*": + return {"error": "Profile feature requires camera='*'."} + dispatcher._receive("profile/set", value) + return {"success": True, "camera": camera, "feature": feature, "value": value} + + if feature not in dispatcher._camera_settings_handlers: + return {"error": f"Unknown feature: {feature}"} + + if camera == "*": + cameras = list(frigate_config.cameras.keys()) + elif camera not in frigate_config.cameras: + return {"error": f"Camera '{camera}' not found."} + else: + cameras = [camera] + + for cam in cameras: + dispatcher._receive(f"{cam}/{feature}/set", value) + + return {"success": True, "camera": camera, "feature": feature, "value": value} + + async def _execute_tool_internal( tool_name: str, arguments: Dict[str, Any], @@ -398,6 +501,8 @@ async def _execute_tool_internal( except (json.JSONDecodeError, AttributeError) as e: logger.warning(f"Failed to extract tool result: {e}") return {"error": "Failed to parse tool result"} + elif tool_name == "set_camera_state": + return await _execute_set_camera_state(request, arguments) elif tool_name == "get_live_context": camera = arguments.get("camera") if not camera: diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 3d2ab5961..45392a138 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -30,6 +30,10 @@ class AppPutRoleBody(BaseModel): role: str +class CameraSetBody(BaseModel): + value: str = Field(..., description="The value to set for the feature") + + class MediaSyncBody(BaseModel): dry_run: bool = Field( default=True, description="If True, only report orphans without deleting them" diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 0a731bcee..f201ab713 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -29,11 +29,13 @@ from frigate.api import ( review, ) from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default +from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, ) from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.config.profile_manager import ProfileManager from frigate.debug_replay import DebugReplayManager from frigate.embeddings import EmbeddingsContext from frigate.genai import GenAIClientManager @@ -69,6 +71,8 @@ def create_fastapi_app( event_metadata_updater: EventMetadataPublisher, config_publisher: CameraConfigUpdatePublisher, replay_manager: DebugReplayManager, + dispatcher: Optional[Dispatcher] = None, + profile_manager: Optional[ProfileManager] = None, enforce_default_admin: bool = True, ): logger.info("Starting FastAPI app") @@ -151,6 +155,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 821f34e61..5cdc1b1c1 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, @@ -118,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: @@ -351,6 +353,19 @@ class FrigateApp: comms, ) + def init_profile_manager(self) -> None: + self.profile_manager = ProfileManager( + self.config, self.inter_config_updater, self.dispatcher + ) + 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: @@ -559,6 +574,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() @@ -588,6 +604,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, diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 490a829dc..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, @@ -91,7 +92,9 @@ class Dispatcher: } self._global_settings_handlers: dict[str, Callable] = { "notifications": self._on_global_notification_command, + "profile": self._on_profile_command, } + self.profile_manager: Optional[ProfileManager] = None for comm in self.comms: comm.subscribe(self._receive) @@ -298,6 +301,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 +564,22 @@ 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..89a986e08 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -38,6 +38,7 @@ class MqttClient(Communicator): ) def stop(self) -> None: + self.publish("available", "stopped", retain=True) self.client.disconnect() def _set_initial_topics(self) -> None: @@ -163,6 +164,11 @@ class MqttClient(Communicator): retain=True, ) + self.publish( + "profile/state", + self.config.active_profile or "none", + retain=True, + ) self.publish("available", "online", retain=True) def on_mqtt_command( @@ -289,6 +295,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 ) diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index ffcb60122..1e1d31349 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 @@ -198,6 +199,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", diff --git a/frigate/config/camera/profile.py b/frigate/config/camera/profile.py new file mode 100644 index 000000000..6a52a9ad6 --- /dev/null +++ b/frigate/config/camera/profile.py @@ -0,0 +1,44 @@ +"""Camera profile configuration for named config overrides.""" + +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 +from .motion import MotionConfig +from .notification import NotificationConfig +from .objects import ObjectConfig +from .record import RecordConfig +from .review import ReviewConfig +from .snapshots import SnapshotsConfig +from .zone import ZoneConfig + +__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. + """ + + enabled: Optional[bool] = None + 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 + record: Optional[RecordConfig] = None + review: Optional[ReviewConfig] = None + snapshots: Optional[SnapshotsConfig] = None + zones: Optional[dict[str, ZoneConfig]] = None diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 0c49ec465..a55f355fb 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -18,6 +18,7 @@ class CameraConfigUpdateEnum(str, Enum): detect = "detect" enabled = "enabled" ffmpeg = "ffmpeg" + live = "live" motion = "motion" # includes motion and motion masks notifications = "notifications" objects = "objects" @@ -27,6 +28,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" @@ -105,6 +108,8 @@ class CameraConfigUpdateSubscriber: config.enabled = updated_config elif update_type == CameraConfigUpdateEnum.object_genai: config.objects.genai = updated_config + elif update_type == CameraConfigUpdateEnum.live: + config.live = updated_config elif update_type == CameraConfigUpdateEnum.motion: config.motion = updated_config elif update_type == CameraConfigUpdateEnum.notifications: @@ -119,6 +124,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/config.py b/frigate/config/config.py index a4b05ec45..24b80eb9b 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, ) @@ -68,6 +67,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 @@ -97,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)) @@ -144,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)) @@ -225,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") @@ -561,6 +539,19 @@ 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", + description="Currently active profile name. Runtime-only, not persisted in YAML.", + exclude=True, + ) + _plus_api: PlusApi @property @@ -910,6 +901,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 new file mode 100644 index 000000000..bb122cc1a --- /dev/null +++ b/frigate/config/profile_manager.py @@ -0,0 +1,334 @@ +"""Profile manager for activating/deactivating named config profiles.""" + +import copy +import logging +from pathlib import Path +from typing import Optional + +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + 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 + +logger = logging.getLogger(__name__) + +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, + "record": CameraConfigUpdateEnum.record, + "review": CameraConfigUpdateEnum.review, + "snapshots": CameraConfigUpdateEnum.snapshots, + "zones": CameraConfigUpdateEnum.zones, +} + +PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" + + +class ProfileManager: + """Manages profile activation, persistence, and config application.""" + + def __init__( + 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] = {} + 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, 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_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_value.model_dump() + self._base_api_configs[cam_name][section] = ( + section_value.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. + + 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_api_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: + 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 + 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. + + 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: + 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]] = {} + + # Reset all cameras to base config + self._reset_to_base(changed) + + # Apply new profile overrides if activating + if profile_name is not None: + 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( + "Profile %s", + f"'{profile_name}' activated" if profile_name else "deactivated", + ) + return 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(): + # 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 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: + cam_config.zones = copy.deepcopy(base_zones) + changed.setdefault(cam_name, set()).add("zones") + + # 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 + 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, + ) + else: + changed.setdefault(cam_name, set()).add(section) + + 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.""" + for cam_name, cam_config in self.config.cameras.items(): + profile = cam_config.profiles.get(profile_name) + 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") + + # 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) + # 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 + + 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}" + + changed.setdefault(cam_name, set()).add(section) + + return None + + 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 in sections: + if section == "enabled": + self.config_updater.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.enabled, cam_name + ), + 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": + 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 + 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_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 [ + {"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.""" + return { + "profiles": self.get_available_profiles(), + "active_profile": self.config.active_profile, + } diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 0a2754468..0d1223219 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -521,7 +521,7 @@ def run_analysis( for i, verified_label in enumerate(final_data["data"]["verified_objects"]): object_type = verified_label.replace("-verified", "").replace("_", " ") name = titlecase(sub_labels_list[i].replace("_", " ")) - unified_objects.append(f"{name} ({object_type})") + unified_objects.append(f"{name} ← {object_type}") for label in objects_list: if "-verified" in label: diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 3e939d28d..f799931ec 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -106,7 +106,7 @@ When forming your description: ## Response Field Guidelines Respond with a JSON object matching the provided schema. Field-specific guidance: -- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. Your description should align with and support the threat level you assign. +- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. Always use subject names from "Objects in Scene" — do not replace named subjects with generic terms like "a person" or "the individual". Your description should align with and support the threat level you assign. - `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). Always include subject names from "Objects in Scene" — do not replace named subjects with generic terms. No editorial qualifiers like "routine" or "suspicious." - `potential_threat_level`: Must be consistent with your scene description and the activity patterns above. {get_concern_prompt()} @@ -120,9 +120,7 @@ Respond with a JSON object matching the provided schema. Field-specific guidance ## Objects in Scene -Each line represents a detection state, not necessarily unique individuals. Parentheses indicate object type or category, use only the name/label in your response, not the parentheses. - -**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.** +Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times. **Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.** {get_objects_list()} @@ -188,8 +186,8 @@ Each line represents a detection state, not necessarily unique individuals. Pare if metadata.confidence > 1.0: metadata.confidence = min(metadata.confidence / 100.0, 1.0) - # If any verified objects (contain parentheses with name), set to 0 - if any("(" in obj for obj in review_data["unified_objects"]): + # If any verified objects (contain ← separator), set to 0 + if any("←" in obj for obj in review_data["unified_objects"]): metadata.potential_threat_level = 0 metadata.time = review_data["start"] diff --git a/frigate/genai/gemini.py b/frigate/genai/gemini.py index 9e01192dc..f32d37e80 100644 --- a/frigate/genai/gemini.py +++ b/frigate/genai/gemini.py @@ -397,13 +397,13 @@ class GeminiClient(GenAIClient): tool_calls_by_index: dict[int, dict[str, Any]] = {} finish_reason = "stop" - response = self.provider.models.generate_content_stream( + stream = await self.provider.aio.models.generate_content_stream( model=self.genai_config.model, contents=gemini_messages, config=types.GenerateContentConfig(**config_params), ) - async for chunk in response: + async for chunk in stream: if not chunk or not chunk.candidates: continue diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index ac698b3b6..48ea9747c 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -64,7 +64,12 @@ class LlamaCppClient(GenAIClient): return None try: - content = [] + content = [ + { + "type": "text", + "text": prompt, + } + ] for image in images: encoded_image = base64.b64encode(image).decode("utf-8") content.append( @@ -75,12 +80,6 @@ class LlamaCppClient(GenAIClient): }, } ) - content.append( - { - "type": "text", - "text": prompt, - } - ) # Build request payload with llama.cpp native options payload = { diff --git a/frigate/genai/ollama.py b/frigate/genai/ollama.py index 90bf3f05e..74c5b7a89 100644 --- a/frigate/genai/ollama.py +++ b/frigate/genai/ollama.py @@ -53,6 +53,39 @@ class OllamaClient(GenAIClient): logger.warning("Error initializing Ollama: %s", str(e)) return None + @staticmethod + def _clean_schema_for_ollama(schema: dict) -> dict: + """Strip Pydantic metadata from a JSON schema for Ollama compatibility. + + Ollama's grammar-based constrained generation works best with minimal + schemas. Pydantic adds title/description/constraint fields that can + cause the grammar generator to silently skip required fields. + """ + STRIP_KEYS = { + "title", + "description", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + } + result = {} + for key, value in schema.items(): + if key in STRIP_KEYS: + continue + if isinstance(value, dict): + result[key] = OllamaClient._clean_schema_for_ollama(value) + elif isinstance(value, list): + result[key] = [ + OllamaClient._clean_schema_for_ollama(item) + if isinstance(item, dict) + else item + for item in value + ] + else: + result[key] = value + return result + def _send( self, prompt: str, @@ -73,7 +106,7 @@ class OllamaClient(GenAIClient): if response_format and response_format.get("type") == "json_schema": schema = response_format.get("json_schema", {}).get("schema") if schema: - ollama_options["format"] = schema + ollama_options["format"] = self._clean_schema_for_ollama(schema) result = self.provider.generate( self.genai_config.model, prompt, 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/frigate/test/test_profiles.py b/frigate/test/test_profiles.py new file mode 100644 index 000000000..b77d3ebb6 --- /dev/null +++ b/frigate/test/test_profiles.py @@ -0,0 +1,629 @@ +"""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 import ProfileDefinitionConfig +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_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_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}) + 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"}, + "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": { + "armed": { + "detect": {"fps": "invalid"}, + }, + }, + }, + }, + } + 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.""" + + def setUp(self): + self.base_config = { + "mqtt": {"host": "mqtt"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + "disarmed": {"friendly_name": "Disarmed"}, + }, + "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"}, + "profiles": { + "armed": {"friendly_name": "Armed"}, + "disarmed": {"friendly_name": "Disarmed"}, + }, + "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 come from top-level profile definitions.""" + profiles = self.manager.get_available_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 defined" 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_activate_profile_disables_camera(self, mock_persist): + """Profile with enabled=false disables the camera.""" + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + self.config.cameras["front"].profiles["away"] = CameraProfileConfig( + enabled=False + ) + 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.""" + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + 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_activate_profile_adds_zone(self, mock_persist): + """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.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.zone import ZoneConfig + + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + 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.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( + 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 frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, + ) + + self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away") + 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.""" + self.manager.activate_profile("armed") + assert self.mock_updater.publish_update.called + + def test_get_profile_info(self): + """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 + names = [p["name"] for p in info["profiles"]] + 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.""" + + 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: + 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: + 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() 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, ) 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/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index afbf27f82..f93439244 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -8,12 +8,19 @@ "masksAndZones": "Mask and Zone Editor - Frigate", "motionTuner": "Motion Tuner - Frigate", "object": "Debug - Frigate", - "general": "Profile Settings - Frigate", + "general": "UI Settings - Frigate", "globalConfig": "Global Configuration - Frigate", "cameraConfig": "Camera Configuration - Frigate", "frigatePlus": "Frigate+ Settings - Frigate", "notifications": "Notification Settings - Frigate", - "maintenance": "Maintenance - Frigate" + "maintenance": "Maintenance - Frigate", + "profiles": "Profiles - Frigate" + }, + "button": { + "overriddenGlobal": "Overridden (Global)", + "overriddenGlobalTooltip": "This camera overrides global configuration settings in this section", + "overriddenBaseConfig": "Overridden (Base Config)", + "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section" }, "menu": { "general": "General", @@ -22,7 +29,8 @@ "integrations": "Integrations", "cameras": "Camera configuration", "ui": "UI", - "profileSettings": "Profile settings", + "uiSettings": "UI settings", + "profiles": "Profiles", "globalDetect": "Object detection", "globalRecording": "Recording", "globalSnapshots": "Snapshots", @@ -47,6 +55,7 @@ "systemDetectorHardware": "Detector hardware", "systemDetectionModel": "Detection model", "systemMqtt": "MQTT", + "systemGo2rtcStreams": "go2rtc streams", "integrationSemanticSearch": "Semantic search", "integrationGenerativeAi": "Generative AI", "integrationFaceRecognition": "Face recognition", @@ -101,6 +110,9 @@ "global": "Global", "camera": "Camera: {{cameraName}}" }, + "profile": { + "label": "Profile" + }, "field": { "label": "Field" }, @@ -114,7 +126,7 @@ "noCamera": "No Camera" }, "general": { - "title": "Profile Settings", + "title": "UI Settings", "liveDashboard": { "title": "Live Dashboard", "automaticLiveView": { @@ -473,6 +485,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": { @@ -519,6 +539,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." @@ -1294,6 +1316,8 @@ "preset": "Preset", "manual": "Manual arguments", "inherit": "Inherit from camera setting", + "none": "None", + "useGlobalSetting": "Inherit from global setting", "selectPreset": "Select preset", "manualPlaceholder": "Enter FFmpeg arguments" }, @@ -1327,7 +1351,8 @@ "genai": "GenAI", "face_recognition": "Face Recognition", "lpr": "License Plate Recognition", - "birdseye": "Birdseye" + "birdseye": "Birdseye", + "masksAndZones": "Masks / Zones" }, "detect": { "title": "Detection Settings" @@ -1427,8 +1452,90 @@ "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", + "activateFailed": "Failed to set profile", + "deactivated": "Profile deactivated", + "noProfiles": "No profiles defined.", + "noOverrides": "No overrides", + "cameraCount_one": "{{count}} camera", + "cameraCount_other": "{{count}} cameras", + "baseConfig": "Base Config", + "addProfile": "Add Profile", + "newProfile": "New Profile", + "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", + "createSuccess": "Profile '{{profile}}' created", + "removeOverride": "Remove Profile Override", + "deleteSection": "Delete Section Overrides", + "deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?", + "deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}", + "enableSwitch": "Enable Profiles", + "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", "confirmReset": "Confirm Reset", "resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.", - "resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone." + "resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone.", + "go2rtcStreams": { + "title": "go2rtc Streams", + "description": "Manage go2rtc stream configurations for camera restreaming. Each stream has a name and one or more source URLs.", + "addStream": "Add stream", + "addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.", + "addUrl": "Add URL", + "streamName": "Stream name", + "streamNamePlaceholder": "e.g., front_door", + "streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream", + "deleteStream": "Delete stream", + "deleteStreamConfirm": "Are you sure you want to delete the stream \"{{streamName}}\"? Cameras that reference this stream may stop working.", + "noStreams": "No go2rtc streams configured. Add a stream to get started.", + "validation": { + "nameRequired": "Stream name is required", + "nameDuplicate": "A stream with this name already exists", + "nameInvalid": "Stream name can only contain letters, numbers, underscores, and hyphens", + "urlRequired": "At least one URL is required" + }, + "renameStream": "Rename stream", + "renameStreamDesc": "Enter a new name for this stream. Renaming a stream may break cameras or other streams that reference it by name.", + "newStreamName": "New stream name", + "ffmpeg": { + "useFfmpegModule": "Use compatibility mode (ffmpeg)", + "video": "Video", + "audio": "Audio", + "hardware": "Hardware acceleration", + "videoCopy": "Copy", + "videoH264": "Transcode to H.264", + "videoH265": "Transcode to H.265", + "videoExclude": "Exclude", + "audioCopy": "Copy", + "audioAac": "Transcode to AAC", + "audioOpus": "Transcode to Opus", + "audioPcmu": "Transcode to PCM μ-law", + "audioPcma": "Transcode to PCM A-law", + "audioPcm": "Transcode to PCM", + "audioMp3": "Transcode to MP3", + "audioExclude": "Exclude", + "hardwareNone": "No hardware acceleration", + "hardwareAuto": "Automatic hardware acceleration" + } + } } 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 ? ( diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 047edd449..d2be6ded4 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -28,12 +28,18 @@ import { useConfigOverride } from "@/hooks/use-config-override"; import { useSectionSchema } from "@/hooks/use-config-schema"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; 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, @@ -59,6 +65,7 @@ import { globalCameraDefaultSections, buildOverrides, buildConfigDataForPath, + getBaseCameraSectionValue, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, } from "@/utils/configUtil"; @@ -126,16 +133,25 @@ export interface BaseSectionProps { onStatusChange?: (status: { hasChanges: boolean; isOverridden: boolean; + overrideSource?: "global" | "profile"; 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, cameraName: string | undefined, data: ConfigSectionData | null, ) => 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 { @@ -166,6 +182,10 @@ export function ConfigSection({ onStatusChange, pendingDataBySection, onPendingDataChange, + profileName, + profileFriendlyName, + profileBorderColor, + onDeleteProfileSection, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -181,12 +201,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,25 +238,29 @@ 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); 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); 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 @@ -256,7 +285,7 @@ export function ConfigSection({ [sectionPath, level, sectionSchema], ); - // Get override status + // Get override status (camera vs global) const { isOverridden, globalValue, cameraValue } = useConfigOverride({ config, cameraName: effectiveLevel === "camera" ? cameraName : undefined, @@ -264,16 +293,46 @@ export function ConfigSection({ compareFields: sectionConfig.overrideFields, }); + // Check if the active profile overrides the base config for this section + const profileOverridesSection = useMemo(() => { + if (!profileName || !cameraName || !config) return false; + const profileData = config.cameras?.[cameraName]?.profiles?.[profileName]; + return !!profileData?.[sectionPath as keyof typeof profileData]; + }, [profileName, cameraName, config, sectionPath]); + + const overrideSource: "global" | "profile" | undefined = + profileOverridesSection ? "profile" : isOverridden ? "global" : undefined; + // Get current form data + // 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) { - return 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], + `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 {}; @@ -285,10 +344,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(() => { @@ -386,8 +455,20 @@ export function ConfigSection({ }, [formData, pendingData, extraHasChanges]); useEffect(() => { - onStatusChange?.({ hasChanges, isOverridden, hasValidationErrors }); - }, [hasChanges, isOverridden, hasValidationErrors, onStatusChange]); + onStatusChange?.({ + hasChanges, + isOverridden: profileOverridesSection || isOverridden, + overrideSource, + hasValidationErrors, + }); + }, [ + hasChanges, + isOverridden, + profileOverridesSection, + overrideSource, + hasValidationErrors, + onStatusChange, + ]); // Handle form data change const handleChange = useCallback( @@ -499,8 +580,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 +603,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 +702,8 @@ export function ConfigSection({ } }, [ sectionPath, + effectiveSectionPath, + profileName, pendingData, effectiveLevel, cameraName, @@ -642,8 +727,8 @@ export function ConfigSection({ try { const basePath = effectiveLevel === "camera" && cameraName - ? `cameras.${cameraName}.${sectionPath}` - : sectionPath; + ? `cameras.${cameraName}.${effectiveSectionPath}` + : effectiveSectionPath; const configData = buildConfigDataForPath(basePath, ""); @@ -675,7 +760,7 @@ export function ConfigSection({ ); } }, [ - sectionPath, + effectiveSectionPath, effectiveLevel, cameraName, requiresRestart, @@ -784,7 +869,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} @@ -823,7 +908,7 @@ export function ConfigSection({ renderers: wrappedRenderers, sectionDocs: sectionConfig.sectionDocs, fieldDocs: sectionConfig.fieldDocs, - hiddenFields: sectionConfig.hiddenFields, + hiddenFields: effectiveHiddenFields, restartRequired: sectionConfig.restartRequired, requiresRestart, }} @@ -855,7 +940,8 @@ export function ConfigSection({ {((effectiveLevel === "camera" && isOverridden) || effectiveLevel === "global") && !hasChanges && - !skipSave && ( + !skipSave && + !profileName && ( )} + {profileName && + profileOverridesSection && + !hasChanges && + !skipSave && + onDeleteProfileSection && ( + + )} {hasChanges && (
); @@ -963,13 +1107,32 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - isOverridden && ( - - {t("button.overridden", { - ns: "common", - defaultValue: "Overridden", - })} - + (profileOverridesSection || isOverridden) && ( + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfig", { + ns: "views/settings", + defaultValue: "Overridden (Base Config)", + }) + : t("button.overriddenGlobal", { + ns: "views/settings", + defaultValue: "Overridden (Global)", + })} + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfigTooltip", { + ns: "views/settings", + profile: profileFriendlyName ?? profileName, + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {hasChanges && ( @@ -1007,16 +1170,40 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - isOverridden && ( - - {t("button.overridden", { - ns: "common", - defaultValue: "Overridden", - })} - + (profileOverridesSection || isOverridden) && ( + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfig", { + ns: "views/settings", + defaultValue: "Overridden (Base Config)", + }) + : t("button.overriddenGlobal", { + ns: "views/settings", + defaultValue: "Overridden (Global)", + })} + + + + {overrideSource === "profile" + ? t("button.overriddenBaseConfigTooltip", { + ns: "views/settings", + profile: profileFriendlyName ?? profileName, + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {hasChanges && ( 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/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index b2490d2ab..31c52296d 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -8,6 +8,11 @@ import { Input } from "@/components/ui/input"; import type { ConfigFormContext } from "@/types/configForm"; import { cn } from "@/lib/utils"; import { getSizedFieldClassName } from "../utils"; +import { + isMaskedPath, + hasCredentials, + maskCredentials, +} from "@/utils/credentialMask"; type RawPathsResponse = { cameras?: Record< @@ -22,9 +27,6 @@ type RawPathsResponse = { >; }; -const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; -const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; - const getInputIndexFromWidgetId = (id: string): number | undefined => { const match = id.match(/_inputs_(\d+)_path$/); if (!match) { @@ -35,44 +37,6 @@ const getInputIndexFromWidgetId = (id: string): number | undefined => { return Number.isNaN(index) ? undefined : index; }; -const isMaskedPath = (value: string): boolean => - MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); - -const hasCredentials = (value: string): boolean => { - if (!value) { - return false; - } - - if (isMaskedPath(value)) { - return true; - } - - try { - const parsed = new URL(value); - if (parsed.username || parsed.password) { - return true; - } - - return ( - parsed.searchParams.has("user") && parsed.searchParams.has("password") - ); - } catch { - return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value); - } -}; - -const maskCredentials = (value: string): string => { - if (!value) { - return value; - } - - const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); - - return maskedAuth - .replace(/([?&]user=)[^&]*/gi, "$1*") - .replace(/([?&]password=)[^&]*/gi, "$1*"); -}; - export function CameraPathWidget(props: WidgetProps) { const { id, diff --git a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx index 415cd2603..dcf34fae8 100644 --- a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx +++ b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx @@ -1,7 +1,9 @@ import type { WidgetProps } from "@rjsf/utils"; import useSWR from "swr"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; import { Input } from "@/components/ui/input"; import { ConfigFormContext } from "@/types/configForm"; import { @@ -22,7 +24,7 @@ type FfmpegPresetResponse = { }; }; -type FfmpegArgsMode = "preset" | "manual" | "inherit"; +type FfmpegArgsMode = "preset" | "manual" | "inherit" | "none"; type PresetField = | "hwaccel_args" @@ -60,8 +62,8 @@ const resolveMode = ( defaultMode: FfmpegArgsMode, allowInherit: boolean, ): FfmpegArgsMode => { - if (allowInherit && (value === null || value === undefined)) { - return "inherit"; + if (value === null || value === undefined) { + return allowInherit ? "inherit" : "none"; } if (allowInherit && Array.isArray(value) && value.length === 0) { @@ -122,6 +124,19 @@ export function FfmpegArgsWidget(props: WidgetProps) { const hideDescription = options?.hideDescription === true; const useSplitLayout = options?.splitLayout !== false; + // Detect camera-level top-level fields (not inside inputs array). + // These should show "Use global setting" instead of "None". + const isInputScoped = id.includes("_inputs_"); + const showUseGlobalSetting = isCameraLevel && !isInputScoped && !allowInherit; + + // Extract the global value for this specific field to detect inheritance + const globalFieldValue = useMemo(() => { + if (!showUseGlobalSetting || !formContext?.globalValue || !presetField) { + return undefined; + } + return get(formContext.globalValue as Record, presetField); + }, [showUseGlobalSetting, formContext?.globalValue, presetField]); + const { data } = useSWR("ffmpeg/presets"); const presetOptions = useMemo( @@ -132,14 +147,48 @@ export function FfmpegArgsWidget(props: WidgetProps) { const canUsePresets = presetOptions.length > 0; const defaultMode: FfmpegArgsMode = canUsePresets ? "preset" : "manual"; - const detectedMode = useMemo( - () => resolveMode(value, presetOptions, defaultMode, allowInherit), - [value, presetOptions, defaultMode, allowInherit], - ); + // Detect if this field's value is effectively inherited from the global + // config (i.e. the camera does not override it). + const isInheritedFromGlobal = useMemo(() => { + if (!showUseGlobalSetting) return false; + if (value === undefined || value === null) return true; + if (globalFieldValue === undefined || globalFieldValue === null) + return false; + return isEqual(value, globalFieldValue); + }, [showUseGlobalSetting, value, globalFieldValue]); + + const detectedMode = useMemo(() => { + if (showUseGlobalSetting && isInheritedFromGlobal) { + return "inherit" as FfmpegArgsMode; + } + return resolveMode(value, presetOptions, defaultMode, allowInherit); + }, [ + showUseGlobalSetting, + isInheritedFromGlobal, + value, + presetOptions, + defaultMode, + allowInherit, + ]); const [mode, setMode] = useState(detectedMode); + // Track whether the user has explicitly changed mode to prevent the + // detected-mode sync from snapping back (e.g. when a user-selected + // preset happens to match the global value). + const userSetModeRef = useRef(false); + + const formIsClean = !formContext?.hasChanges; + + // Reset tracking when the widget identity changes (camera switch) + // or when the form returns to a clean state (after a successful save). useEffect(() => { + userSetModeRef.current = false; + }, [id, formIsClean]); + + useEffect(() => { + if (userSetModeRef.current) return; + if (!canUsePresets && detectedMode === "preset") { setMode("manual"); return; @@ -150,6 +199,7 @@ export function FfmpegArgsWidget(props: WidgetProps) { const handleModeChange = useCallback( (nextMode: FfmpegArgsMode) => { + userSetModeRef.current = true; setMode(nextMode); if (nextMode === "inherit") { @@ -157,6 +207,11 @@ export function FfmpegArgsWidget(props: WidgetProps) { return; } + if (nextMode === "none") { + onChange(undefined); + return; + } + if (nextMode === "preset") { const currentValue = typeof value === "string" ? value : undefined; const presetValue = @@ -203,7 +258,6 @@ export function FfmpegArgsWidget(props: WidgetProps) { return undefined; } - const isInputScoped = id.includes("_inputs_"); const prefix = isInputScoped ? "ffmpeg.inputs" : "ffmpeg"; if (presetField === "hwaccel_args") { @@ -227,7 +281,7 @@ export function FfmpegArgsWidget(props: WidgetProps) { } return undefined; - }, [id, presetField]); + }, [isInputScoped, presetField]); const translatedDescription = fallbackDescriptionKey && @@ -247,7 +301,25 @@ export function FfmpegArgsWidget(props: WidgetProps) { onValueChange={(next) => handleModeChange(next as FfmpegArgsMode)} className="gap-3" > - {allowInherit ? ( + {showUseGlobalSetting ? ( +
+ + +
+ ) : allowInherit ? (
- ) : null} + ) : ( +
+ + +
+ )}
- {mode === "inherit" ? null : mode === "preset" && canUsePresets ? ( + {mode === "inherit" || mode === "none" ? null : mode === "preset" && + canUsePresets ? ( + + + + + {profileState.allProfileNames.map((profile) => { + const color = getProfileColor( + profile, + profileState.allProfileNames, + ); + return ( + +
+ + {profileState.profileFriendlyNames.get(profile) ?? + profile} +
+
+ ); + })} +
+ + +
+ {cameras.map((camera) => { + const state = getEnabledState(camera); + const isSaving = savingCamera === camera; + + return ( +
+ + {isSaving ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ + + ); +} diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx new file mode 100644 index 000000000..dc6f1533c --- /dev/null +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -0,0 +1,1009 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import axios from "axios"; +import isEqual from "lodash/isEqual"; +import { toast } from "sonner"; +import { + LuChevronDown, + LuExternalLink, + LuEye, + LuEyeOff, + LuPencil, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import { Link } from "react-router-dom"; +import Heading from "@/components/ui/heading"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { cn } from "@/lib/utils"; +import { + isMaskedPath, + hasCredentials, + maskCredentials, +} from "@/utils/credentialMask"; +import { + parseFfmpegUrl, + buildFfmpegUrl, + toggleFfmpegMode, + type FfmpegVideoOption, + type FfmpegAudioOption, + type FfmpegHardwareOption, +} from "@/utils/go2rtcFfmpeg"; + +type RawPathsResponse = { + cameras: Record< + string, + { ffmpeg: { inputs: { path: string; roles: string[] }[] } } + >; + go2rtc: { streams: Record }; +}; + +type Go2RtcStreamsSettingsViewProps = { + setUnsavedChanges: React.Dispatch>; + onSectionStatusChange?: ( + sectionKey: string, + level: "global" | "camera", + status: { + hasChanges: boolean; + isOverridden: boolean; + hasValidationErrors: boolean; + }, + ) => void; +}; + +const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; + +function normalizeStreams( + streams: Record | undefined, +): Record { + if (!streams) return {}; + const result: Record = {}; + for (const [name, urls] of Object.entries(streams)) { + result[name] = Array.isArray(urls) ? urls : [urls]; + } + return result; +} + +export default function Go2RtcStreamsSettingsView({ + setUnsavedChanges, + onSectionStatusChange, +}: Go2RtcStreamsSettingsViewProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { getLocaleDocUrl } = useDocDomain(); + const { data: config, mutate: updateConfig } = + useSWR("config"); + const { data: rawPaths, mutate: updateRawPaths } = + useSWR("config/raw_paths"); + + const [editedStreams, setEditedStreams] = useState>( + {}, + ); + const [serverStreams, setServerStreams] = useState>( + {}, + ); + const [initialized, setInitialized] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [credentialVisibility, setCredentialVisibility] = useState< + Record + >({}); + const [deleteDialog, setDeleteDialog] = useState(null); + const [renameDialog, setRenameDialog] = useState(null); + const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false); + const [newlyAdded, setNewlyAdded] = useState>(new Set()); + + // Initialize from config — wait for both config and rawPaths to avoid + // a mismatch when rawPaths arrives after config with different data + useEffect(() => { + if (!config || !rawPaths) return; + + // Always use rawPaths for go2rtc streams — the /config endpoint masks + // credentials, so using config.go2rtc.streams would save masked values + const normalized = normalizeStreams(rawPaths.go2rtc?.streams); + + setServerStreams(normalized); + if (!initialized) { + setEditedStreams(normalized); + setInitialized(true); + } + }, [config, rawPaths, initialized]); + + // Track unsaved changes + const hasChanges = useMemo( + () => initialized && !isEqual(editedStreams, serverStreams), + [editedStreams, serverStreams, initialized], + ); + + useEffect(() => { + setUnsavedChanges(hasChanges); + }, [hasChanges, setUnsavedChanges]); + + const hasValidationErrors = useMemo(() => { + const names = Object.keys(editedStreams); + const seenNames = new Set(); + + for (const name of names) { + if (!name.trim() || !STREAM_NAME_PATTERN.test(name)) return true; + if (seenNames.has(name)) return true; + seenNames.add(name); + + const urls = editedStreams[name]; + if (!urls || urls.length === 0 || urls.every((u) => !u.trim())) + return true; + } + + return false; + }, [editedStreams]); + + // Report status to parent for sidebar red dot + useEffect(() => { + onSectionStatusChange?.("go2rtc_streams", "global", { + hasChanges: hasChanges, + isOverridden: false, + hasValidationErrors, + }); + }, [hasChanges, hasValidationErrors, onSectionStatusChange]); + + // Save handler + const saveToConfig = useCallback(async () => { + setIsLoading(true); + + try { + const streamsPayload: Record = { + ...editedStreams, + }; + const deletedStreamNames = Object.keys(serverStreams).filter( + (name) => !(name in editedStreams), + ); + for (const deleted of deletedStreamNames) { + streamsPayload[deleted] = ""; + } + + await axios.put("config/set", { + requires_restart: 0, + config_data: { go2rtc: { streams: streamsPayload } }, + }); + + // Update running go2rtc instance + const go2rtcUpdates: Promise[] = []; + for (const [streamName, urls] of Object.entries(editedStreams)) { + if (urls[0]) { + go2rtcUpdates.push( + axios.put( + `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, + ), + ); + } + } + for (const deleted of deletedStreamNames) { + go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`)); + } + await Promise.allSettled(go2rtcUpdates); + + toast.success( + t("toast.success", { + ns: "views/settings", + defaultValue: "Settings saved successfully", + }), + ); + + setServerStreams(editedStreams); + updateConfig(); + updateRawPaths(); + } catch { + toast.error( + t("toast.error", { + ns: "views/settings", + defaultValue: "Failed to save settings", + }), + ); + } finally { + setIsLoading(false); + } + }, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]); + + // Reset handler + const onReset = useCallback(() => { + setEditedStreams(serverStreams); + setCredentialVisibility({}); + }, [serverStreams]); + + // Stream CRUD operations + const addStream = useCallback((name: string) => { + setEditedStreams((prev) => ({ ...prev, [name]: [""] })); + setNewlyAdded((prev) => new Set(prev).add(name)); + setAddStreamDialogOpen(false); + }, []); + + const deleteStream = useCallback((streamName: string) => { + setEditedStreams((prev) => { + const { [streamName]: _, ...rest } = prev; + return rest; + }); + setDeleteDialog(null); + }, []); + + const renameStream = useCallback((oldName: string, newName: string) => { + if (oldName === newName || !newName.trim()) return; + + setEditedStreams((prev) => { + const urls = prev[oldName]; + if (!urls) return prev; + + const entries = Object.entries(prev); + const result: Record = {}; + for (const [key, value] of entries) { + if (key === oldName) { + result[newName] = value; + } else { + result[key] = value; + } + } + return result; + }); + }, []); + + const updateUrl = useCallback( + (streamName: string, urlIndex: number, newUrl: string) => { + setEditedStreams((prev) => { + const urls = [...(prev[streamName] || [])]; + urls[urlIndex] = newUrl; + return { ...prev, [streamName]: urls }; + }); + }, + [], + ); + + const addUrl = useCallback((streamName: string) => { + setEditedStreams((prev) => { + const urls = [...(prev[streamName] || []), ""]; + return { ...prev, [streamName]: urls }; + }); + }, []); + + const removeUrl = useCallback((streamName: string, urlIndex: number) => { + setEditedStreams((prev) => { + const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex); + return { ...prev, [streamName]: urls.length > 0 ? urls : [""] }; + }); + }, []); + + const toggleCredentialVisibility = useCallback((key: string) => { + setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + if (!config) return null; + + const streamEntries = Object.entries(editedStreams); + + return ( +
+
+
+ {t("go2rtcStreams.title")} +
+ {t("go2rtcStreams.description")} +
+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+ + {streamEntries.length === 0 && ( +
+ {t("go2rtcStreams.noStreams")} +
+ )} + +
+ {streamEntries.map(([streamName, urls]) => ( + setRenameDialog(streamName)} + onDelete={() => setDeleteDialog(streamName)} + onUpdateUrl={updateUrl} + onAddUrl={() => addUrl(streamName)} + onRemoveUrl={(urlIndex) => removeUrl(streamName, urlIndex)} + onToggleCredentialVisibility={toggleCredentialVisibility} + /> + ))} +
+ + +
+ + {/* Sticky save/undo buttons */} +
+
+ {hasChanges && ( +
+ {t("unsavedChanges")} +
+ )} +
+ {hasChanges && ( + + )} + +
+
+
+ + {/* Delete confirmation dialog */} + { + if (!open) setDeleteDialog(null); + }} + > + + + + {t("go2rtcStreams.deleteStream")} + + + {t("go2rtcStreams.deleteStreamConfirm", { + streamName: deleteDialog ?? "", + })} + + + + + {t("button.cancel", { ns: "common" })} + + deleteDialog && deleteStream(deleteDialog)} + > + {t("go2rtcStreams.deleteStream")} + + + + + + {/* Rename dialog */} + { + renameStream(oldName, newName); + setRenameDialog(null); + }} + onClose={() => setRenameDialog(null)} + /> + + setAddStreamDialogOpen(false)} + /> +
+ ); +} + +// --- RenameStreamDialog --- + +type RenameStreamDialogProps = { + open: boolean; + streamName: string; + allStreamNames: string[]; + onRename: (oldName: string, newName: string) => void; + onClose: () => void; +}; + +function RenameStreamDialog({ + open, + streamName, + allStreamNames, + onRename, + onClose, +}: RenameStreamDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [newName, setNewName] = useState(""); + + useEffect(() => { + if (open) { + setNewName(streamName); + } + }, [open, streamName]); + + const nameError = useMemo(() => { + if (!newName.trim()) { + return t("go2rtcStreams.validation.nameRequired"); + } + if (!STREAM_NAME_PATTERN.test(newName)) { + return t("go2rtcStreams.validation.nameInvalid"); + } + if (newName !== streamName && allStreamNames.includes(newName)) { + return t("go2rtcStreams.validation.nameDuplicate"); + } + return null; + }, [newName, streamName, allStreamNames, t]); + + const canSubmit = !nameError && newName !== streamName; + + return ( + !v && onClose()}> + + + {t("go2rtcStreams.renameStream")} + + {t("go2rtcStreams.renameStreamDesc")} + + +
+ + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmit) { + onRename(streamName, newName); + } + }} + autoFocus + /> + {nameError && newName !== streamName && ( +

{nameError}

+ )} +
+ + + + + + +
+
+ ); +} + +type AddStreamDialogProps = { + open: boolean; + allStreamNames: string[]; + onAdd: (name: string) => void; + onClose: () => void; +}; + +function AddStreamDialog({ + open, + allStreamNames, + onAdd, + onClose, +}: AddStreamDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [name, setName] = useState(""); + + useEffect(() => { + if (open) { + setName(""); + } + }, [open]); + + const nameError = useMemo(() => { + if (!name.trim()) { + return t("go2rtcStreams.validation.nameRequired"); + } + if (!STREAM_NAME_PATTERN.test(name)) { + return t("go2rtcStreams.validation.nameInvalid"); + } + if (allStreamNames.includes(name)) { + return t("go2rtcStreams.validation.nameDuplicate"); + } + return null; + }, [name, allStreamNames, t]); + + const canSubmit = !nameError; + + return ( + !v && onClose()}> + + + {t("go2rtcStreams.addStream")} + + {t("go2rtcStreams.addStreamDesc")} + + +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmit) { + onAdd(name); + } + }} + placeholder="camera_name" + autoFocus + /> + {nameError && name.length > 0 && ( +

{nameError}

+ )} +
+ + + + + + +
+
+ ); +} + +type StreamCardProps = { + streamName: string; + urls: string[]; + credentialVisibility: Record; + onRename: () => void; + onDelete: () => void; + onUpdateUrl: (streamName: string, urlIndex: number, newUrl: string) => void; + onAddUrl: () => void; + onRemoveUrl: (urlIndex: number) => void; + onToggleCredentialVisibility: (key: string) => void; + defaultOpen?: boolean; +}; + +function StreamCard({ + streamName, + urls, + credentialVisibility, + onRename, + onDelete, + onUpdateUrl, + onAddUrl, + onRemoveUrl, + onToggleCredentialVisibility, + defaultOpen = false, +}: StreamCardProps) { + const { t } = useTranslation("views/settings"); + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + + + +
+
+

{streamName}

+ +
+
+ + + + +
+
+ +
+ {urls.map((url, urlIndex) => ( + 1} + showCredentials={ + credentialVisibility[`${streamName}-${urlIndex}`] ?? false + } + onUpdateUrl={onUpdateUrl} + onRemoveUrl={() => onRemoveUrl(urlIndex)} + onToggleCredentialVisibility={() => + onToggleCredentialVisibility(`${streamName}-${urlIndex}`) + } + /> + ))} + +
+
+
+
+
+ ); +} + +type StreamUrlEntryProps = { + streamName: string; + url: string; + urlIndex: number; + canRemove: boolean; + showCredentials: boolean; + onUpdateUrl: (streamName: string, urlIndex: number, newUrl: string) => void; + onRemoveUrl: () => void; + onToggleCredentialVisibility: () => void; +}; + +function StreamUrlEntry({ + streamName, + url, + urlIndex, + canRemove, + showCredentials, + onUpdateUrl, + onRemoveUrl, + onToggleCredentialVisibility, +}: StreamUrlEntryProps) { + const { t } = useTranslation("views/settings"); + const [isFocused, setIsFocused] = useState(false); + const parsed = useMemo(() => parseFfmpegUrl(url), [url]); + + const rawBaseUrl = parsed.isFfmpeg ? parsed.baseUrl : url; + const canToggleCredentials = + hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl); + + const baseUrlForDisplay = useMemo(() => { + // Never mask while the input is focused — the user may be typing credentials + if (isFocused) return rawBaseUrl; + if (!showCredentials && hasCredentials(rawBaseUrl)) { + return maskCredentials(rawBaseUrl); + } + return rawBaseUrl; + }, [rawBaseUrl, showCredentials, isFocused]); + + const isTranscodingVideo = + parsed.isFfmpeg && parsed.video !== "copy" && parsed.video !== "exclude"; + + const handleBaseUrlChange = useCallback( + (newBaseUrl: string) => { + if (parsed.isFfmpeg) { + const newUrl = buildFfmpegUrl({ ...parsed, baseUrl: newBaseUrl }); + onUpdateUrl(streamName, urlIndex, newUrl); + } else { + onUpdateUrl(streamName, urlIndex, newBaseUrl); + } + }, + [parsed, streamName, urlIndex, onUpdateUrl], + ); + + const handleFfmpegToggle = useCallback( + (enabled: boolean) => { + const newUrl = toggleFfmpegMode(url, enabled); + onUpdateUrl(streamName, urlIndex, newUrl); + }, + [url, streamName, urlIndex, onUpdateUrl], + ); + + const handleFfmpegOptionChange = useCallback( + ( + field: "video" | "audio" | "hardware", + value: FfmpegVideoOption | FfmpegAudioOption | FfmpegHardwareOption, + ) => { + const updated = { ...parsed, [field]: value }; + // Clear hardware when switching away from transcoding video + if (field === "video" && (value === "copy" || value === "exclude")) { + updated.hardware = "none"; + } + const newUrl = buildFfmpegUrl(updated); + onUpdateUrl(streamName, urlIndex, newUrl); + }, + [parsed, streamName, urlIndex, onUpdateUrl], + ); + + const audioDisplayLabel = useMemo(() => { + const labels: Record = { + copy: t("go2rtcStreams.ffmpeg.audioCopy"), + aac: t("go2rtcStreams.ffmpeg.audioAac"), + opus: t("go2rtcStreams.ffmpeg.audioOpus"), + pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"), + pcma: t("go2rtcStreams.ffmpeg.audioPcma"), + pcm: t("go2rtcStreams.ffmpeg.audioPcm"), + mp3: t("go2rtcStreams.ffmpeg.audioMp3"), + exclude: t("go2rtcStreams.ffmpeg.audioExclude"), + }; + return labels[parsed.audio] || parsed.audio; + }, [parsed.audio, t]); + + return ( +
+
+
+ handleBaseUrlChange(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={t("go2rtcStreams.streamUrlPlaceholder")} + /> + {canToggleCredentials && ( + + )} +
+ {canRemove && ( + + )} +
+ + {/* ffmpeg module toggle */} +
+ + +
+ + {/* ffmpeg options */} + {parsed.isFfmpeg && ( +
+ {/* Video */} +
+ + +
+ + {/* Audio */} +
+ + +
+ + {/* Hardware acceleration - only when transcoding video */} + {isTranscodingVideo && ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index a80eab572..6bb2fde9a 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,17 +35,19 @@ import { useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { cn } from "@/lib/utils"; - +import { ProfileState } from "@/types/profile"; 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(); @@ -70,6 +72,10 @@ export default function MasksAndZonesView({ const [activeLine, setActiveLine] = useState(); const [snapPoints, setSnapPoints] = useState(false); + // Profile state + const currentEditingProfile = + profileState?.editingProfile[selectedCamera] ?? null; + const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -228,18 +234,94 @@ 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; + + // 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(baseZones)); + const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {})); + const baseMotionMaskNames = new Set(Object.keys(baseMotion.mask || {})); + const profileMotionMaskNames = new Set( + Object.keys(profileData?.motion?.mask ?? {}), + ); + const baseGlobalObjectMaskNames = new Set( + Object.keys(baseObjects.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(baseZones)) { + 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 ?? + baseZones[name]?.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 +330,60 @@ 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(baseMotion.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 +399,59 @@ 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(baseObjects.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 +467,41 @@ 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( + baseObjects.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(baseObjects.filters || {}) .filter( ([, filterConfig]) => filterConfig.mask && Object.keys(filterConfig.mask).length > 0, @@ -316,22 +509,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 +546,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 +555,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 +633,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 +657,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 +736,7 @@ export default function MasksAndZonesView({ setActiveLine={setActiveLine} snapPoints={snapPoints} setSnapPoints={setSnapPoints} + editingProfile={currentEditingProfile} /> )} {editPane == "motion_mask" && ( @@ -488,6 +752,7 @@ export default function MasksAndZonesView({ onSave={handleSave} snapPoints={snapPoints} setSnapPoints={setSnapPoints} + editingProfile={currentEditingProfile} /> )} {editPane == "object_mask" && ( @@ -503,13 +768,14 @@ export default function MasksAndZonesView({ onSave={handleSave} snapPoints={snapPoints} setSnapPoints={setSnapPoints} + editingProfile={currentEditingProfile} /> )} {editPane === undefined && ( <> - - {t("menu.masksAndZones")} - +
+ {t("menu.masksAndZones")} +
{(selectedZoneMask === undefined || selectedZoneMask.includes("zone" as PolygonType)) && ( @@ -575,6 +841,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} loadingPolygonIndex={loadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex} + editingProfile={currentEditingProfile} + allProfileNames={profileState?.allProfileNames} /> ))}
@@ -649,6 +917,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} loadingPolygonIndex={loadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex} + editingProfile={currentEditingProfile} + allProfileNames={profileState?.allProfileNames} /> ))} @@ -723,6 +993,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} loadingPolygonIndex={loadingPolygonIndex} setLoadingPolygonIndex={setLoadingPolygonIndex} + editingProfile={currentEditingProfile} + allProfileNames={profileState?.allProfileNames} /> ))} diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx new file mode 100644 index 000000000..3c4436015 --- /dev/null +++ b/web/src/views/settings/ProfilesView.tsx @@ -0,0 +1,733 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import useSWR from "swr"; +import axios from "axios"; +import { toast } from "sonner"; +import { Pencil, Trash2 } from "lucide-react"; +import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import type { JsonObject } from "@/types/configForm"; +import type { ProfileState, ProfilesApiResponse } from "@/types/profile"; +import { getProfileColor } from "@/utils/profileColors"; +import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil"; +import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; +import { cn } from "@/lib/utils"; +import Heading from "@/components/ui/heading"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import NameAndIdFields from "@/components/input/NameAndIdFields"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +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 { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; + +type ProfilesViewProps = { + setUnsavedChanges?: React.Dispatch>; + profileState?: ProfileState; + profilesUIEnabled?: boolean; + setProfilesUIEnabled?: React.Dispatch>; +}; + +export default function ProfilesView({ + profileState, + profilesUIEnabled, + setProfilesUIEnabled, +}: 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); + const [renameProfile, setRenameProfile] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [renaming, setRenaming] = useState(false); + const [expandedProfiles, setExpandedProfiles] = useState>( + new Set(), + ); + const [addDialogOpen, setAddDialogOpen] = useState(false); + + const allProfileNames = useMemo( + () => profileState?.allProfileNames ?? [], + [profileState?.allProfileNames], + ); + + const addProfileSchema = useMemo( + () => + z.object({ + name: z + .string() + .min(2, { + message: t("profiles.error.mustBeAtLeastTwoCharacters", { + ns: "views/settings", + }), + }) + .refine((value) => !value.includes("."), { + message: t("profiles.error.mustNotContainPeriod", { + ns: "views/settings", + }), + }) + .refine((value) => !allProfileNames.includes(value), { + message: t("profiles.error.alreadyExists", { + ns: "views/settings", + }), + }), + friendly_name: z.string().min(2, { + message: t("profiles.error.mustBeAtLeastTwoCharacters", { + ns: "views/settings", + }), + }), + }), + [t, allProfileNames], + ); + + type AddProfileForm = z.infer; + const addForm = useForm({ + resolver: zodResolver(addProfileSchema), + defaultValues: { friendly_name: "", name: "" }, + }); + + const profileFriendlyNames = profileState?.profileFriendlyNames; + + useEffect(() => { + document.title = t("documentTitle.profiles", { + ns: "views/settings", + }); + }, [t]); + + 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 [addingProfile, setAddingProfile] = useState(false); + + const handleAddSubmit = useCallback( + async (data: AddProfileForm) => { + const id = data.name.trim(); + const friendlyName = data.friendly_name.trim(); + if (!id || !friendlyName) return; + + 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); + } + }, + [updateConfig, updateProfiles, addForm, t], + ); + + const handleActivateProfile = useCallback( + async (profile: string | null) => { + setActivating(true); + try { + await axios.put("camera/*/set/profile", { + value: profile ?? "none", + }); + await updateProfiles(); + toast.success( + profile + ? t("profiles.activated", { + ns: "views/settings", + profile: profileFriendlyNames?.get(profile) ?? profile, + }) + : t("profiles.deactivated", { ns: "views/settings" }), + { position: "top-center" }, + ); + } catch (err) { + const message = + axios.isAxiosError(err) && err.response?.data?.message + ? String(err.response.data.message) + : undefined; + toast.error( + message || t("profiles.activateFailed", { ns: "views/settings" }), + { position: "top-center" }, + ); + } finally { + setActivating(false); + } + }, + [updateProfiles, profileFriendlyNames, 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("camera/*/set/profile", { value: "none" }); + } + + // Remove the profile from all cameras and the top-level definition + const cameraData: JsonObject = {}; + for (const camera of Object.keys(config.cameras)) { + if (config.cameras[camera]?.profiles?.[deleteProfile]) { + cameraData[camera] = { + profiles: { [deleteProfile]: "" }, + }; + } + } + + const configData: JsonObject = { + profiles: { [deleteProfile]: "" }, + }; + if (Object.keys(cameraData).length > 0) { + configData.cameras = cameraData; + } + + await axios.put("config/set", { + requires_restart: 0, + config_data: configData, + }); + + await updateConfig(); + await updateProfiles(); + + toast.success( + t("profiles.deleteSuccess", { + ns: "views/settings", + profile: profileFriendlyNames?.get(deleteProfile) ?? deleteProfile, + }), + { position: "top-center" }, + ); + } catch (err) { + const errorMessage = + axios.isAxiosError(err) && err.response?.data?.message + ? String(err.response.data.message) + : undefined; + toast.error( + errorMessage || t("toast.save.error.noMessage", { ns: "common" }), + { position: "top-center" }, + ); + } finally { + setDeleting(false); + setDeleteProfile(null); + } + }, [ + deleteProfile, + activeProfile, + config, + profileFriendlyNames, + updateConfig, + updateProfiles, + t, + ]); + + const toggleExpanded = useCallback((profile: string) => { + setExpandedProfiles((prev) => { + const next = new Set(prev); + if (next.has(profile)) { + next.delete(profile); + } else { + next.add(profile); + } + return next; + }); + }, []); + + const handleRename = useCallback(async () => { + if (!renameProfile || !renameValue.trim()) return; + + setRenaming(true); + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { + profiles: { + [renameProfile]: { friendly_name: renameValue.trim() }, + }, + }, + }); + + await updateConfig(); + await updateProfiles(); + + toast.success( + t("profiles.renameSuccess", { + ns: "views/settings", + profile: renameValue.trim(), + }), + { position: "top-center" }, + ); + } catch { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { + position: "top-center", + }); + } finally { + setRenaming(false); + setRenameProfile(null); + } + }, [renameProfile, renameValue, updateConfig, updateProfiles, t]); + + if (!config || !profilesData) { + return null; + } + + const hasProfiles = allProfileNames.length > 0; + + return ( +
+ {t("profiles.title", { ns: "views/settings" })} +
+ {t("profiles.disabledDescription", { ns: "views/settings" })} +
+ + {/* Enable Profiles Toggle — shown only when no profiles exist */} + {!hasProfiles && setProfilesUIEnabled && ( +
+
+ + +
+
+ )} + + {profilesUIEnabled && !hasProfiles && ( +

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

+ )} + + {/* Active Profile + Add Profile bar */} + {(hasProfiles || profilesUIEnabled) && ( +
+ {hasProfiles && ( +
+ + {t("profiles.activeProfile", { ns: "views/settings" })} + + + {activating && } +
+ )} + +
+ )} + + {/* Profile List */} + {!hasProfiles ? ( + profilesUIEnabled ? ( +

+ {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(); + const isExpanded = expandedProfiles.has(profile); + + return ( + toggleExpanded(profile)} + > +
+ +
+
+ {isExpanded ? ( + + ) : ( + + )} + + + {profileFriendlyNames?.get(profile) ?? 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(", ")} + +
+ ); + })} +
+ ) : ( +
+ {t("profiles.noOverrides", { ns: "views/settings" })} +
+ )} +
+
+
+ ); + })} +
+ )} + + {/* Add Profile Dialog */} + { + setAddDialogOpen(open); + if (!open) { + addForm.reset(); + } + }} + > + + + + {t("profiles.newProfile", { ns: "views/settings" })} + + + +
+ + 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", + })} + /> + + + + + +
+
+
+ + {/* Delete Profile Confirmation */} + { + if (!open) setDeleteProfile(null); + }} + > + + + + {t("profiles.deleteProfile", { ns: "views/settings" })} + + + {t("profiles.deleteProfileConfirm", { + ns: "views/settings", + profile: deleteProfile + ? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile) + : "", + })} + + + + + {t("button.cancel", { ns: "common" })} + + { + e.preventDefault(); + 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 c1e7752f7..ee87a9e76 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -4,8 +4,16 @@ import type { SectionConfig } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import type { ConfigSectionData } from "@/types/configForm"; +import type { ProfileState } from "@/types/profile"; import { getSectionConfig } from "@/utils/configUtil"; +import { getProfileColor } from "@/utils/profileColors"; +import { cn } from "@/lib/utils"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; @@ -20,17 +28,24 @@ 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>; }; export type SectionStatus = { hasChanges: boolean; isOverridden: boolean; + /** Where the override comes from: "global" = camera overrides global, "profile" = profile overrides base */ + overrideSource?: "global" | "profile"; hasValidationErrors: boolean; }; @@ -56,6 +71,8 @@ export function SingleSectionPage({ onSectionStatusChange, pendingDataBySection, onPendingDataChange, + profileState, + onDeleteProfileSection, }: SingleSectionPageProps) { const sectionNamespace = level === "camera" ? "config/cameras" : "config/global"; @@ -78,6 +95,24 @@ export function SingleSectionPage({ ? getLocaleDocUrl(resolvedSectionConfig.sectionDocs) : undefined; + const currentEditingProfile = selectedCamera + ? (profileState?.editingProfile[selectedCamera] ?? null) + : null; + + const profileColor = useMemo( + () => + currentEditingProfile && profileState?.allProfileNames + ? getProfileColor(currentEditingProfile, profileState.allProfileNames) + : undefined, + [currentEditingProfile, profileState?.allProfileNames], + ); + + const handleDeleteProfileSection = useCallback(() => { + if (currentEditingProfile && onDeleteProfileSection) { + onDeleteProfileSection(currentEditingProfile); + } + }, [currentEditingProfile, onDeleteProfileSection]); + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -127,15 +162,44 @@ 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: "views/settings", + profile: currentEditingProfile + ? (profileState?.profileFriendlyNames.get( + currentEditingProfile, + ) ?? currentEditingProfile) + : "", + }) + : t("button.overriddenGlobalTooltip", { + ns: "views/settings", + })} + + )} {sectionStatus.hasChanges && (
); diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 60aaffabf..daf23ac57 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -212,10 +212,10 @@ export default function UiSettingsView() { return (
- - {t("general.title")} -
+ + {t("general.title")} +