mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-13 03:47:34 +03:00
add top-level profiles config section with friendly names
This commit is contained in:
parent
dace54734b
commit
0b3c6ed22e
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
20
frigate/config/profile.py
Normal 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.",
|
||||
)
|
||||
@ -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."""
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user