Refactor birdseye activity modes as a list and add alerts/detections (#24012)

* backend

* tests

* frontend and i18n

* e2e test schema

* docs
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent e77155a4f1
commit f4cf1e1539
29 changed files with 583 additions and 425 deletions
+46 -64
View File
@@ -1,3 +1,5 @@
from enum import Enum
from pydantic import BaseModel, Field
from ..base import FrigateBaseModel
@@ -6,78 +8,58 @@ __all__ = [
"BirdseyeCameraConfig",
"BirdseyeConfig",
"BirdseyeLayoutConfig",
"BirdseyeModeConfig",
"BirdseyeModeEnum",
"birdseye_modes_from_mqtt_payload",
"birdseye_modes_to_mqtt_payload",
]
BIRDSEYE_ACTIVITY_TYPES = (
"objects",
"motion",
"stationary_objects",
"continuous",
)
# canonical MQTT payload for an empty mode list
MQTT_NO_MODES = "NONE"
class BirdseyeModeConfig(FrigateBaseModel):
continuous: bool = Field(
default=False,
title="Continuous",
description="Always include the camera in Birdseye.",
)
motion: bool = Field(
default=False,
title="Motion",
description="Include the camera in Birdseye when motion is detected.",
)
objects: bool = Field(
default=False,
title="Active objects",
description="Include the camera in Birdseye while an active object is tracked.",
)
stationary_objects: bool = Field(
default=False,
title="Stationary objects",
description="Include the camera in Birdseye while a stationary object is tracked.",
)
class BirdseyeModeEnum(str, Enum):
continuous = "continuous"
motion = "motion"
all_objects = "all_objects"
alerts = "alerts"
detections = "detections"
@classmethod
def from_mqtt_payload(cls, payload: str) -> "BirdseyeModeConfig | None":
"""Create mode options from an uppercase MQTT payload."""
raw_modes = payload.split(",")
if not raw_modes or any(not mode for mode in raw_modes):
def birdseye_modes_from_mqtt_payload(payload: str) -> list[BirdseyeModeEnum] | None:
"""Parse an uppercase MQTT payload into activity modes, or None when invalid."""
raw_modes = payload.split(",")
if any(not raw_mode or raw_mode != raw_mode.upper() for raw_mode in raw_modes):
return None
if raw_modes == [MQTT_NO_MODES]:
return []
modes: list[BirdseyeModeEnum] = []
for raw_mode in raw_modes:
try:
mode = BirdseyeModeEnum(raw_mode.lower())
except ValueError:
return None
modes = [mode.lower() for mode in raw_modes]
if any(
raw_mode != mode.upper() or mode not in BIRDSEYE_ACTIVITY_TYPES
for raw_mode, mode in zip(raw_modes, modes)
):
if mode in modes:
return None
if len(modes) != len(set(modes)):
return None
modes.append(mode)
return cls(**{mode: True for mode in modes})
def has_enabled_activity(self) -> bool:
"""Return whether at least one activity type is enabled."""
return any(getattr(self, activity) for activity in BIRDSEYE_ACTIVITY_TYPES)
def to_mqtt_payload(self) -> str:
"""Serialize enabled mode options for MQTT state topics."""
payload = ",".join(
activity.upper()
for activity in BIRDSEYE_ACTIVITY_TYPES
if getattr(self, activity)
)
if not payload:
raise ValueError("At least one Birdseye activity type must be enabled")
return payload
return modes
def default_birdseye_mode() -> BirdseyeModeConfig:
"""Return the default Birdseye mode configuration."""
return BirdseyeModeConfig(objects=True)
def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
"""Serialize activity modes for MQTT state topics."""
payload = ",".join(mode.value.upper() for mode in BirdseyeModeEnum if mode in modes)
return payload or MQTT_NO_MODES
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
"""Return the default Birdseye activity modes."""
return [BirdseyeModeEnum.all_objects]
class BirdseyeLayoutConfig(FrigateBaseModel):
@@ -101,8 +83,8 @@ class BirdseyeConfig(FrigateBaseModel):
title="Enable Birdseye",
description="Enable or disable the Birdseye view feature.",
)
mode: BirdseyeModeConfig = Field(
default_factory=default_birdseye_mode,
modes: list[BirdseyeModeEnum] = Field(
default_factory=default_birdseye_modes,
title="Activity types",
description="Activity types that include cameras in Birdseye.",
)
@@ -156,8 +138,8 @@ class BirdseyeCameraConfig(BaseModel):
title="Enable Birdseye",
description="Enable or disable the Birdseye view feature.",
)
mode: BirdseyeModeConfig = Field(
default_factory=default_birdseye_mode,
modes: list[BirdseyeModeEnum] = Field(
default_factory=default_birdseye_modes,
title="Activity types",
description="Activity types that include cameras in Birdseye.",
)
+2 -18
View File
@@ -41,7 +41,7 @@ from .auth import AuthConfig
from .base import FrigateBaseModel
from .camera import CameraConfig, CameraLiveConfig
from .camera.audio import AudioConfig, AudioFilterConfig
from .camera.birdseye import BirdseyeConfig, BirdseyeModeConfig
from .camera.birdseye import BirdseyeConfig
from .camera.detect import DetectConfig
from .camera.ffmpeg import FfmpegConfig
from .camera.genai import GenAIConfig, GenAIRoleEnum
@@ -349,20 +349,8 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
"""Verify profile overrides against the resolved base camera configuration."""
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
for profile_name, profile in camera_config.profiles.items():
if profile.birdseye is not None:
overrides = profile.birdseye.mode.model_dump(exclude_unset=True)
base_mode = camera_config.birdseye.mode.model_dump()
resolved_mode = BirdseyeModeConfig.model_validate(
deep_merge(overrides, base_mode)
)
if not resolved_mode.has_enabled_activity():
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' must "
"enable at least one Birdseye activity type"
)
if profile.zones:
for zone_name in profile.zones:
if zone_name not in camera_config.zones:
@@ -1033,10 +1021,6 @@ class FrigateConfig(FrigateBaseModel):
self.cameras[name] = camera_config
verify_config_roles(camera_config)
if not camera_config.birdseye.mode.has_enabled_activity():
raise ValueError(
f"Camera '{name}' must enable at least one Birdseye activity type"
)
verify_valid_live_stream_names(self, camera_config)
verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config)
+5 -2
View File
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from frigate.config.camera.birdseye import birdseye_modes_to_mqtt_payload
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
@@ -42,9 +43,11 @@ SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
"birdseye": [
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
(
"birdseye_mode",
"birdseye_modes",
lambda c: (
c.birdseye.mode.to_mqtt_payload() if c.birdseye.enabled else "OFF"
birdseye_modes_to_mqtt_payload(c.birdseye.modes)
if c.birdseye.enabled
else "OFF"
),
),
],