add top-level profiles config section with friendly names

This commit is contained in:
Josh Hawkins 2026-03-11 19:50:31 -05:00
parent dace54734b
commit 0b3c6ed22e
5 changed files with 123 additions and 38 deletions

View File

@ -169,14 +169,13 @@ class MqttClient(Communicator):
self.config.active_profile or "none", self.config.active_profile or "none",
retain=True, retain=True,
) )
available_profiles: list[str] = [] available_profiles = [
for camera in self.config.cameras.values(): {"name": name, "friendly_name": defn.friendly_name}
for profile_name in camera.profiles: for name, defn in sorted(self.config.profiles.items())
if profile_name not in available_profiles: ]
available_profiles.append(profile_name)
self.publish( self.publish(
"profiles/available", "profiles/available",
json.dumps(sorted(available_profiles)), json.dumps(available_profiles),
retain=True, retain=True,
) )

View File

@ -68,6 +68,7 @@ from .env import EnvVars
from .logger import LoggerConfig from .logger import LoggerConfig
from .mqtt import MqttConfig from .mqtt import MqttConfig
from .network import NetworkingConfig from .network import NetworkingConfig
from .profile import ProfileDefinitionConfig
from .proxy import ProxyConfig from .proxy import ProxyConfig
from .telemetry import TelemetryConfig from .telemetry import TelemetryConfig
from .tls import TlsConfig from .tls import TlsConfig
@ -561,6 +562,12 @@ class FrigateConfig(FrigateBaseModel):
description="Configuration for named camera groups used to organize cameras in the UI.", 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( active_profile: Optional[str] = Field(
default=None, default=None,
title="Active profile", title="Active profile",
@ -917,6 +924,15 @@ class FrigateConfig(FrigateBaseModel):
verify_objects_track(camera_config, labelmap_objects) verify_objects_track(camera_config, labelmap_objects)
verify_lpr_and_face(self, camera_config) 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 # set names on classification configs
for name, config in self.classification.custom.items(): for name, config in self.classification.custom.items():
config.name = name config.name = name

20
frigate/config/profile.py Normal file
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.",
)

View File

@ -81,11 +81,7 @@ class ProfileManager:
# Re-apply profile overrides without publishing ZMQ updates # Re-apply profile overrides without publishing ZMQ updates
# (the config/set caller handles its own ZMQ publishing) # (the config/set caller handles its own ZMQ publishing)
if current_active is not None: if current_active is not None:
has_profile = any( if current_active in self.config.profiles:
current_active in cam.profiles
for cam in self.config.cameras.values()
)
if has_profile:
changed: dict[str, set[str]] = {} changed: dict[str, set[str]] = {}
self._apply_profile_overrides(current_active, changed) self._apply_profile_overrides(current_active, changed)
self.config.active_profile = current_active self.config.active_profile = current_active
@ -104,11 +100,8 @@ class ProfileManager:
None on success, or an error message string on failure. None on success, or an error message string on failure.
""" """
if profile_name is not None: if profile_name is not None:
has_profile = any( if profile_name not in self.config.profiles:
profile_name in cam.profiles for cam in self.config.cameras.values() return f"Profile '{profile_name}' is not defined in the profiles section"
)
if not has_profile:
return f"Profile '{profile_name}' not found on any camera"
# Track which camera/section pairs get changed for ZMQ publishing # Track which camera/section pairs get changed for ZMQ publishing
changed: dict[str, set[str]] = {} changed: dict[str, set[str]] = {}
@ -265,12 +258,12 @@ class ProfileManager:
logger.exception("Failed to load persisted profile") logger.exception("Failed to load persisted profile")
return None return None
def get_available_profiles(self) -> list[str]: def get_available_profiles(self) -> list[dict[str, str]]:
"""Get a deduplicated list of all profile names across cameras.""" """Get list of all profile definitions from the top-level config."""
profiles: set[str] = set() return [
for cam_config in self.config.cameras.values(): {"name": name, "friendly_name": defn.friendly_name}
profiles.update(cam_config.profiles.keys()) for name, defn in sorted(self.config.profiles.items())
return sorted(profiles) ]
def get_profile_info(self) -> dict: def get_profile_info(self) -> dict:
"""Get profile state info for API responses.""" """Get profile state info for API responses."""

View File

@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.profile import CameraProfileConfig from frigate.config.camera.profile import CameraProfileConfig
from frigate.config.profile import ProfileDefinitionConfig
from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
@ -125,6 +126,9 @@ class TestCameraProfileConfig(unittest.TestCase):
config_data = { config_data = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": { "cameras": {
"front": { "front": {
"ffmpeg": { "ffmpeg": {
@ -147,6 +151,37 @@ class TestCameraProfileConfig(unittest.TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
FrigateConfig(**config_data) 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): class TestProfileInConfig(unittest.TestCase):
"""Test that profiles parse correctly in FrigateConfig.""" """Test that profiles parse correctly in FrigateConfig."""
@ -154,6 +189,10 @@ class TestProfileInConfig(unittest.TestCase):
def setUp(self): def setUp(self):
self.base_config = { self.base_config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
"disarmed": {"friendly_name": "Disarmed"},
},
"cameras": { "cameras": {
"front": { "front": {
"ffmpeg": { "ffmpeg": {
@ -244,6 +283,10 @@ class TestProfileManager(unittest.TestCase):
def setUp(self): def setUp(self):
self.config_data = { self.config_data = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
"disarmed": {"friendly_name": "Disarmed"},
},
"cameras": { "cameras": {
"front": { "front": {
"ffmpeg": { "ffmpeg": {
@ -295,17 +338,21 @@ class TestProfileManager(unittest.TestCase):
self.manager = ProfileManager(self.config, self.mock_updater) self.manager = ProfileManager(self.config, self.mock_updater)
def test_get_available_profiles(self): def test_get_available_profiles(self):
"""Available profiles are collected from all cameras.""" """Available profiles come from top-level profile definitions."""
profiles = self.manager.get_available_profiles() profiles = self.manager.get_available_profiles()
assert "armed" in profiles
assert "disarmed" in profiles
assert len(profiles) == 2 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): def test_activate_invalid_profile(self):
"""Activating non-existent profile returns error.""" """Activating non-existent profile returns error."""
err = self.manager.activate_profile("nonexistent") err = self.manager.activate_profile("nonexistent")
assert err is not None assert err is not None
assert "not found" in err assert "not defined" in err
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile(self, mock_persist): def test_activate_profile(self, mock_persist):
@ -368,13 +415,12 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile_disables_camera(self, mock_persist): def test_activate_profile_disables_camera(self, mock_persist):
"""Profile with enabled=false disables the camera.""" """Profile with enabled=false disables the camera."""
# Add a profile that disables the front camera self.config.profiles["away"] = ProfileDefinitionConfig(
from frigate.config.camera.profile import CameraProfileConfig friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig( self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
enabled=False enabled=False
) )
# Re-create manager to pick up new profile
self.manager = ProfileManager(self.config, self.mock_updater) self.manager = ProfileManager(self.config, self.mock_updater)
assert self.config.cameras["front"].enabled is True assert self.config.cameras["front"].enabled is True
@ -385,8 +431,9 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_restores_enabled(self, mock_persist): def test_deactivate_restores_enabled(self, mock_persist):
"""Deactivating a profile restores the camera's base enabled state.""" """Deactivating a profile restores the camera's base enabled state."""
from frigate.config.camera.profile import CameraProfileConfig self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig( self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
enabled=False enabled=False
) )
@ -401,9 +448,11 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile_adds_zone(self, mock_persist): def test_activate_profile_adds_zone(self, mock_persist):
"""Profile with zones adds/overrides zones on camera.""" """Profile with zones adds/overrides zones on camera."""
from frigate.config.camera.profile import CameraProfileConfig
from frigate.config.camera.zone import ZoneConfig from frigate.config.camera.zone import ZoneConfig
self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig( self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
zones={ zones={
"driveway": ZoneConfig( "driveway": ZoneConfig(
@ -423,9 +472,11 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_restores_zones(self, mock_persist): def test_deactivate_restores_zones(self, mock_persist):
"""Deactivating a profile restores base zones.""" """Deactivating a profile restores base zones."""
from frigate.config.camera.profile import CameraProfileConfig
from frigate.config.camera.zone import ZoneConfig from frigate.config.camera.zone import ZoneConfig
self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig( self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
zones={ zones={
"driveway": ZoneConfig( "driveway": ZoneConfig(
@ -445,13 +496,15 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_zones_zmq_published(self, mock_persist): def test_zones_zmq_published(self, mock_persist):
"""ZMQ update is published for zones change.""" """ZMQ update is published for zones change."""
from frigate.config.camera.profile import CameraProfileConfig
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.camera.zone import ZoneConfig from frigate.config.camera.zone import ZoneConfig
self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig( self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
zones={ zones={
"driveway": ZoneConfig( "driveway": ZoneConfig(
@ -476,12 +529,14 @@ class TestProfileManager(unittest.TestCase):
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_enabled_zmq_published(self, mock_persist): def test_enabled_zmq_published(self, mock_persist):
"""ZMQ update is published for enabled state change.""" """ZMQ update is published for enabled state change."""
from frigate.config.camera.profile import CameraProfileConfig
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
self.config.profiles["away"] = ProfileDefinitionConfig(
friendly_name="Away"
)
self.config.cameras["front"].profiles["away"] = CameraProfileConfig( self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
enabled=False enabled=False
) )
@ -507,12 +562,14 @@ class TestProfileManager(unittest.TestCase):
assert self.mock_updater.publish_update.called assert self.mock_updater.publish_update.called
def test_get_profile_info(self): def test_get_profile_info(self):
"""Profile info returns correct structure.""" """Profile info returns correct structure with friendly names."""
info = self.manager.get_profile_info() info = self.manager.get_profile_info()
assert "profiles" in info assert "profiles" in info
assert "active_profile" in info assert "active_profile" in info
assert info["active_profile"] is None assert info["active_profile"] is None
assert "armed" in info["profiles"] names = [p["name"] for p in info["profiles"]]
assert "armed" in names
assert "disarmed" in names
class TestProfilePersistence(unittest.TestCase): class TestProfilePersistence(unittest.TestCase):