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",
retain=True,
)
available_profiles: list[str] = []
for camera in self.config.cameras.values():
for profile_name in camera.profiles:
if profile_name not in available_profiles:
available_profiles.append(profile_name)
available_profiles = [
{"name": name, "friendly_name": defn.friendly_name}
for name, defn in sorted(self.config.profiles.items())
]
self.publish(
"profiles/available",
json.dumps(sorted(available_profiles)),
json.dumps(available_profiles),
retain=True,
)

View File

@ -68,6 +68,7 @@ from .env import EnvVars
from .logger import LoggerConfig
from .mqtt import MqttConfig
from .network import NetworkingConfig
from .profile import ProfileDefinitionConfig
from .proxy import ProxyConfig
from .telemetry import TelemetryConfig
from .tls import TlsConfig
@ -561,6 +562,12 @@ class FrigateConfig(FrigateBaseModel):
description="Configuration for named camera groups used to organize cameras in the UI.",
)
profiles: Dict[str, ProfileDefinitionConfig] = Field(
default_factory=dict,
title="Profiles",
description="Named profile definitions with friendly names. Camera profiles must reference names defined here.",
)
active_profile: Optional[str] = Field(
default=None,
title="Active profile",
@ -917,6 +924,15 @@ class FrigateConfig(FrigateBaseModel):
verify_objects_track(camera_config, labelmap_objects)
verify_lpr_and_face(self, camera_config)
# Validate camera profiles reference top-level profile definitions
for cam_name, cam_config in self.cameras.items():
for profile_name in cam_config.profiles:
if profile_name not in self.profiles:
raise ValueError(
f"Camera '{cam_name}' references profile '{profile_name}' "
f"which is not defined in the top-level 'profiles' section"
)
# set names on classification configs
for name, config in self.classification.custom.items():
config.name = name

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

View File

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