Camera profile support (#22482)

* add CameraProfileConfig model for named config overrides

* add profiles field to CameraConfig

* add active_profile field to FrigateConfig

Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.

* add ProfileManager for profile activation and persistence

Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.

* add profile API endpoints (GET /profiles, GET/PUT /profile)

* add MQTT and dispatcher integration for profiles

- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect

* wire ProfileManager into app startup and FastAPI

- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app

* add tests for invalid profile values and keys

Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.

* formatting

* fix CameraLiveConfig JSON serialization error on profile activation

refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.

* consolidate

* add enabled field to camera profiles for enabling/disabling cameras

* add zones support to camera profiles

* add frontend profile types, color utility, and config save support

* add profile state management and save preview support

* add profileName prop to BaseSection for profile-aware config editing

* add profile section dropdown and wire into camera settings pages

* add per-profile camera enable/disable to Camera Management view

* add profiles summary page with card-based layout and fix backend zone comparison bug

* add active profile badge to settings toolbar

* i18n

* add red dot for any pending changes including profiles

* profile support for mask and zone editor

* fix hidden field validation errors caused by lodash wildcard and schema gaps

lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.

* add face_recognition and lpr to profile-eligible sections

* move profile dropdown from section panes to settings header

* add profiles enable toggle and improve empty state

* formatting

* tweaks

* tweak colors and switch

* fix profile save diff, masksAndZones delete, and config sync

* ui tweaks

* ensure profile manager gets updated config

* rename profile settings to ui settings

* refactor profilesview and add dots/border colors when overridden

* implement an update_config method for profile manager

* fix mask deletion

* more unique colors

* add top-level profiles config section with friendly names

* implement profile friendly names and improve profile UI

- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
  button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
  camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions

* remove profile badge in settings and add profiles to main menu

* use icon only on mobile

* change color order

* docs

* show activity indicator on trash icon while deleting a profile

* tweak language

* immediately create profiles on backend instead of deferring to Save All

* hide restart-required fields when editing a profile section

fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set

* show active profile indicator in desktop status bar

* fix profile config inheritance bug where Pydantic defaults override base values

The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.

Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.

* docs tweaks

* docs tweak

* formatting

* formatting

* fix typing

* fix test pollution

test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed

* remove

* fix settings showing profile-merged values when editing base config

When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.

Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.

Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.

* use rasterized_mask as field

makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles

* fix zones

- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager

* formatting

* don't require restart for camera enabled change for profiles

* publish camera state when changing profiles

* formatting

* remove available profiles from mqtt

* improve typing
This commit is contained in:
Josh Hawkins
2026-03-19 09:47:57 -05:00
committed by GitHub
parent a05f35c747
commit c93dad9bd9
47 changed files with 4591 additions and 458 deletions
+7
View File
@@ -34,6 +34,7 @@ from .mqtt import CameraMqttConfig
from .notification import NotificationConfig
from .objects import ObjectConfig
from .onvif import OnvifConfig
from .profile import CameraProfileConfig
from .record import RecordConfig
from .review import ReviewConfig
from .snapshots import SnapshotsConfig
@@ -184,6 +185,12 @@ class CameraConfig(FrigateBaseModel):
title="Camera URL",
description="URL to visit the camera directly from system page",
)
profiles: dict[str, CameraProfileConfig] = Field(
default_factory=dict,
title="Profiles",
description="Named config profiles with partial overrides that can be activated at runtime.",
)
zones: dict[str, ZoneConfig] = Field(
default_factory=dict,
title="Zones",
+44
View File
@@ -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
+6
View File
@@ -27,6 +27,8 @@ class CameraConfigUpdateEnum(str, Enum):
review = "review"
review_genai = "review_genai"
semantic_search = "semantic_search" # for semantic search triggers
face_recognition = "face_recognition"
lpr = "lpr"
snapshots = "snapshots"
zones = "zones"
@@ -119,6 +121,10 @@ class CameraConfigUpdateSubscriber:
config.review.genai = updated_config
elif update_type == CameraConfigUpdateEnum.semantic_search:
config.semantic_search = updated_config
elif update_type == CameraConfigUpdateEnum.face_recognition:
config.face_recognition = updated_config
elif update_type == CameraConfigUpdateEnum.lpr:
config.lpr = updated_config
elif update_type == CameraConfigUpdateEnum.snapshots:
config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.zones:
+25 -25
View File
@@ -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
+20
View File
@@ -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.",
)
+334
View File
@@ -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,
}