2023-01-13 16:18:15 +03:00
|
|
|
"""Handle communication between Frigate and other applications."""
|
2022-11-24 05:03:20 +03:00
|
|
|
|
2024-04-24 16:44:28 +03:00
|
|
|
import datetime
|
2024-04-30 16:09:50 +03:00
|
|
|
import json
|
2022-11-24 05:03:20 +03:00
|
|
|
import logging
|
2025-08-08 20:25:39 +03:00
|
|
|
from typing import Any, Callable, Optional, cast
|
2022-11-24 05:03:20 +03:00
|
|
|
|
2024-09-27 15:53:23 +03:00
|
|
|
from frigate.camera import PTZMetrics
|
2025-08-25 21:40:21 +03:00
|
|
|
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
2025-02-11 05:47:15 +03:00
|
|
|
from frigate.comms.base_communicator import Communicator
|
|
|
|
|
from frigate.comms.webpush import WebPushClient
|
2023-10-26 14:20:55 +03:00
|
|
|
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
2025-05-22 21:16:51 +03:00
|
|
|
from frigate.config.camera.updater import (
|
|
|
|
|
CameraConfigUpdateEnum,
|
|
|
|
|
CameraConfigUpdatePublisher,
|
|
|
|
|
CameraConfigUpdateTopic,
|
|
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
from frigate.config.profile_manager import ProfileManager
|
2024-02-21 02:26:09 +03:00
|
|
|
from frigate.const import (
|
2024-04-24 16:44:28 +03:00
|
|
|
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
2025-08-25 21:40:21 +03:00
|
|
|
EXPIRE_AUDIO_ACTIVITY,
|
2024-02-21 02:26:09 +03:00
|
|
|
INSERT_MANY_RECORDINGS,
|
|
|
|
|
INSERT_PREVIEW,
|
2025-02-11 05:47:15 +03:00
|
|
|
NOTIFICATION_TEST,
|
2024-02-21 02:26:09 +03:00
|
|
|
REQUEST_REGION_GRID,
|
2025-08-25 21:40:21 +03:00
|
|
|
UPDATE_AUDIO_ACTIVITY,
|
2025-11-24 16:34:56 +03:00
|
|
|
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
2025-06-08 21:06:17 +03:00
|
|
|
UPDATE_BIRDSEYE_LAYOUT,
|
2024-04-30 16:09:50 +03:00
|
|
|
UPDATE_CAMERA_ACTIVITY,
|
2024-10-10 22:28:43 +03:00
|
|
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
2024-06-22 00:30:19 +03:00
|
|
|
UPDATE_EVENT_DESCRIPTION,
|
2026-01-06 18:20:19 +03:00
|
|
|
UPDATE_JOB_STATE,
|
2024-10-07 23:30:45 +03:00
|
|
|
UPDATE_MODEL_STATE,
|
2025-08-10 14:57:54 +03:00
|
|
|
UPDATE_REVIEW_DESCRIPTION,
|
2024-02-21 02:26:09 +03:00
|
|
|
UPSERT_REVIEW_SEGMENT,
|
|
|
|
|
)
|
2024-06-22 00:30:19 +03:00
|
|
|
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
2023-07-08 15:04:47 +03:00
|
|
|
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
2024-11-18 21:26:44 +03:00
|
|
|
from frigate.types import ModelStatusTypesEnum, TrackedObjectUpdateTypesEnum
|
2023-10-19 02:21:52 +03:00
|
|
|
from frigate.util.object import get_camera_regions_grid
|
2023-07-06 17:28:50 +03:00
|
|
|
from frigate.util.services import restart_frigate
|
2022-11-24 05:03:20 +03:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Dispatcher:
|
2023-01-13 16:18:15 +03:00
|
|
|
"""Handle communication between Frigate and communicators."""
|
2022-11-24 05:03:20 +03:00
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
config: FrigateConfig,
|
2025-05-22 21:16:51 +03:00
|
|
|
config_updater: CameraConfigUpdatePublisher,
|
2023-04-26 14:08:53 +03:00
|
|
|
onvif: OnvifController,
|
2024-09-27 15:53:23 +03:00
|
|
|
ptz_metrics: dict[str, PTZMetrics],
|
2022-11-24 05:03:20 +03:00
|
|
|
communicators: list[Communicator],
|
|
|
|
|
) -> None:
|
|
|
|
|
self.config = config
|
2024-02-19 16:26:59 +03:00
|
|
|
self.config_updater = config_updater
|
2023-04-26 14:08:53 +03:00
|
|
|
self.onvif = onvif
|
2023-07-11 14:23:20 +03:00
|
|
|
self.ptz_metrics = ptz_metrics
|
2022-11-24 05:03:20 +03:00
|
|
|
self.comms = communicators
|
2025-01-04 07:11:53 +03:00
|
|
|
self.camera_activity = CameraActivityManager(config, self.publish)
|
2025-08-25 21:40:21 +03:00
|
|
|
self.audio_activity = AudioActivityManager(config, self.publish)
|
2025-08-08 15:08:37 +03:00
|
|
|
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
2026-01-06 18:20:19 +03:00
|
|
|
self.job_state: dict[str, dict[str, Any]] = {} # {job_type: job_data}
|
2025-08-08 15:08:37 +03:00
|
|
|
self.embeddings_reindex: dict[str, Any] = {}
|
|
|
|
|
self.birdseye_layout: dict[str, Any] = {}
|
2025-11-24 16:34:56 +03:00
|
|
|
self.audio_transcription_state: str = "idle"
|
2022-11-24 05:03:20 +03:00
|
|
|
self._camera_settings_handlers: dict[str, Callable] = {
|
2023-07-01 16:18:33 +03:00
|
|
|
"audio": self._on_audio_command,
|
2025-05-27 18:26:00 +03:00
|
|
|
"audio_transcription": self._on_audio_transcription_command,
|
2022-11-24 05:03:20 +03:00
|
|
|
"detect": self._on_detect_command,
|
2025-03-03 18:30:52 +03:00
|
|
|
"enabled": self._on_enabled_command,
|
2022-11-24 05:03:20 +03:00
|
|
|
"improve_contrast": self._on_motion_improve_contrast_command,
|
2023-07-08 15:04:47 +03:00
|
|
|
"ptz_autotracker": self._on_ptz_autotracker_command,
|
2022-11-24 05:03:20 +03:00
|
|
|
"motion": self._on_motion_command,
|
|
|
|
|
"motion_contour_area": self._on_motion_contour_area_command,
|
|
|
|
|
"motion_threshold": self._on_motion_threshold_command,
|
2025-02-11 05:47:15 +03:00
|
|
|
"notifications": self._on_camera_notification_command,
|
2022-11-29 03:58:41 +03:00
|
|
|
"recordings": self._on_recordings_command,
|
2022-11-24 05:03:20 +03:00
|
|
|
"snapshots": self._on_snapshots_command,
|
2023-10-26 14:20:55 +03:00
|
|
|
"birdseye": self._on_birdseye_command,
|
|
|
|
|
"birdseye_mode": self._on_birdseye_mode_command,
|
2025-02-11 17:46:25 +03:00
|
|
|
"review_alerts": self._on_alerts_command,
|
|
|
|
|
"review_detections": self._on_detections_command,
|
2025-08-10 16:38:04 +03:00
|
|
|
"object_descriptions": self._on_object_description_command,
|
|
|
|
|
"review_descriptions": self._on_review_description_command,
|
2026-02-28 17:04:43 +03:00
|
|
|
"motion_mask": self._on_motion_mask_command,
|
|
|
|
|
"object_mask": self._on_object_mask_command,
|
|
|
|
|
"zone": self._on_zone_command,
|
2022-11-24 05:03:20 +03:00
|
|
|
}
|
2024-09-10 20:24:44 +03:00
|
|
|
self._global_settings_handlers: dict[str, Callable] = {
|
2025-02-11 05:47:15 +03:00
|
|
|
"notifications": self._on_global_notification_command,
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
"profile": self._on_profile_command,
|
2024-09-10 20:24:44 +03:00
|
|
|
}
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
self.profile_manager: Optional[ProfileManager] = None
|
2022-11-24 05:03:20 +03:00
|
|
|
|
2023-09-01 15:06:59 +03:00
|
|
|
for comm in self.comms:
|
|
|
|
|
comm.subscribe(self._receive)
|
|
|
|
|
|
2025-02-11 05:47:15 +03:00
|
|
|
self.web_push_client = next(
|
|
|
|
|
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def _receive(self, topic: str, payload: Any) -> Optional[Any]:
|
2022-11-24 05:03:20 +03:00
|
|
|
"""Handle receiving of payload from communicators."""
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_camera_command(
|
2026-02-28 17:04:43 +03:00
|
|
|
command_type: str,
|
|
|
|
|
camera_name: str,
|
|
|
|
|
command: str,
|
|
|
|
|
payload: str,
|
|
|
|
|
sub_command: str | None = None,
|
2025-08-08 15:08:37 +03:00
|
|
|
) -> None:
|
2026-03-04 19:07:34 +03:00
|
|
|
if camera_name not in self.config.cameras:
|
|
|
|
|
return
|
|
|
|
|
|
2023-04-26 14:08:53 +03:00
|
|
|
try:
|
2024-10-10 22:28:43 +03:00
|
|
|
if command_type == "set":
|
2026-04-02 17:15:51 +03:00
|
|
|
# Commands that require a sub-command (mask/zone name)
|
|
|
|
|
sub_command_required = {
|
|
|
|
|
"motion_mask",
|
|
|
|
|
"object_mask",
|
|
|
|
|
"zone",
|
|
|
|
|
}
|
2026-02-28 17:04:43 +03:00
|
|
|
if sub_command:
|
|
|
|
|
self._camera_settings_handlers[command](
|
|
|
|
|
camera_name, sub_command, payload
|
|
|
|
|
)
|
2026-04-02 17:15:51 +03:00
|
|
|
elif command in sub_command_required:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Command %s requires a sub-command (mask/zone name)",
|
|
|
|
|
command,
|
|
|
|
|
)
|
2026-02-28 17:04:43 +03:00
|
|
|
else:
|
|
|
|
|
self._camera_settings_handlers[command](camera_name, payload)
|
2024-10-10 22:28:43 +03:00
|
|
|
elif command_type == "ptz":
|
|
|
|
|
self._on_ptz_command(camera_name, payload)
|
|
|
|
|
except KeyError:
|
2024-10-11 03:48:56 +03:00
|
|
|
logger.error(f"Invalid command type or handler: {command_type}")
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_restart() -> None:
|
2022-11-24 05:03:20 +03:00
|
|
|
restart_frigate()
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_insert_many_recordings() -> None:
|
2023-07-26 13:55:08 +03:00
|
|
|
Recordings.insert_many(payload).execute()
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_request_region_grid() -> Any:
|
2023-10-19 02:21:52 +03:00
|
|
|
camera = payload
|
2026-03-04 19:07:34 +03:00
|
|
|
if camera not in self.config.cameras:
|
|
|
|
|
return None
|
|
|
|
|
|
2024-02-15 03:24:36 +03:00
|
|
|
grid = get_camera_regions_grid(
|
|
|
|
|
camera,
|
|
|
|
|
self.config.cameras[camera].detect,
|
|
|
|
|
max(self.config.model.width, self.config.model.height),
|
2023-10-19 02:21:52 +03:00
|
|
|
)
|
2024-02-15 03:24:36 +03:00
|
|
|
return grid
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_insert_preview() -> None:
|
2023-12-03 17:16:01 +03:00
|
|
|
Previews.insert(payload).execute()
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_upsert_review_segment() -> None:
|
2024-10-10 22:28:43 +03:00
|
|
|
ReviewSegment.insert(payload).on_conflict(
|
|
|
|
|
conflict_target=[ReviewSegment.id],
|
|
|
|
|
update=payload,
|
|
|
|
|
).execute()
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_clear_ongoing_review_segments() -> None:
|
2024-04-24 16:44:28 +03:00
|
|
|
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
|
2024-10-10 22:28:43 +03:00
|
|
|
ReviewSegment.end_time.is_null(True)
|
2024-04-24 16:44:28 +03:00
|
|
|
).execute()
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_update_camera_activity() -> None:
|
2025-01-04 07:11:53 +03:00
|
|
|
self.camera_activity.update_activity(payload)
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-25 21:40:21 +03:00
|
|
|
def handle_update_audio_activity() -> None:
|
|
|
|
|
self.audio_activity.update_activity(payload)
|
|
|
|
|
|
|
|
|
|
def handle_expire_audio_activity() -> None:
|
|
|
|
|
self.audio_activity.expire_all(payload)
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_update_event_description() -> None:
|
2024-06-22 00:30:19 +03:00
|
|
|
event: Event = Event.get(Event.id == payload["id"])
|
2025-08-08 20:25:39 +03:00
|
|
|
cast(dict, event.data)["description"] = payload["description"]
|
2024-06-22 00:30:19 +03:00
|
|
|
event.save()
|
2024-09-24 17:14:51 +03:00
|
|
|
self.publish(
|
2024-11-18 21:26:44 +03:00
|
|
|
"tracked_object_update",
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"type": TrackedObjectUpdateTypesEnum.description,
|
|
|
|
|
"id": event.id,
|
|
|
|
|
"description": event.data["description"],
|
2025-04-29 01:43:03 +03:00
|
|
|
"camera": event.camera,
|
2024-11-18 21:26:44 +03:00
|
|
|
}
|
|
|
|
|
),
|
2024-09-24 17:14:51 +03:00
|
|
|
)
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-10 14:57:54 +03:00
|
|
|
def handle_update_review_description() -> None:
|
|
|
|
|
final_data = payload["after"]
|
|
|
|
|
ReviewSegment.insert(final_data).on_conflict(
|
|
|
|
|
conflict_target=[ReviewSegment.id],
|
|
|
|
|
update=final_data,
|
|
|
|
|
).execute()
|
|
|
|
|
self.publish("reviews", json.dumps(payload))
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_update_model_state() -> None:
|
2024-10-11 19:47:23 +03:00
|
|
|
if payload:
|
|
|
|
|
model = payload["model"]
|
|
|
|
|
state = payload["state"]
|
|
|
|
|
self.model_state[model] = ModelStatusTypesEnum[state]
|
|
|
|
|
self.publish("model_state", json.dumps(self.model_state))
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_model_state() -> None:
|
2024-10-10 22:28:43 +03:00
|
|
|
self.publish("model_state", json.dumps(self.model_state.copy()))
|
|
|
|
|
|
2026-01-06 18:20:19 +03:00
|
|
|
def handle_update_job_state() -> None:
|
|
|
|
|
if payload and isinstance(payload, dict):
|
|
|
|
|
job_type = payload.get("job_type")
|
|
|
|
|
if job_type:
|
|
|
|
|
self.job_state[job_type] = payload
|
|
|
|
|
self.publish(
|
|
|
|
|
"job_state",
|
|
|
|
|
json.dumps(self.job_state),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def handle_job_state() -> None:
|
|
|
|
|
self.publish("job_state", json.dumps(self.job_state.copy()))
|
|
|
|
|
|
2025-11-24 16:34:56 +03:00
|
|
|
def handle_update_audio_transcription_state() -> None:
|
|
|
|
|
if payload:
|
|
|
|
|
self.audio_transcription_state = payload
|
|
|
|
|
self.publish(
|
|
|
|
|
"audio_transcription_state",
|
|
|
|
|
json.dumps(self.audio_transcription_state),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def handle_audio_transcription_state() -> None:
|
|
|
|
|
self.publish(
|
|
|
|
|
"audio_transcription_state", json.dumps(self.audio_transcription_state)
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_update_embeddings_reindex_progress() -> None:
|
2024-10-10 22:28:43 +03:00
|
|
|
self.embeddings_reindex = payload
|
|
|
|
|
self.publish(
|
|
|
|
|
"embeddings_reindex_progress",
|
|
|
|
|
json.dumps(payload),
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_embeddings_reindex_progress() -> None:
|
2024-10-10 22:28:43 +03:00
|
|
|
self.publish(
|
|
|
|
|
"embeddings_reindex_progress",
|
|
|
|
|
json.dumps(self.embeddings_reindex.copy()),
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_update_birdseye_layout() -> None:
|
2025-06-08 21:06:17 +03:00
|
|
|
if payload:
|
|
|
|
|
self.birdseye_layout = payload
|
|
|
|
|
self.publish("birdseye_layout", json.dumps(self.birdseye_layout))
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_birdseye_layout() -> None:
|
2025-06-08 21:06:17 +03:00
|
|
|
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
|
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_on_connect() -> None:
|
2026-03-04 19:07:34 +03:00
|
|
|
camera_status = {
|
|
|
|
|
camera: status
|
|
|
|
|
for camera, status in self.camera_activity.last_camera_activity.copy().items()
|
|
|
|
|
if camera in self.config.cameras
|
|
|
|
|
}
|
2025-08-25 21:40:21 +03:00
|
|
|
audio_detections = self.audio_activity.current_audio_detections.copy()
|
2025-03-20 19:20:44 +03:00
|
|
|
cameras_with_status = camera_status.keys()
|
|
|
|
|
|
|
|
|
|
for camera in self.config.cameras.keys():
|
|
|
|
|
if camera not in cameras_with_status:
|
|
|
|
|
camera_status[camera] = {}
|
2024-08-06 18:08:43 +03:00
|
|
|
|
|
|
|
|
camera_status[camera]["config"] = {
|
|
|
|
|
"detect": self.config.cameras[camera].detect.enabled,
|
2025-03-03 18:30:52 +03:00
|
|
|
"enabled": self.config.cameras[camera].enabled,
|
2024-08-06 18:08:43 +03:00
|
|
|
"snapshots": self.config.cameras[camera].snapshots.enabled,
|
|
|
|
|
"record": self.config.cameras[camera].record.enabled,
|
|
|
|
|
"audio": self.config.cameras[camera].audio.enabled,
|
2025-05-27 18:26:00 +03:00
|
|
|
"audio_transcription": self.config.cameras[
|
|
|
|
|
camera
|
|
|
|
|
].audio_transcription.live_enabled,
|
2025-02-11 05:47:15 +03:00
|
|
|
"notifications": self.config.cameras[camera].notifications.enabled,
|
|
|
|
|
"notifications_suspended": int(
|
|
|
|
|
self.web_push_client.suspended_cameras.get(camera, 0)
|
|
|
|
|
)
|
|
|
|
|
if self.web_push_client
|
|
|
|
|
and camera in self.web_push_client.suspended_cameras
|
|
|
|
|
else 0,
|
2024-08-06 18:08:43 +03:00
|
|
|
"autotracking": self.config.cameras[
|
|
|
|
|
camera
|
|
|
|
|
].onvif.autotracking.enabled,
|
2025-02-11 17:46:25 +03:00
|
|
|
"alerts": self.config.cameras[camera].review.alerts.enabled,
|
|
|
|
|
"detections": self.config.cameras[camera].review.detections.enabled,
|
2025-08-10 16:38:04 +03:00
|
|
|
"object_descriptions": self.config.cameras[
|
|
|
|
|
camera
|
|
|
|
|
].objects.genai.enabled,
|
|
|
|
|
"review_descriptions": self.config.cameras[
|
|
|
|
|
camera
|
|
|
|
|
].review.genai.enabled,
|
2024-08-06 18:08:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.publish("camera_activity", json.dumps(camera_status))
|
2024-10-14 04:36:49 +03:00
|
|
|
self.publish("model_state", json.dumps(self.model_state.copy()))
|
|
|
|
|
self.publish(
|
|
|
|
|
"embeddings_reindex_progress",
|
|
|
|
|
json.dumps(self.embeddings_reindex.copy()),
|
|
|
|
|
)
|
2025-06-08 21:06:17 +03:00
|
|
|
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
2025-08-25 21:40:21 +03:00
|
|
|
self.publish("audio_detections", json.dumps(audio_detections))
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
self.publish(
|
|
|
|
|
"profile/state",
|
|
|
|
|
self.config.active_profile or "none",
|
|
|
|
|
retain=True,
|
|
|
|
|
)
|
2024-10-10 22:28:43 +03:00
|
|
|
|
2025-08-08 15:08:37 +03:00
|
|
|
def handle_notification_test() -> None:
|
2025-02-11 05:47:15 +03:00
|
|
|
self.publish("notification_test", "Test notification")
|
|
|
|
|
|
2024-10-10 22:28:43 +03:00
|
|
|
# Dictionary mapping topic to handlers
|
|
|
|
|
topic_handlers = {
|
|
|
|
|
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
|
|
|
|
|
REQUEST_REGION_GRID: handle_request_region_grid,
|
|
|
|
|
INSERT_PREVIEW: handle_insert_preview,
|
|
|
|
|
UPSERT_REVIEW_SEGMENT: handle_upsert_review_segment,
|
|
|
|
|
CLEAR_ONGOING_REVIEW_SEGMENTS: handle_clear_ongoing_review_segments,
|
|
|
|
|
UPDATE_CAMERA_ACTIVITY: handle_update_camera_activity,
|
2025-08-25 21:40:21 +03:00
|
|
|
UPDATE_AUDIO_ACTIVITY: handle_update_audio_activity,
|
|
|
|
|
EXPIRE_AUDIO_ACTIVITY: handle_expire_audio_activity,
|
2024-10-10 22:28:43 +03:00
|
|
|
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
2025-08-10 14:57:54 +03:00
|
|
|
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
2024-10-10 22:28:43 +03:00
|
|
|
UPDATE_MODEL_STATE: handle_update_model_state,
|
2026-01-06 18:20:19 +03:00
|
|
|
UPDATE_JOB_STATE: handle_update_job_state,
|
2024-10-10 22:28:43 +03:00
|
|
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
2025-06-08 21:06:17 +03:00
|
|
|
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
2025-11-24 16:34:56 +03:00
|
|
|
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
2025-02-11 05:47:15 +03:00
|
|
|
NOTIFICATION_TEST: handle_notification_test,
|
2024-10-10 22:28:43 +03:00
|
|
|
"restart": handle_restart,
|
|
|
|
|
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
|
|
|
|
"modelState": handle_model_state,
|
2026-01-06 18:20:19 +03:00
|
|
|
"jobState": handle_job_state,
|
2025-11-24 16:34:56 +03:00
|
|
|
"audioTranscriptionState": handle_audio_transcription_state,
|
2025-06-08 21:06:17 +03:00
|
|
|
"birdseyeLayout": handle_birdseye_layout,
|
2024-10-10 22:28:43 +03:00
|
|
|
"onConnect": handle_on_connect,
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-11 05:47:15 +03:00
|
|
|
if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"):
|
2024-10-10 22:28:43 +03:00
|
|
|
try:
|
|
|
|
|
parts = topic.split("/")
|
|
|
|
|
if len(parts) == 3 and topic.endswith("set"):
|
|
|
|
|
# example /cam_name/detect/set payload=ON|OFF
|
|
|
|
|
camera_name = parts[-3]
|
|
|
|
|
command = parts[-2]
|
2024-10-11 03:48:56 +03:00
|
|
|
handle_camera_command("set", camera_name, command, payload)
|
2026-02-28 17:04:43 +03:00
|
|
|
elif len(parts) == 4 and topic.endswith("set"):
|
|
|
|
|
# example /cam_name/motion_mask/mask_name/set payload=ON|OFF
|
|
|
|
|
camera_name = parts[-4]
|
|
|
|
|
command = parts[-3]
|
|
|
|
|
sub_command = parts[-2]
|
|
|
|
|
handle_camera_command(
|
|
|
|
|
"set", camera_name, command, payload, sub_command
|
|
|
|
|
)
|
2024-10-10 22:28:43 +03:00
|
|
|
elif len(parts) == 2 and topic.endswith("set"):
|
|
|
|
|
command = parts[-2]
|
|
|
|
|
self._global_settings_handlers[command](payload)
|
|
|
|
|
elif len(parts) == 2 and topic.endswith("ptz"):
|
|
|
|
|
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
|
|
|
|
|
camera_name = parts[-2]
|
2024-10-11 03:48:56 +03:00
|
|
|
handle_camera_command("ptz", camera_name, "", payload)
|
2025-02-11 05:47:15 +03:00
|
|
|
elif len(parts) == 3 and topic.endswith("suspend"):
|
|
|
|
|
# example /cam_name/notifications/suspend payload=duration
|
|
|
|
|
camera_name = parts[-3]
|
|
|
|
|
command = parts[-2]
|
2026-03-04 19:07:34 +03:00
|
|
|
if camera_name in self.config.cameras:
|
|
|
|
|
self._on_camera_notification_suspend(camera_name, payload)
|
2024-10-10 22:28:43 +03:00
|
|
|
except IndexError:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
|
|
|
|
)
|
2025-08-08 15:08:37 +03:00
|
|
|
return None
|
2024-10-10 22:28:43 +03:00
|
|
|
elif topic in topic_handlers:
|
|
|
|
|
return topic_handlers[topic]()
|
2023-07-14 03:52:33 +03:00
|
|
|
else:
|
|
|
|
|
self.publish(topic, payload, retain=False)
|
2025-08-08 15:08:37 +03:00
|
|
|
return None
|
2022-11-24 05:03:20 +03:00
|
|
|
|
|
|
|
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
|
|
|
|
"""Handle publishing to communicators."""
|
|
|
|
|
for comm in self.comms:
|
|
|
|
|
comm.publish(topic, payload, retain)
|
|
|
|
|
|
2023-02-04 05:15:47 +03:00
|
|
|
def stop(self) -> None:
|
|
|
|
|
for comm in self.comms:
|
|
|
|
|
comm.stop()
|
|
|
|
|
|
2022-11-24 05:03:20 +03:00
|
|
|
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for detect topic."""
|
|
|
|
|
detect_settings = self.config.cameras[camera_name].detect
|
2024-02-19 16:26:59 +03:00
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
2022-11-24 05:03:20 +03:00
|
|
|
|
|
|
|
|
if payload == "ON":
|
2024-02-19 16:26:59 +03:00
|
|
|
if not detect_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning on detection for {camera_name}")
|
|
|
|
|
detect_settings.enabled = True
|
|
|
|
|
|
2024-02-19 16:26:59 +03:00
|
|
|
if not motion_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(
|
|
|
|
|
f"Turning on motion for {camera_name} due to detection being enabled."
|
|
|
|
|
)
|
2024-02-19 16:26:59 +03:00
|
|
|
motion_settings.enabled = True
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(
|
|
|
|
|
CameraConfigUpdateEnum.motion, camera_name
|
|
|
|
|
),
|
|
|
|
|
motion_settings,
|
2024-02-19 16:26:59 +03:00
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/motion/state", payload, retain=True)
|
|
|
|
|
elif payload == "OFF":
|
2024-02-19 16:26:59 +03:00
|
|
|
if detect_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning off detection for {camera_name}")
|
|
|
|
|
detect_settings.enabled = False
|
|
|
|
|
|
2025-05-23 20:05:04 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
|
|
|
|
|
detect_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/detect/state", payload, retain=True)
|
|
|
|
|
|
2025-03-03 18:30:52 +03:00
|
|
|
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for camera topic."""
|
|
|
|
|
camera_settings = self.config.cameras[camera_name]
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[camera_name].enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Camera must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
if not camera_settings.enabled:
|
|
|
|
|
logger.info(f"Turning on camera {camera_name}")
|
|
|
|
|
camera_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if camera_settings.enabled:
|
|
|
|
|
logger.info(f"Turning off camera {camera_name}")
|
|
|
|
|
camera_settings.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
|
|
|
|
|
camera_settings.enabled,
|
|
|
|
|
)
|
2025-03-03 18:30:52 +03:00
|
|
|
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
|
|
|
|
|
|
2022-11-24 05:03:20 +03:00
|
|
|
def _on_motion_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for motion topic."""
|
2024-02-19 16:26:59 +03:00
|
|
|
detect_settings = self.config.cameras[camera_name].detect
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
|
|
2022-11-24 05:03:20 +03:00
|
|
|
if payload == "ON":
|
2024-02-19 16:26:59 +03:00
|
|
|
if not motion_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning on motion for {camera_name}")
|
2024-02-19 16:26:59 +03:00
|
|
|
motion_settings.enabled = True
|
2022-11-24 05:03:20 +03:00
|
|
|
elif payload == "OFF":
|
2024-02-19 16:26:59 +03:00
|
|
|
if detect_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.error(
|
2023-05-29 13:31:17 +03:00
|
|
|
"Turning off motion is not allowed when detection is enabled."
|
2022-11-24 05:03:20 +03:00
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2024-02-19 16:26:59 +03:00
|
|
|
if motion_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning off motion for {camera_name}")
|
2024-02-19 16:26:59 +03:00
|
|
|
motion_settings.enabled = False
|
2022-11-24 05:03:20 +03:00
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
|
|
|
|
|
motion_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/motion/state", payload, retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_motion_improve_contrast_command(
|
|
|
|
|
self, camera_name: str, payload: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Callback for improve_contrast topic."""
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
2024-02-19 16:26:59 +03:00
|
|
|
if not motion_settings.improve_contrast:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning on improve contrast for {camera_name}")
|
2025-08-08 15:08:37 +03:00
|
|
|
motion_settings.improve_contrast = True
|
2022-11-24 05:03:20 +03:00
|
|
|
elif payload == "OFF":
|
2024-02-19 16:26:59 +03:00
|
|
|
if motion_settings.improve_contrast:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning off improve contrast for {camera_name}")
|
2025-08-08 15:08:37 +03:00
|
|
|
motion_settings.improve_contrast = False
|
2022-11-24 05:03:20 +03:00
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
|
|
|
|
|
motion_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/improve_contrast/state", payload, retain=True)
|
|
|
|
|
|
2023-07-08 15:04:47 +03:00
|
|
|
def _on_ptz_autotracker_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for ptz_autotracker topic."""
|
|
|
|
|
ptz_autotracker_settings = self.config.cameras[camera_name].onvif.autotracking
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
2023-11-16 04:25:48 +03:00
|
|
|
if not self.config.cameras[
|
|
|
|
|
camera_name
|
|
|
|
|
].onvif.autotracking.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Autotracking must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
2024-09-27 15:53:23 +03:00
|
|
|
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
|
2023-07-08 15:04:47 +03:00
|
|
|
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
2024-09-27 15:53:23 +03:00
|
|
|
self.ptz_metrics[camera_name].autotracker_enabled.value = True
|
|
|
|
|
self.ptz_metrics[camera_name].start_time.value = 0
|
2023-07-08 15:04:47 +03:00
|
|
|
ptz_autotracker_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
2024-09-27 15:53:23 +03:00
|
|
|
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
2023-07-08 15:04:47 +03:00
|
|
|
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
2024-09-27 15:53:23 +03:00
|
|
|
self.ptz_metrics[camera_name].autotracker_enabled.value = False
|
|
|
|
|
self.ptz_metrics[camera_name].start_time.value = 0
|
2023-07-08 15:04:47 +03:00
|
|
|
ptz_autotracker_settings.enabled = False
|
|
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
|
|
|
|
|
|
2022-11-24 05:03:20 +03:00
|
|
|
def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None:
|
|
|
|
|
"""Callback for motion contour topic."""
|
|
|
|
|
try:
|
|
|
|
|
payload = int(payload)
|
|
|
|
|
except ValueError:
|
|
|
|
|
f"Received unsupported value for motion contour area: {payload}"
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
|
logger.info(f"Setting motion contour area for {camera_name}: {payload}")
|
2025-08-08 15:08:37 +03:00
|
|
|
motion_settings.contour_area = payload
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
|
|
|
|
|
motion_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/motion_contour_area/state", payload, retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_motion_threshold_command(self, camera_name: str, payload: int) -> None:
|
|
|
|
|
"""Callback for motion threshold topic."""
|
|
|
|
|
try:
|
|
|
|
|
payload = int(payload)
|
|
|
|
|
except ValueError:
|
|
|
|
|
f"Received unsupported value for motion threshold: {payload}"
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
|
logger.info(f"Setting motion threshold for {camera_name}: {payload}")
|
2025-08-08 15:08:37 +03:00
|
|
|
motion_settings.threshold = payload
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
|
|
|
|
|
motion_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True)
|
|
|
|
|
|
2025-02-11 05:47:15 +03:00
|
|
|
def _on_global_notification_command(self, payload: str) -> None:
|
|
|
|
|
"""Callback for global notification topic."""
|
2024-09-10 20:24:44 +03:00
|
|
|
if payload != "ON" and payload != "OFF":
|
2025-02-11 05:47:15 +03:00
|
|
|
f"Received unsupported value for all notification: {payload}"
|
2024-09-10 20:24:44 +03:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
notification_settings = self.config.notifications
|
2025-02-11 05:47:15 +03:00
|
|
|
logger.info(f"Setting all notifications: {payload}")
|
2025-08-08 15:08:37 +03:00
|
|
|
notification_settings.enabled = payload == "ON"
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publisher.publish(
|
|
|
|
|
"config/notifications", notification_settings
|
2025-02-11 05:47:15 +03:00
|
|
|
)
|
2024-09-10 20:24:44 +03:00
|
|
|
self.publish("notifications/state", payload, retain=True)
|
|
|
|
|
|
Camera profile support (#22482)
* add CameraProfileConfig model for named config overrides
* add profiles field to CameraConfig
* add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
* add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
* add profile API endpoints (GET /profiles, GET/PUT /profile)
* add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
* wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
* add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
* formatting
* fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
* consolidate
* add enabled field to camera profiles for enabling/disabling cameras
* add zones support to camera profiles
* add frontend profile types, color utility, and config save support
* add profile state management and save preview support
* add profileName prop to BaseSection for profile-aware config editing
* add profile section dropdown and wire into camera settings pages
* add per-profile camera enable/disable to Camera Management view
* add profiles summary page with card-based layout and fix backend zone comparison bug
* add active profile badge to settings toolbar
* i18n
* add red dot for any pending changes including profiles
* profile support for mask and zone editor
* fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
* add face_recognition and lpr to profile-eligible sections
* move profile dropdown from section panes to settings header
* add profiles enable toggle and improve empty state
* formatting
* tweaks
* tweak colors and switch
* fix profile save diff, masksAndZones delete, and config sync
* ui tweaks
* ensure profile manager gets updated config
* rename profile settings to ui settings
* refactor profilesview and add dots/border colors when overridden
* implement an update_config method for profile manager
* fix mask deletion
* more unique colors
* add top-level profiles config section with friendly names
* implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
* remove profile badge in settings and add profiles to main menu
* use icon only on mobile
* change color order
* docs
* show activity indicator on trash icon while deleting a profile
* tweak language
* immediately create profiles on backend instead of deferring to Save All
* hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
* show active profile indicator in desktop status bar
* fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.
Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
* docs tweaks
* docs tweak
* formatting
* formatting
* fix typing
* fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
* remove
* fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.
Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.
Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
* use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
* fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
* formatting
* don't require restart for camera enabled change for profiles
* publish camera state when changing profiles
* formatting
* remove available profiles from mqtt
* improve typing
2026-03-19 17:47:57 +03:00
|
|
|
def _on_profile_command(self, payload: str) -> None:
|
|
|
|
|
"""Callback for profile/set topic."""
|
|
|
|
|
if self.profile_manager is None:
|
|
|
|
|
logger.error("Profile manager not initialized")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
profile_name = (
|
|
|
|
|
payload.strip() if payload.strip() not in ("", "none", "None") else None
|
|
|
|
|
)
|
|
|
|
|
err = self.profile_manager.activate_profile(profile_name)
|
|
|
|
|
if err:
|
|
|
|
|
logger.error("Failed to activate profile: %s", err)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self.publish("profile/state", payload.strip() or "none", retain=True)
|
|
|
|
|
|
2023-07-01 16:18:33 +03:00
|
|
|
def _on_audio_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for audio topic."""
|
|
|
|
|
audio_settings = self.config.cameras[camera_name].audio
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[camera_name].audio.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Audio detection must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not audio_settings.enabled:
|
|
|
|
|
logger.info(f"Turning on audio detection for {camera_name}")
|
|
|
|
|
audio_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
2024-02-19 16:26:59 +03:00
|
|
|
if audio_settings.enabled:
|
2023-07-01 16:18:33 +03:00
|
|
|
logger.info(f"Turning off audio detection for {camera_name}")
|
|
|
|
|
audio_settings.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
|
|
|
|
|
audio_settings,
|
|
|
|
|
)
|
2023-07-01 16:18:33 +03:00
|
|
|
self.publish(f"{camera_name}/audio/state", payload, retain=True)
|
|
|
|
|
|
2025-05-27 18:26:00 +03:00
|
|
|
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for live audio transcription topic."""
|
|
|
|
|
audio_transcription_settings = self.config.cameras[
|
|
|
|
|
camera_name
|
|
|
|
|
].audio_transcription
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[
|
|
|
|
|
camera_name
|
|
|
|
|
].audio_transcription.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Audio transcription must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not audio_transcription_settings.live_enabled:
|
|
|
|
|
logger.info(f"Turning on live audio transcription for {camera_name}")
|
|
|
|
|
audio_transcription_settings.live_enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if audio_transcription_settings.live_enabled:
|
|
|
|
|
logger.info(f"Turning off live audio transcription for {camera_name}")
|
|
|
|
|
audio_transcription_settings.live_enabled = False
|
|
|
|
|
|
|
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(
|
|
|
|
|
CameraConfigUpdateEnum.audio_transcription, camera_name
|
|
|
|
|
),
|
|
|
|
|
audio_transcription_settings,
|
|
|
|
|
)
|
|
|
|
|
self.publish(f"{camera_name}/audio_transcription/state", payload, retain=True)
|
|
|
|
|
|
2022-11-24 05:03:20 +03:00
|
|
|
def _on_recordings_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for recordings topic."""
|
|
|
|
|
record_settings = self.config.cameras[camera_name].record
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
2023-05-15 15:36:26 +03:00
|
|
|
if not self.config.cameras[camera_name].record.enabled_in_config:
|
|
|
|
|
logger.error(
|
2023-05-29 13:31:17 +03:00
|
|
|
"Recordings must be enabled in the config to be turned on via MQTT."
|
2023-05-15 15:36:26 +03:00
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2022-11-24 05:03:20 +03:00
|
|
|
if not record_settings.enabled:
|
|
|
|
|
logger.info(f"Turning on recordings for {camera_name}")
|
|
|
|
|
record_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
2024-02-19 16:26:59 +03:00
|
|
|
if record_settings.enabled:
|
2022-11-24 05:03:20 +03:00
|
|
|
logger.info(f"Turning off recordings for {camera_name}")
|
|
|
|
|
record_settings.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
|
|
|
|
|
record_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for snapshots topic."""
|
|
|
|
|
snapshots_settings = self.config.cameras[camera_name].snapshots
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not snapshots_settings.enabled:
|
|
|
|
|
logger.info(f"Turning on snapshots for {camera_name}")
|
|
|
|
|
snapshots_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if snapshots_settings.enabled:
|
|
|
|
|
logger.info(f"Turning off snapshots for {camera_name}")
|
|
|
|
|
snapshots_settings.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
|
|
|
|
|
snapshots_settings,
|
|
|
|
|
)
|
2022-11-24 05:03:20 +03:00
|
|
|
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
|
2023-04-26 14:08:53 +03:00
|
|
|
|
2025-12-09 21:08:44 +03:00
|
|
|
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None:
|
2023-04-26 14:08:53 +03:00
|
|
|
"""Callback for ptz topic."""
|
|
|
|
|
try:
|
2025-12-09 21:08:44 +03:00
|
|
|
preset: str = (
|
|
|
|
|
payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
|
|
|
).lower()
|
|
|
|
|
|
|
|
|
|
if "preset" in preset:
|
2023-04-26 14:08:53 +03:00
|
|
|
command = OnvifCommandEnum.preset
|
2025-12-09 21:08:44 +03:00
|
|
|
param = preset[preset.index("_") + 1 :]
|
|
|
|
|
elif "move_relative" in preset:
|
2024-03-23 19:53:33 +03:00
|
|
|
command = OnvifCommandEnum.move_relative
|
2025-12-09 21:08:44 +03:00
|
|
|
param = preset[preset.index("_") + 1 :]
|
2023-04-26 14:08:53 +03:00
|
|
|
else:
|
2025-12-09 21:08:44 +03:00
|
|
|
command = OnvifCommandEnum[preset]
|
2023-04-26 14:08:53 +03:00
|
|
|
param = ""
|
|
|
|
|
|
|
|
|
|
self.onvif.handle_command(camera_name, command, param)
|
|
|
|
|
logger.info(f"Setting ptz command to {command} for {camera_name}")
|
|
|
|
|
except KeyError as k:
|
2025-12-09 21:08:44 +03:00
|
|
|
logger.error(f"Invalid PTZ command {preset}: {k}")
|
2023-10-26 14:20:55 +03:00
|
|
|
|
|
|
|
|
def _on_birdseye_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for birdseye topic."""
|
|
|
|
|
birdseye_settings = self.config.cameras[camera_name].birdseye
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
2024-02-19 16:26:59 +03:00
|
|
|
if not birdseye_settings.enabled:
|
2023-10-26 14:20:55 +03:00
|
|
|
logger.info(f"Turning on birdseye for {camera_name}")
|
|
|
|
|
birdseye_settings.enabled = True
|
|
|
|
|
|
|
|
|
|
elif payload == "OFF":
|
2024-02-19 16:26:59 +03:00
|
|
|
if birdseye_settings.enabled:
|
2023-10-26 14:20:55 +03:00
|
|
|
logger.info(f"Turning off birdseye for {camera_name}")
|
|
|
|
|
birdseye_settings.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name),
|
|
|
|
|
birdseye_settings,
|
|
|
|
|
)
|
2023-10-26 14:20:55 +03:00
|
|
|
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for birdseye mode topic."""
|
|
|
|
|
|
|
|
|
|
if payload not in ["CONTINUOUS", "MOTION", "OBJECTS"]:
|
|
|
|
|
logger.info(f"Invalid birdseye_mode command: {payload}")
|
|
|
|
|
return
|
|
|
|
|
|
2024-02-19 16:26:59 +03:00
|
|
|
birdseye_settings = self.config.cameras[camera_name].birdseye
|
|
|
|
|
|
|
|
|
|
if not birdseye_settings.enabled:
|
2023-10-26 14:20:55 +03:00
|
|
|
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
|
|
|
|
return
|
|
|
|
|
|
2024-02-19 16:26:59 +03:00
|
|
|
birdseye_settings.mode = BirdseyeModeEnum(payload.lower())
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}"
|
|
|
|
|
)
|
2023-10-26 14:20:55 +03:00
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name),
|
|
|
|
|
birdseye_settings,
|
|
|
|
|
)
|
2023-10-26 14:20:55 +03:00
|
|
|
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|
2025-02-11 05:47:15 +03:00
|
|
|
|
|
|
|
|
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for camera level notifications topic."""
|
|
|
|
|
notification_settings = self.config.cameras[camera_name].notifications
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[camera_name].notifications.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Notifications must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not notification_settings.enabled:
|
|
|
|
|
logger.info(f"Turning on notifications for {camera_name}")
|
|
|
|
|
notification_settings.enabled = True
|
|
|
|
|
if (
|
|
|
|
|
self.web_push_client
|
|
|
|
|
and camera_name in self.web_push_client.suspended_cameras
|
|
|
|
|
):
|
|
|
|
|
self.web_push_client.suspended_cameras[camera_name] = 0
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if notification_settings.enabled:
|
|
|
|
|
logger.info(f"Turning off notifications for {camera_name}")
|
|
|
|
|
notification_settings.enabled = False
|
|
|
|
|
if (
|
|
|
|
|
self.web_push_client
|
|
|
|
|
and camera_name in self.web_push_client.suspended_cameras
|
|
|
|
|
):
|
|
|
|
|
self.web_push_client.suspended_cameras[camera_name] = 0
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.notifications, camera_name),
|
|
|
|
|
notification_settings,
|
2025-02-11 05:47:15 +03:00
|
|
|
)
|
|
|
|
|
self.publish(f"{camera_name}/notifications/state", payload, retain=True)
|
|
|
|
|
self.publish(f"{camera_name}/notifications/suspended", "0", retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_camera_notification_suspend(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for camera level notifications suspend topic."""
|
|
|
|
|
try:
|
|
|
|
|
duration = int(payload)
|
|
|
|
|
except ValueError:
|
|
|
|
|
logger.error(f"Invalid suspension duration: {payload}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.web_push_client is None:
|
|
|
|
|
logger.error("WebPushClient not available for suspension")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
notification_settings = self.config.cameras[camera_name].notifications
|
|
|
|
|
|
|
|
|
|
if not notification_settings.enabled:
|
|
|
|
|
logger.error(f"Notifications are not enabled for {camera_name}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if duration != 0:
|
|
|
|
|
self.web_push_client.suspend_notifications(camera_name, duration)
|
|
|
|
|
else:
|
|
|
|
|
self.web_push_client.unsuspend_notifications(camera_name)
|
|
|
|
|
|
|
|
|
|
self.publish(
|
|
|
|
|
f"{camera_name}/notifications/suspended",
|
|
|
|
|
str(
|
|
|
|
|
int(self.web_push_client.suspended_cameras.get(camera_name, 0))
|
|
|
|
|
if camera_name in self.web_push_client.suspended_cameras
|
|
|
|
|
else 0
|
|
|
|
|
),
|
|
|
|
|
retain=True,
|
|
|
|
|
)
|
2025-02-11 17:46:25 +03:00
|
|
|
|
|
|
|
|
def _on_alerts_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for alerts topic."""
|
|
|
|
|
review_settings = self.config.cameras[camera_name].review
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[camera_name].review.alerts.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Alerts must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not review_settings.alerts.enabled:
|
|
|
|
|
logger.info(f"Turning on alerts for {camera_name}")
|
|
|
|
|
review_settings.alerts.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if review_settings.alerts.enabled:
|
|
|
|
|
logger.info(f"Turning off alerts for {camera_name}")
|
|
|
|
|
review_settings.alerts.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.review, camera_name),
|
|
|
|
|
review_settings,
|
|
|
|
|
)
|
2025-02-11 17:46:25 +03:00
|
|
|
self.publish(f"{camera_name}/review_alerts/state", payload, retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_detections_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for detections topic."""
|
|
|
|
|
review_settings = self.config.cameras[camera_name].review
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[camera_name].review.detections.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Detections must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not review_settings.detections.enabled:
|
|
|
|
|
logger.info(f"Turning on detections for {camera_name}")
|
|
|
|
|
review_settings.detections.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if review_settings.detections.enabled:
|
|
|
|
|
logger.info(f"Turning off detections for {camera_name}")
|
|
|
|
|
review_settings.detections.enabled = False
|
|
|
|
|
|
2025-05-22 21:16:51 +03:00
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.review, camera_name),
|
|
|
|
|
review_settings,
|
|
|
|
|
)
|
2025-02-11 17:46:25 +03:00
|
|
|
self.publish(f"{camera_name}/review_detections/state", payload, retain=True)
|
2025-07-14 15:58:43 +03:00
|
|
|
|
2025-08-10 16:38:04 +03:00
|
|
|
def _on_object_description_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for object description topic."""
|
2025-08-09 01:33:11 +03:00
|
|
|
genai_settings = self.config.cameras[camera_name].objects.genai
|
2025-07-14 15:58:43 +03:00
|
|
|
|
|
|
|
|
if payload == "ON":
|
2025-08-09 01:33:11 +03:00
|
|
|
if not self.config.cameras[camera_name].objects.genai.enabled_in_config:
|
2025-07-14 15:58:43 +03:00
|
|
|
logger.error(
|
|
|
|
|
"GenAI must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not genai_settings.enabled:
|
2025-08-10 16:38:04 +03:00
|
|
|
logger.info(f"Turning on object descriptions for {camera_name}")
|
|
|
|
|
genai_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if genai_settings.enabled:
|
|
|
|
|
logger.info(f"Turning off object descriptions for {camera_name}")
|
|
|
|
|
genai_settings.enabled = False
|
|
|
|
|
|
|
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.object_genai, camera_name),
|
|
|
|
|
genai_settings,
|
|
|
|
|
)
|
|
|
|
|
self.publish(f"{camera_name}/object_descriptions/state", payload, retain=True)
|
|
|
|
|
|
|
|
|
|
def _on_review_description_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for review description topic."""
|
|
|
|
|
genai_settings = self.config.cameras[camera_name].review.genai
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not self.config.cameras[camera_name].review.genai.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
"GenAI Alerts or Detections must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not genai_settings.enabled:
|
|
|
|
|
logger.info(f"Turning on review descriptions for {camera_name}")
|
2025-07-14 15:58:43 +03:00
|
|
|
genai_settings.enabled = True
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
|
if genai_settings.enabled:
|
2025-08-10 16:38:04 +03:00
|
|
|
logger.info(f"Turning off review descriptions for {camera_name}")
|
2025-07-14 15:58:43 +03:00
|
|
|
genai_settings.enabled = False
|
|
|
|
|
|
|
|
|
|
self.config_updater.publish_update(
|
2025-08-10 16:38:04 +03:00
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.review_genai, camera_name),
|
2025-07-14 15:58:43 +03:00
|
|
|
genai_settings,
|
|
|
|
|
)
|
2025-08-10 16:38:04 +03:00
|
|
|
self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True)
|
2026-02-28 17:04:43 +03:00
|
|
|
|
|
|
|
|
def _on_motion_mask_command(
|
|
|
|
|
self, camera_name: str, mask_name: str, payload: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Callback for motion mask topic."""
|
|
|
|
|
if payload not in ["ON", "OFF"]:
|
|
|
|
|
logger.error(f"Invalid payload for motion mask {mask_name}: {payload}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
|
|
|
|
|
|
if mask_name not in motion_settings.mask:
|
|
|
|
|
logger.error(f"Unknown motion mask: {mask_name}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
mask = motion_settings.mask[mask_name]
|
|
|
|
|
|
|
|
|
|
if not mask:
|
|
|
|
|
logger.error(f"Motion mask {mask_name} is None")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not mask.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Motion mask {mask_name} must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
mask.enabled = payload == "ON"
|
|
|
|
|
|
|
|
|
|
# Recreate RuntimeMotionConfig to update rasterized_mask
|
|
|
|
|
motion_settings = RuntimeMotionConfig(
|
|
|
|
|
frame_shape=self.config.cameras[camera_name].frame_shape,
|
|
|
|
|
**motion_settings.model_dump(exclude_unset=True),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Update the dispatcher's own config
|
|
|
|
|
self.config.cameras[camera_name].motion = motion_settings
|
|
|
|
|
|
|
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
|
|
|
|
|
motion_settings,
|
|
|
|
|
)
|
|
|
|
|
self.publish(
|
|
|
|
|
f"{camera_name}/motion_mask/{mask_name}/state", payload, retain=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _on_object_mask_command(
|
|
|
|
|
self, camera_name: str, mask_name: str, payload: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Callback for object mask topic."""
|
|
|
|
|
if payload not in ["ON", "OFF"]:
|
|
|
|
|
logger.error(f"Invalid payload for object mask {mask_name}: {payload}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
object_settings = self.config.cameras[camera_name].objects
|
|
|
|
|
|
|
|
|
|
# Check if this is a global mask
|
|
|
|
|
mask_found = False
|
|
|
|
|
if mask_name in object_settings.mask:
|
|
|
|
|
mask = object_settings.mask[mask_name]
|
|
|
|
|
if mask:
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not mask.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
mask.enabled = payload == "ON"
|
|
|
|
|
mask_found = True
|
|
|
|
|
|
|
|
|
|
# Check if this is a per-object filter mask
|
|
|
|
|
for object_name, filter_config in object_settings.filters.items():
|
|
|
|
|
if mask_name in filter_config.mask:
|
|
|
|
|
mask = filter_config.mask[mask_name]
|
|
|
|
|
if mask:
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not mask.enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
mask.enabled = payload == "ON"
|
|
|
|
|
mask_found = True
|
|
|
|
|
|
|
|
|
|
if not mask_found:
|
|
|
|
|
logger.error(f"Unknown object mask: {mask_name}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Recreate RuntimeFilterConfig for each object filter to update rasterized_mask
|
|
|
|
|
for object_name, filter_config in object_settings.filters.items():
|
|
|
|
|
# Merge global object masks with per-object filter masks
|
|
|
|
|
merged_mask = dict(filter_config.mask) # Copy filter-specific masks
|
|
|
|
|
|
|
|
|
|
# Add global object masks if they exist
|
|
|
|
|
if object_settings.mask:
|
|
|
|
|
for global_mask_id, global_mask_config in object_settings.mask.items():
|
|
|
|
|
# Use a global prefix to avoid key collisions
|
|
|
|
|
global_mask_id_prefixed = f"global_{global_mask_id}"
|
|
|
|
|
merged_mask[global_mask_id_prefixed] = global_mask_config
|
|
|
|
|
|
|
|
|
|
object_settings.filters[object_name] = RuntimeFilterConfig(
|
|
|
|
|
frame_shape=self.config.cameras[camera_name].frame_shape,
|
|
|
|
|
mask=merged_mask,
|
|
|
|
|
**filter_config.model_dump(
|
|
|
|
|
exclude_unset=True, exclude={"mask", "raw_mask"}
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Update the dispatcher's own config
|
|
|
|
|
self.config.cameras[camera_name].objects = object_settings
|
|
|
|
|
|
|
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.objects, camera_name),
|
|
|
|
|
object_settings,
|
|
|
|
|
)
|
|
|
|
|
self.publish(
|
|
|
|
|
f"{camera_name}/object_mask/{mask_name}/state", payload, retain=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _on_zone_command(self, camera_name: str, zone_name: str, payload: str) -> None:
|
|
|
|
|
"""Callback for zone topic."""
|
|
|
|
|
if payload not in ["ON", "OFF"]:
|
|
|
|
|
logger.error(f"Invalid payload for zone {zone_name}: {payload}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
camera_config = self.config.cameras[camera_name]
|
|
|
|
|
|
|
|
|
|
if zone_name not in camera_config.zones:
|
|
|
|
|
logger.error(f"Unknown zone: {zone_name}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
|
if not camera_config.zones[zone_name].enabled_in_config:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Zone {zone_name} must be enabled in the config to be turned on via MQTT."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
camera_config.zones[zone_name].enabled = payload == "ON"
|
|
|
|
|
|
|
|
|
|
self.config_updater.publish_update(
|
|
|
|
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, camera_name),
|
|
|
|
|
camera_config.zones,
|
|
|
|
|
)
|
|
|
|
|
self.publish(f"{camera_name}/zone/{zone_name}/state", payload, retain=True)
|