mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-29 19:40:19 +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",
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
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
|
# 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."""
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user