Profiles fixes (#23306)

* add prop to disable id field

* disable id field when editing profile mask/zone

also, disable if the zone name already exists in required_zones or the base config is being edited and the id already exists on a profile

* add backend validation to reject profile-omly masks/zones

* add tests

* update docs

* tweak
This commit is contained in:
Josh Hawkins
2026-05-25 07:04:00 -06:00
committed by GitHub
parent 90248ef243
commit 2ed70bd693
7 changed files with 255 additions and 8 deletions
+42
View File
@@ -326,6 +326,47 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
)
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
for profile_name, profile in camera_config.profiles.items():
if profile.zones:
for zone_name in profile.zones:
if zone_name not in camera_config.zones:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"zone '{zone_name}' that does not exist on the base config"
)
if profile.motion and profile.motion.mask:
for mask_name in profile.motion.mask:
if mask_name not in camera_config.motion.mask:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"motion mask '{mask_name}' that does not exist on the base config"
)
if profile.objects:
for mask_name in profile.objects.mask or {}:
if mask_name not in (camera_config.objects.mask or {}):
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' that does not exist on the base config"
)
for label, filter_config in (profile.objects.filters or {}).items():
base_filter = (camera_config.objects.filters or {}).get(label)
profile_filter_masks = (
filter_config.mask if filter_config else None
) or {}
base_filter_masks = (base_filter.mask if base_filter else None) or {}
for mask_name in profile_filter_masks:
if mask_name not in base_filter_masks:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' for '{label}' that does not exist "
f"on the base config"
)
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
"""Verify that required_zones are specified when autotracking is enabled."""
if (
@@ -952,6 +993,7 @@ class FrigateConfig(FrigateBaseModel):
verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config)
verify_required_zones_exist(camera_config)
verify_profile_overrides_match_base(camera_config)
verify_autotrack_zones(camera_config)
verify_motion_and_detect(camera_config)
verify_objects_track(camera_config, labelmap_objects)
+135
View File
@@ -178,6 +178,141 @@ class TestCameraProfileConfig(unittest.TestCase):
with self.assertRaises(ValidationError):
FrigateConfig(**config_data)
def test_profile_zone_without_base_rejected(self):
"""Profile defining a zone not present on the base camera 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},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"profiles": {
"armed": {
"zones": {
"phantom": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom", str(ctx.exception))
def test_profile_motion_mask_without_base_rejected(self):
"""Profile defining a motion mask not present on the base camera 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},
"motion": {
"mask": {
"base_mask": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"motion": {
"mask": {
"phantom_mask": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom_mask", str(ctx.exception))
def test_profile_overrides_matching_base_accepted(self):
"""Profile overrides that reference existing base zones/masks parse cleanly."""
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},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"zones": {
"front_yard": {
"coordinates": "0,0,50,0,50,50,0,50",
"inertia": 5,
},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,75,0,75,75,0,75",
},
},
},
},
},
},
},
}
config = FrigateConfig(**config_data)
assert "armed" in config.cameras["front"].profiles
class TestProfileInConfig(unittest.TestCase):
"""Test that profiles parse correctly in FrigateConfig."""