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-08-17 08:10:09 -05:00
parent 36607133e5
commit 5e37b2c5c2
29 changed files with 583 additions and 425 deletions

View File

@ -252,16 +252,14 @@ birdseye:
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
quality: 8
# Optional: Activity types that include cameras in Birdseye (default: shown below)
# Multiple activity types can be enabled at the same time.
mode:
# Optional: All cameras are included always (default: shown below)
continuous: False
# Optional: Cameras are included if motion was detected in the last 30 seconds (default: shown below)
motion: False
# Optional: Cameras are included if they have had an active tracked object within the last 30 seconds (default: shown below)
objects: True
# Optional: Cameras are included while they have a stationary tracked object (default: shown below)
stationary_objects: False
# Multiple activity types can be listed at the same time.
# continuous: all cameras are included always
# motion: included if motion was detected within the inactivity threshold
# all_objects: included if a tracked object was present within the inactivity threshold
# alerts: included while an alert review item is in progress
# detections: included while a detection review item is in progress
modes:
- all_objects
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
inactivity_threshold: 30
# Optional: Configure the birdseye layout

View File

@ -20,12 +20,15 @@ Each camera tile in Birdseye is composed from the frames of the stream assigned
### Birdseye Activity Types
Birdseye offers independent activity types that control when cameras are shown. Multiple activity types can be enabled together.
Birdseye offers independent activity types that control when cameras are shown. Multiple activity types can be listed together.
- **continuous:** The camera is always included
- **motion:** The camera is included when motion was detected within the last 30 seconds
- **objects:** The camera is included when an active object was tracked within the last 30 seconds
- **stationary_objects:** The camera is included while a stationary object is tracked
- **all_objects:** The camera is included when a tracked object is present, active or stationary
- **alerts:** The camera is included while an alert review item is in progress
- **detections:** The camera is included while a detection review item is in progress
`alerts` and `detections` follow the review item's own lifetime, so the camera is removed as soon as the review item ends. Which objects qualify for each is set in [review configuration](./review.md).
### Custom Birdseye Icon
@ -50,20 +53,19 @@ To include a camera in Birdseye view only for specific circumstances, or exclude
</TabItem>
<TabItem value="yaml">
```yaml {8-11,13-15}
```yaml {10-12,15-16}
# Include all cameras by default in Birdseye view
birdseye:
enabled: True
mode:
continuous: True
modes:
- continuous
cameras:
front:
# Only include the "front" camera in Birdseye view when objects are detected
# Only include the "front" camera in Birdseye view when an alert is in progress
birdseye:
mode:
continuous: False
objects: True
modes:
- alerts
back:
# Exclude the "back" camera from Birdseye view
birdseye:
@ -75,7 +77,7 @@ cameras:
### Birdseye Inactivity
By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured.
By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured, and applies to the `motion` and `all_objects` activity types only.
<ConfigTabs>
<TabItem value="ui">
@ -144,7 +146,8 @@ Navigate to <NavPath path="Settings > System > Birdseye" /> and in the **Camera
# Include all cameras by default in Birdseye view
birdseye:
enabled: True
mode: continuous
modes:
- continuous
cameras:
front:

View File

@ -553,23 +553,25 @@ must be enabled in the configuration.
Topic with current state of Birdseye for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/birdseye_mode/set`
### `frigate/<camera_name>/birdseye_modes/set`
Topic to set the Birdseye activity types for a camera. Send one uppercase activity type or combine multiple types with commas, for example `MOTION,OBJECTS,STATIONARY_OBJECTS`.
Topic to set the Birdseye activity types for a camera. Send one uppercase activity type or combine multiple types with commas, for example `MOTION,ALERTS`.
_Note: Changing the value from `CONTINUOUS` to non-continuous activity types will take up to 30 seconds for
the camera to be removed from the view._
| Command | Description |
| -------------------- | ---------------------------------------------------------------- |
| `CONTINUOUS` | Always included |
| `MOTION` | Shown if motion was detected within the last 30 seconds |
| `OBJECTS` | Shown if an active object was tracked within the last 30 seconds |
| `STATIONARY_OBJECTS` | Shown while a stationary object is tracked |
| Command | Description |
| ------------- | ---------------------------------------------------------------- |
| `CONTINUOUS` | Always included |
| `MOTION` | Shown if motion was detected within the last 30 seconds |
| `ALL_OBJECTS` | Shown if a tracked object was present within the last 30 seconds |
| `ALERTS` | Shown while an alert review item is in progress |
| `DETECTIONS` | Shown while a detection review item is in progress |
| `NONE` | Never included |
### `frigate/<camera_name>/birdseye_mode/state`
### `frigate/<camera_name>/birdseye_modes/state`
Topic with the current Birdseye activity types for a camera. Multiple enabled types are published as a comma-separated value in the order `OBJECTS`, `MOTION`, `STATIONARY_OBJECTS`, `CONTINUOUS`.
Topic with the current Birdseye activity types for a camera. Multiple enabled types are published as a comma-separated value in the order `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`. `NONE` is published when no activity types are enabled.
### `frigate/<camera_name>/notifications/set`

View File

@ -713,7 +713,7 @@ paths:
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
@ -803,7 +803,7 @@ paths:
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |

View File

@ -1386,7 +1386,7 @@ def camera_set(
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |

View File

@ -11,7 +11,11 @@ from frigate.camera.activity_manager import AudioActivityManager, CameraActivity
from frigate.comms.base_communicator import Communicator
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient
from frigate.config import BirdseyeModeConfig, FrigateConfig
from frigate.config import (
FrigateConfig,
birdseye_modes_from_mqtt_payload,
birdseye_modes_to_mqtt_payload,
)
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
@ -84,7 +88,7 @@ class Dispatcher:
"recordings": self._on_recordings_command,
"snapshots": self._on_snapshots_command,
"birdseye": self._on_birdseye_command,
"birdseye_mode": self._on_birdseye_mode_command,
"birdseye_modes": self._on_birdseye_modes_command,
"review_alerts": self._on_alerts_command,
"review_detections": self._on_detections_command,
"object_descriptions": self._on_object_description_command,
@ -879,12 +883,12 @@ class Dispatcher:
)
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
def _on_birdseye_modes_command(self, camera_name: str, payload: str) -> None:
"""Callback for birdseye mode topic."""
mode = BirdseyeModeConfig.from_mqtt_payload(payload)
if mode is None:
logger.info("Invalid birdseye_mode command: %s", payload)
modes = birdseye_modes_from_mqtt_payload(payload)
if modes is None:
logger.info("Invalid birdseye_modes command: %s", payload)
return
birdseye_settings = self.config.cameras[camera_name].birdseye
@ -893,9 +897,9 @@ class Dispatcher:
logger.info(f"Birdseye mode not enabled for {camera_name}")
return
birdseye_settings.mode = mode
birdseye_settings.modes = modes
logger.info(
f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}"
f"Setting birdseye mode for {camera_name} to {birdseye_settings.modes}"
)
self.config_updater.publish_update(
@ -903,7 +907,9 @@ class Dispatcher:
birdseye_settings,
)
self.publish(
f"{camera_name}/birdseye_mode/state", mode.to_mqtt_payload(), retain=True
f"{camera_name}/birdseye_modes/state",
birdseye_modes_to_mqtt_payload(modes),
retain=True,
)
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:

View File

@ -7,7 +7,7 @@ import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from frigate.comms.base_communicator import Communicator
from frigate.config import FrigateConfig
from frigate.config import FrigateConfig, birdseye_modes_to_mqtt_payload
logger = logging.getLogger(__name__)
@ -123,9 +123,9 @@ class MqttClient(Communicator):
retain=True,
)
self.publish(
f"{camera_name}/birdseye_mode/state",
f"{camera_name}/birdseye_modes/state",
(
camera.birdseye.mode.to_mqtt_payload()
birdseye_modes_to_mqtt_payload(camera.birdseye.modes)
if camera.birdseye.enabled
else "OFF"
),
@ -270,7 +270,7 @@ class MqttClient(Communicator):
"motion_threshold",
"motion_contour_area",
"birdseye",
"birdseye_mode",
"birdseye_modes",
"review_alerts",
"review_detections",
"object_descriptions",

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.",
)

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)

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"
),
),
],

View File

@ -453,7 +453,7 @@ def get_tool_definitions(
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"birdseye_modes",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
@ -468,7 +468,7 @@ def get_tool_definitions(
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, OBJECTS, STATIONARY_OBJECTS, or a comma-separated combination. "
"birdseye_modes accepts CONTINUOUS, MOTION, ALL_OBJECTS, ALERTS, DETECTIONS, NONE, or a comma-separated combination. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),

View File

@ -17,9 +17,11 @@ import cv2
import numpy as np
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import BirdseyeModeConfig, FfmpegConfig, FrigateConfig
from frigate.comms.review_updater import ReviewDataSubscriber
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
from frigate.output.ws_auth import ws_has_camera_access
from frigate.review.types import SeverityEnum
from frigate.util.image import (
SharedMemoryFrameManager,
copy_yuv_to_position,
@ -33,9 +35,9 @@ logger = logging.getLogger(__name__)
class BirdseyeActivity:
"""Activity signals used to decide whether a camera is shown in Birdseye."""
has_active_object: bool
has_stationary_object: bool
has_object: bool
has_motion: bool
severity: str | None
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
@ -367,6 +369,7 @@ class BirdsEyeFrameManager:
settings.detect.height,
],
"last_active_frame": 0.0,
"live_active": False,
"current_frame": 0.0,
"layout_frame": 0.0,
"channel_dims": {
@ -418,16 +421,32 @@ class BirdsEyeFrameManager:
channel_dims,
)
def camera_active(
def camera_threshold_active(
self,
mode: BirdseyeModeConfig,
modes: list[BirdseyeModeEnum],
activity: BirdseyeActivity,
) -> bool:
"""Return whether activity subject to inactivity_threshold is present."""
return (BirdseyeModeEnum.motion in modes and activity.has_motion) or (
BirdseyeModeEnum.all_objects in modes and activity.has_object
)
def camera_live_active(
self,
modes: list[BirdseyeModeEnum],
activity: BirdseyeActivity,
) -> bool:
"""Return whether activity that ends the moment it stops is present."""
return (
mode.continuous
or (mode.motion and activity.has_motion)
or (mode.objects and activity.has_active_object)
or (mode.stationary_objects and activity.has_stationary_object)
BirdseyeModeEnum.continuous in modes
or (
BirdseyeModeEnum.alerts in modes
and activity.severity == SeverityEnum.alert
)
or (
BirdseyeModeEnum.detections in modes
and activity.severity == SeverityEnum.detection
)
)
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
@ -459,9 +478,15 @@ class BirdsEyeFrameManager:
and self.config.cameras[cam].birdseye.enabled
and self.config.cameras[cam].enabled_in_config
and self.config.cameras[cam].enabled
and cam_data["last_active_frame"] > 0
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.config.birdseye.inactivity_threshold
and (
cam_data["live_active"]
or (
cam_data["last_active_frame"] > 0
and cam_data["current_frame_time"]
- cam_data["last_active_frame"]
< self.config.birdseye.inactivity_threshold
)
)
]
)
logger.debug(f"Active cameras: {active_cameras}")
@ -478,7 +503,9 @@ class BirdsEyeFrameManager:
limited_active_cameras = sorted(
active_cameras,
key=lambda active_camera: (
self.cameras[active_camera]["current_frame_time"]
0.0
if self.cameras[active_camera]["live_active"]
else self.cameras[active_camera]["current_frame_time"]
- self.cameras[active_camera]["last_active_frame"]
),
)
@ -754,10 +781,10 @@ class BirdsEyeFrameManager:
# disabling birdseye is a little tricky
if not camera_config.birdseye.enabled or not camera_config.enabled:
# if we've rendered a frame (we have a value for last_active_frame)
# then we need to set it to zero
if camera_state["last_active_frame"] > 0:
# if we've rendered a frame (we have activity state) then clear it
if camera_state["last_active_frame"] > 0 or camera_state["live_active"]:
camera_state["last_active_frame"] = 0
camera_state["live_active"] = False
force_update = True
else:
return False, False
@ -765,12 +792,13 @@ class BirdsEyeFrameManager:
# update the last active frame for the camera
camera_state["current_frame"] = frame.copy()
camera_state["current_frame_time"] = frame_time
if self.camera_active(
camera_config.birdseye.mode,
activity,
):
modes = camera_config.birdseye.modes
if self.camera_threshold_active(modes, activity):
camera_state["last_active_frame"] = frame_time
camera_state["live_active"] = self.camera_live_active(modes, activity)
now = datetime.datetime.now().timestamp()
# limit output to 10 fps
@ -828,6 +856,8 @@ class Birdseye:
self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
self.requestor = InterProcessRequestor()
self.review_subscriber = ReviewDataSubscriber("")
self.review_severity: dict[str, str] = {}
self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps
self._idle_interval: float | None = (
(1.0 / self.idle_fps) if self.idle_fps > 0 else None
@ -858,6 +888,21 @@ class Birdseye:
self.birdseye_manager.clear_frame()
self.__send_new_frame()
def check_review_updates(self) -> None:
"""Drain review updates so each camera's active severity stays current."""
while True:
update = self.review_subscriber.check_for_update(timeout=0)
if update is None:
break
camera = update["after"]["camera"]
if update["type"] == "end":
self.review_severity.pop(camera, None)
else:
self.review_severity[camera] = update["after"]["severity"]
def add_camera(self, camera: str) -> None:
"""Add a camera to the birdseye manager."""
self.birdseye_manager.add_camera(camera)
@ -866,6 +911,7 @@ class Birdseye:
def remove_camera(self, camera: str) -> None:
"""Remove a camera from the birdseye manager."""
self.birdseye_manager.remove_camera(camera)
self.review_severity.pop(camera, None)
logger.debug(f"Removed camera {camera} from birdseye")
def write_data(
@ -876,24 +922,13 @@ class Birdseye:
frame_time: float,
frame: np.ndarray,
) -> None:
has_active_object = False
has_stationary_object = False
for tracked_object in current_tracked_objects:
if tracked_object["stationary"]:
if not tracked_object["false_positive"]:
has_stationary_object = True
else:
# Preserve the existing objects activity behavior, which includes
# non-stationary trackers before they are confirmed.
has_active_object = True
if has_active_object and has_stationary_object:
break
activity = BirdseyeActivity(
has_active_object=has_active_object,
has_stationary_object=has_stationary_object,
has_object=any(
not tracked_object["false_positive"]
for tracked_object in current_tracked_objects
),
has_motion=bool(motion_boxes),
severity=self.review_severity.get(camera),
)
frame_changed, frame_layout_changed = self.birdseye_manager.update(
@ -919,5 +954,6 @@ class Birdseye:
self.__send_new_frame()
def stop(self) -> None:
self.review_subscriber.stop()
self.converter.join()
self.broadcaster.join()

View File

@ -188,6 +188,11 @@ class OutputProcess(FrigateProcess):
self.config.birdseye = birdseye_config
logger.debug("Applied dynamic birdseye config update")
# drain review updates every iteration, not just when birdseye is
# being consumed, so a dropped end never strands a camera
if birdseye is not None:
birdseye.check_review_updates()
# check if there is an updated config
updates = config_subscriber.check_for_updates()

View File

@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from frigate.config import FrigateConfig
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
@ -383,22 +383,15 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
Global birdseye only seeds enabled and mode; the camera copies are what
the output process actually reads. Sending just the global object makes
a worker guess which cameras were inheriting, and the only available
guess (mode still equals the previous global) wrongly claims a camera
whose explicit yaml mode happens to match.
guess (modes still equals the previous global) wrongly claims a camera
whose explicit yaml modes happens to match.
"""
self.minimal_config["birdseye"] = {
"enabled": True,
"mode": {"motion": True},
"modes": ["motion"],
}
# explicit override that matches the global value being replaced
self.minimal_config["cameras"]["front_door"]["birdseye"] = {
"mode": {
"continuous": False,
"motion": True,
"objects": False,
"stationary_objects": False,
}
}
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"modes": ["motion"]}
config_path = self._write_config_file()
mock_find_config.return_value = config_path
@ -409,16 +402,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
resp = client.put(
"/config/set",
json={
"config_data": {
"birdseye": {
"mode": {
"continuous": True,
"motion": False,
"objects": False,
"stationary_objects": False,
}
}
},
"config_data": {"birdseye": {"modes": ["continuous"]}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
@ -430,7 +414,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
mock_publisher.publisher.publish.assert_called_once()
topic, settings = mock_publisher.publisher.publish.call_args[0]
self.assertEqual(topic, "config/birdseye")
self.assertEqual(settings.mode.to_mqtt_payload(), "CONTINUOUS")
self.assertEqual(settings.modes, [BirdseyeModeEnum.continuous])
published = {
call[0][0].camera: call[0][1]
@ -445,10 +429,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
# the override survives, the inheriting camera follows global
self.assertEqual(
published["front_door"].mode.to_mqtt_payload(), "MOTION"
published["front_door"].modes, [BirdseyeModeEnum.motion]
)
self.assertEqual(
published["back_yard"].mode.to_mqtt_payload(), "CONTINUOUS"
published["back_yard"].modes, [BirdseyeModeEnum.continuous]
)
finally:
os.unlink(config_path)

View File

@ -4,7 +4,12 @@ import multiprocessing as mp
import unittest
from unittest.mock import Mock
from frigate.config import BirdseyeModeConfig, FrigateConfig
from frigate.config import (
BirdseyeModeEnum,
FrigateConfig,
birdseye_modes_from_mqtt_payload,
birdseye_modes_to_mqtt_payload,
)
from frigate.output.birdseye import (
Birdseye,
BirdseyeActivity,
@ -215,11 +220,7 @@ class TestBirdseyeActivity(unittest.TestCase):
"mqtt": {"enabled": False},
"birdseye": {
"enabled": True,
"mode": {
"motion": True,
"objects": True,
"stationary_objects": True,
},
"modes": ["motion", "all_objects"],
},
"cameras": {
"front": {
@ -234,53 +235,93 @@ class TestBirdseyeActivity(unittest.TestCase):
}
self.manager = BirdsEyeFrameManager(FrigateConfig(**config), mp.Event())
def test_existing_modes_keep_their_activity_rules(self):
continuous = BirdseyeModeConfig(continuous=True)
motion = BirdseyeModeConfig(motion=True)
objects = BirdseyeModeConfig(objects=True)
def test_each_mode_matches_only_its_own_activity(self):
motion_activity = BirdseyeActivity(
has_object=False, has_motion=True, severity=None
)
object_activity = BirdseyeActivity(
has_object=True, has_motion=False, severity=None
)
no_activity = BirdseyeActivity(
has_object=False, has_motion=False, severity=None
)
no_activity = BirdseyeActivity(False, False, False)
motion_activity = BirdseyeActivity(False, False, True)
stationary_activity = BirdseyeActivity(False, True, False)
active_object_activity = BirdseyeActivity(True, False, False)
assert self.manager.camera_threshold_active(
[BirdseyeModeEnum.motion], motion_activity
)
assert not self.manager.camera_threshold_active(
[BirdseyeModeEnum.motion], object_activity
)
assert self.manager.camera_threshold_active(
[BirdseyeModeEnum.all_objects], object_activity
)
assert not self.manager.camera_threshold_active(
[BirdseyeModeEnum.all_objects], motion_activity
)
assert self.manager.camera_live_active(
[BirdseyeModeEnum.continuous], no_activity
)
assert self.manager.camera_active(continuous, no_activity)
assert self.manager.camera_active(motion, motion_activity)
assert not self.manager.camera_active(motion, stationary_activity)
assert self.manager.camera_active(objects, active_object_activity)
assert not self.manager.camera_active(objects, stationary_activity)
def test_continuous_is_not_threshold_activity(self):
no_activity = BirdseyeActivity(
has_object=False, has_motion=False, severity=None
)
assert not self.manager.camera_threshold_active(
[BirdseyeModeEnum.continuous], no_activity
)
def test_modes_can_be_combined(self):
mode = BirdseyeModeConfig(motion=True, stationary_objects=True)
modes = [BirdseyeModeEnum.motion, BirdseyeModeEnum.all_objects]
assert self.manager.camera_active(mode, BirdseyeActivity(False, False, True))
assert self.manager.camera_active(mode, BirdseyeActivity(False, True, False))
assert not self.manager.camera_active(
mode, BirdseyeActivity(False, False, False)
assert self.manager.camera_threshold_active(
modes, BirdseyeActivity(has_object=False, has_motion=True, severity=None)
)
assert self.manager.camera_threshold_active(
modes, BirdseyeActivity(has_object=True, has_motion=False, severity=None)
)
assert not self.manager.camera_threshold_active(
modes, BirdseyeActivity(has_object=False, has_motion=False, severity=None)
)
def test_stationary_objects_are_independent_from_active_objects(self):
stationary_objects = BirdseyeModeConfig(stationary_objects=True)
def test_empty_modes_never_activate(self):
activity = BirdseyeActivity(has_object=True, has_motion=True, severity=None)
assert self.manager.camera_active(
stationary_objects, BirdseyeActivity(False, True, False)
assert not self.manager.camera_threshold_active([], activity)
assert not self.manager.camera_live_active([], activity)
def test_alerts_and_detections_match_review_severity(self):
alert = BirdseyeActivity(has_object=False, has_motion=False, severity="alert")
detection = BirdseyeActivity(
has_object=False, has_motion=False, severity="detection"
)
assert not self.manager.camera_active(
stationary_objects, BirdseyeActivity(True, False, False)
idle = BirdseyeActivity(has_object=False, has_motion=False, severity=None)
assert self.manager.camera_live_active([BirdseyeModeEnum.alerts], alert)
assert not self.manager.camera_live_active([BirdseyeModeEnum.alerts], detection)
assert not self.manager.camera_live_active([BirdseyeModeEnum.alerts], idle)
assert self.manager.camera_live_active([BirdseyeModeEnum.detections], detection)
assert not self.manager.camera_live_active([BirdseyeModeEnum.detections], alert)
def test_review_severity_is_not_threshold_activity(self):
alert = BirdseyeActivity(has_object=False, has_motion=False, severity="alert")
assert not self.manager.camera_threshold_active(
[BirdseyeModeEnum.alerts, BirdseyeModeEnum.motion], alert
)
def test_write_data_preserves_active_and_confirms_stationary_activity(self):
def test_all_objects_covers_active_and_stationary_objects(self):
birdseye = Birdseye.__new__(Birdseye)
birdseye.birdseye_manager = Mock()
birdseye.birdseye_manager.update.return_value = (False, False)
birdseye._idle_interval = None
birdseye.review_severity = {}
frame = Mock()
birdseye.write_data(
"front",
[
{"stationary": True, "false_positive": True},
{"stationary": False, "false_positive": True},
{"stationary": True, "false_positive": False},
],
[[0, 0, 10, 10]],
@ -289,26 +330,36 @@ class TestBirdseyeActivity(unittest.TestCase):
)
birdseye.birdseye_manager.update.assert_called_once_with(
"front", BirdseyeActivity(True, True, True), 1.0, frame
"front",
BirdseyeActivity(has_object=True, has_motion=True, severity=None),
1.0,
frame,
)
def test_stationary_false_positive_does_not_activate_birdseye(self):
def test_false_positives_do_not_count_as_objects(self):
birdseye = Birdseye.__new__(Birdseye)
birdseye.birdseye_manager = Mock()
birdseye.birdseye_manager.update.return_value = (False, False)
birdseye._idle_interval = None
birdseye.review_severity = {}
frame = Mock()
birdseye.write_data(
"front",
[{"stationary": True, "false_positive": True}],
[
{"stationary": True, "false_positive": True},
{"stationary": False, "false_positive": True},
],
[],
1.0,
frame,
)
birdseye.birdseye_manager.update.assert_called_once_with(
"front", BirdseyeActivity(False, False, False), 1.0, frame
"front",
BirdseyeActivity(has_object=False, has_motion=False, severity=None),
1.0,
frame,
)
@ -318,7 +369,7 @@ class TestBirdseyeCameraOrder(unittest.TestCase):
def setUp(self):
config = {
"mqtt": {"enabled": False},
"birdseye": {"enabled": True, "mode": {"continuous": True}},
"birdseye": {"enabled": True, "modes": ["continuous"]},
"cameras": {
camera: {
"ffmpeg": {
@ -340,6 +391,7 @@ class TestBirdseyeCameraOrder(unittest.TestCase):
camera_data["current_frame"] = None
camera_data["current_frame_time"] = 1.0
camera_data["last_active_frame"] = 1.0
camera_data["live_active"] = True
def layout_order(self) -> list[str]:
"""Return the cameras in the order the current layout renders them."""
@ -377,3 +429,155 @@ class TestBirdseyeCameraOrder(unittest.TestCase):
assert not layout_changed
assert self.layout_order() == ["back", "front", "side"]
class TestBirdseyeLiveActivity(unittest.TestCase):
"""Test that live activity bypasses the inactivity threshold."""
def setUp(self):
config = {
"mqtt": {"enabled": False},
"birdseye": {
"enabled": True,
"modes": ["continuous"],
"inactivity_threshold": 30,
},
"cameras": {
camera: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
for camera in ("back", "front")
},
}
self.config = FrigateConfig(**config)
self.manager = BirdsEyeFrameManager(self.config, mp.Event())
for camera_data in self.manager.cameras.values():
camera_data["current_frame"] = None
camera_data["current_frame_time"] = 1000.0
camera_data["last_active_frame"] = 0.0
camera_data["live_active"] = False
def test_live_active_camera_is_shown_without_a_recent_active_frame(self):
self.manager.cameras["front"]["live_active"] = True
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
def test_live_active_camera_drops_out_immediately(self):
self.manager.cameras["front"]["live_active"] = True
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
self.manager.cameras["front"]["live_active"] = False
self.manager.update_frame()
assert self.manager.active_cameras == set()
def test_threshold_activity_lingers_then_expires(self):
self.manager.cameras["front"]["last_active_frame"] = 980.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
self.manager.cameras["front"]["last_active_frame"] = 960.0
self.manager.update_frame()
assert self.manager.active_cameras == set()
def test_max_cameras_ranks_live_active_cameras_first(self):
self.config.birdseye.layout.max_cameras = 1
self.manager.cameras["front"]["live_active"] = True
self.manager.cameras["back"]["last_active_frame"] = 995.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
class TestBirdseyeModePayload(unittest.TestCase):
"""Test the MQTT payload contract for Birdseye activity modes."""
def test_single_mode_round_trips(self):
modes = birdseye_modes_from_mqtt_payload("ALERTS")
assert modes == [BirdseyeModeEnum.alerts]
assert birdseye_modes_to_mqtt_payload(modes) == "ALERTS"
def test_combined_modes_are_published_in_enum_order(self):
modes = birdseye_modes_from_mqtt_payload("ALERTS,MOTION")
assert modes == [BirdseyeModeEnum.alerts, BirdseyeModeEnum.motion]
assert birdseye_modes_to_mqtt_payload(modes) == "MOTION,ALERTS"
def test_none_round_trips_to_an_empty_list(self):
assert birdseye_modes_from_mqtt_payload("NONE") == []
assert birdseye_modes_to_mqtt_payload([]) == "NONE"
def test_invalid_payloads_are_rejected(self):
for payload in (
"UNKNOWN",
"motion",
"MOTION_OBJECTS",
"NONE,MOTION",
"MOTION,MOTION",
"MOTION,",
"",
):
with self.subTest(payload=payload):
assert birdseye_modes_from_mqtt_payload(payload) is None
class TestBirdseyeReviewSeverity(unittest.TestCase):
"""Test that review updates drive the per-camera severity map."""
def setUp(self):
self.birdseye = Birdseye.__new__(Birdseye)
self.birdseye.review_subscriber = Mock()
self.birdseye.review_severity = {}
self.birdseye.birdseye_manager = Mock()
self.birdseye.birdseye_manager.update.return_value = (False, False)
self.birdseye._idle_interval = None
def _drain(self, *updates):
self.birdseye.review_subscriber.check_for_update.side_effect = [*updates, None]
self.birdseye.check_review_updates()
def test_new_segment_sets_severity(self):
self._drain({"type": "new", "after": {"camera": "front", "severity": "alert"}})
assert self.birdseye.review_severity == {"front": "alert"}
def test_update_upgrades_severity(self):
self._drain(
{"type": "new", "after": {"camera": "front", "severity": "detection"}},
{"type": "update", "after": {"camera": "front", "severity": "alert"}},
)
assert self.birdseye.review_severity == {"front": "alert"}
def test_end_clears_severity(self):
self._drain(
{"type": "new", "after": {"camera": "front", "severity": "alert"}},
{"type": "end", "after": {"camera": "front", "severity": "alert"}},
)
assert self.birdseye.review_severity == {}
def test_write_data_passes_the_tracked_severity(self):
self.birdseye.review_severity = {"front": "alert"}
frame = Mock()
self.birdseye.write_data("front", [], [], 1.0, frame)
self.birdseye.birdseye_manager.update.assert_called_once_with(
"front",
BirdseyeActivity(has_object=False, has_motion=False, severity="alert"),
1.0,
frame,
)

View File

@ -8,7 +8,7 @@ import numpy as np
from pydantic import ValidationError
from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import FrigateConfig, RetainModeEnum
from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum
from frigate.util.builtin import deep_merge
@ -171,7 +171,7 @@ class TestConfig(unittest.TestCase):
def test_override_birdseye(self):
config = {
"mqtt": {"host": "mqtt"},
"birdseye": {"enabled": True, "mode": {"continuous": True}},
"birdseye": {"enabled": True, "modes": ["continuous"]},
"cameras": {
"back": {
"ffmpeg": {
@ -186,7 +186,7 @@ class TestConfig(unittest.TestCase):
},
"birdseye": {
"enabled": False,
"mode": {"continuous": False, "motion": True},
"modes": ["motion"],
},
}
},
@ -194,18 +194,16 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert not frigate_config.cameras["back"].birdseye.enabled
mode = frigate_config.cameras["back"].birdseye.mode
assert mode.motion
assert not mode.continuous
assert not mode.objects
assert not mode.stationary_objects
assert frigate_config.cameras["back"].birdseye.modes == [
BirdseyeModeEnum.motion
]
def test_override_birdseye_non_inheritable(self):
config = {
"mqtt": {"host": "mqtt"},
"birdseye": {
"enabled": True,
"mode": {"continuous": True},
"modes": ["continuous"],
"height": 1920,
},
"cameras": {
@ -229,109 +227,38 @@ class TestConfig(unittest.TestCase):
def test_inherit_birdseye(self):
config = {
"mqtt": {"host": "mqtt"},
"birdseye": {"enabled": True, "mode": {"continuous": True}},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
**self.minimal,
"birdseye": {"enabled": True, "modes": ["continuous"]},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].birdseye.enabled
mode = frigate_config.cameras["back"].birdseye.mode
assert mode.continuous
assert not mode.motion
assert not mode.objects
assert not mode.stationary_objects
assert frigate_config.cameras["back"].birdseye.modes == [
BirdseyeModeEnum.continuous
]
def test_combine_birdseye_activity_types(self):
def test_camera_modes_replace_the_global_list(self):
"""A camera list fully replaces the global one, it does not merge into it."""
config = {
**self.minimal,
"birdseye": {
"mode": {
"motion": True,
"stationary_objects": True,
}
},
"birdseye": {"modes": ["motion", "all_objects"]},
}
config["cameras"]["back"]["birdseye"] = {"modes": ["alerts"]}
frigate_config = FrigateConfig(**config)
mode = frigate_config.cameras["back"].birdseye.mode
assert mode.motion
assert mode.stationary_objects
assert not mode.continuous
assert not mode.objects
assert frigate_config.cameras["back"].birdseye.modes == [
BirdseyeModeEnum.alerts
]
def test_birdseye_requires_an_activity_type(self):
def test_camera_can_select_no_modes(self):
config = {
**self.minimal,
"birdseye": {
"mode": {
"continuous": False,
"motion": False,
"objects": False,
"stationary_objects": False,
}
},
"birdseye": {"modes": ["motion"]},
}
with self.assertRaisesRegex(
ValidationError, "must enable at least one Birdseye activity type"
):
FrigateConfig(**config)
def test_camera_can_disable_an_inherited_activity_type(self):
config = {
**self.minimal,
"birdseye": {"mode": {"motion": True, "objects": True}},
}
config["cameras"]["back"]["birdseye"] = {"mode": {"motion": False}}
config["cameras"]["back"]["birdseye"] = {"modes": []}
frigate_config = FrigateConfig(**config)
mode = frigate_config.cameras["back"].birdseye.mode
assert not mode.motion
assert mode.objects
def test_profile_must_leave_an_activity_type_enabled(self):
config = {
**self.minimal,
"profiles": {"away": {"friendly_name": "Away"}},
"birdseye": {"mode": {"objects": True}},
}
config["cameras"]["back"]["profiles"] = {
"away": {"birdseye": {"mode": {"objects": False}}}
}
with self.assertRaisesRegex(
ValidationError, "must enable at least one Birdseye activity type"
):
FrigateConfig(**config)
def test_camera_birdseye_activity_types_override_global_values(self):
config = {
**self.minimal,
"birdseye": {"mode": {"motion": True, "objects": True}},
}
config["cameras"]["back"]["birdseye"] = {
"mode": {"motion": False, "stationary_objects": True}
}
frigate_config = FrigateConfig(**config)
mode = frigate_config.cameras["back"].birdseye.mode
assert not mode.motion
assert mode.objects
assert mode.stationary_objects
assert frigate_config.cameras["back"].birdseye.modes == []
def test_override_tracked_objects(self):
config = {

View File

@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
from frigate.app import FrigateApp
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.config import BirdseyeModeConfig
from frigate.config import BirdseyeModeEnum
def _make_camera_mock(
@ -53,7 +53,7 @@ def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
class TestBirdseyeModeCommands(unittest.TestCase):
"""Verify Birdseye mode commands use the boolean mode contract."""
"""Verify Birdseye mode commands use the activity list contract."""
def setUp(self) -> None:
self.camera = _make_camera_mock()
@ -62,30 +62,33 @@ class TestBirdseyeModeCommands(unittest.TestCase):
self.dispatcher.publish = MagicMock()
def test_combined_modes_are_accepted(self) -> None:
self.dispatcher._on_birdseye_mode_command(
"front_door", "STATIONARY_OBJECTS,MOTION"
)
self.dispatcher._on_birdseye_modes_command("front_door", "ALERTS,MOTION")
self.assertEqual(
self.camera.birdseye.mode,
BirdseyeModeConfig(motion=True, stationary_objects=True),
self.camera.birdseye.modes,
[BirdseyeModeEnum.alerts, BirdseyeModeEnum.motion],
)
self.dispatcher.config_updater.publish_update.assert_called_once()
self.dispatcher.publish.assert_called_once_with(
"front_door/birdseye_mode/state",
"MOTION,STATIONARY_OBJECTS",
"front_door/birdseye_modes/state",
"MOTION,ALERTS",
retain=True,
)
def test_single_activity_type_is_accepted(self) -> None:
self.dispatcher._on_birdseye_mode_command("front_door", "OBJECTS")
self.dispatcher._on_birdseye_modes_command("front_door", "ALL_OBJECTS")
self.assertEqual(
self.camera.birdseye.mode,
BirdseyeModeConfig(objects=True),
)
self.assertEqual(self.camera.birdseye.modes, [BirdseyeModeEnum.all_objects])
self.dispatcher.publish.assert_called_once_with(
"front_door/birdseye_mode/state", "OBJECTS", retain=True
"front_door/birdseye_modes/state", "ALL_OBJECTS", retain=True
)
def test_none_clears_every_activity_type(self) -> None:
self.dispatcher._on_birdseye_modes_command("front_door", "NONE")
self.assertEqual(self.camera.birdseye.modes, [])
self.dispatcher.publish.assert_called_once_with(
"front_door/birdseye_modes/state", "NONE", retain=True
)
def test_unknown_mode_is_rejected(self) -> None:
@ -93,13 +96,12 @@ class TestBirdseyeModeCommands(unittest.TestCase):
"UNKNOWN",
"motion",
"MOTION_OBJECTS",
"NONE",
"NONE,MOTION",
"MOTION,MOTION",
"MOTION,",
):
with self.subTest(payload=payload):
self.dispatcher._on_birdseye_mode_command("front_door", payload)
self.dispatcher._on_birdseye_modes_command("front_door", payload)
self.dispatcher.config_updater.publish_update.assert_not_called()

View File

@ -6,7 +6,7 @@ import os
import unittest
from unittest.mock import MagicMock, patch
from frigate.config import FrigateConfig
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.profile import CameraProfileConfig
from frigate.config.profile import ProfileDefinitionConfig
from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager
@ -561,23 +561,22 @@ class TestProfileManager(unittest.TestCase):
assert self.config.cameras["front"].enabled is False
@patch.object(ProfileManager, "_persist_active_profile")
def test_profile_can_disable_inherited_birdseye_activity(self, mock_persist):
"""A false-only mode override inherits the other base activity types."""
def test_profile_replaces_birdseye_activity_modes(self, mock_persist):
"""A profile mode list replaces the base list rather than merging into it."""
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
base_mode = self.config.cameras["front"].birdseye.mode
base_mode.motion = True
base_mode.objects = True
self.config.cameras["front"].birdseye.modes = [
BirdseyeModeEnum.motion,
BirdseyeModeEnum.all_objects,
]
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
birdseye={"mode": {"motion": False}}
birdseye={"modes": ["alerts"]}
)
self.manager = ProfileManager(self.config, self.mock_updater)
err = self.manager.activate_profile("away")
assert err is None
mode = self.config.cameras["front"].birdseye.mode
assert not mode.motion
assert mode.objects
assert self.config.cameras["front"].birdseye.modes == [BirdseyeModeEnum.alerts]
@patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_restores_enabled(self, mock_persist):

View File

@ -533,19 +533,26 @@ def _convert_legacy_mask_to_dict(
return result
# the 0.18 scalar Birdseye modes, mapped to their activity mode names
_LEGACY_BIRDSEYE_MODES = {
"continuous": "continuous",
"motion": "motion",
"objects": "all_objects",
}
def _migrate_birdseye_mode(birdseye: dict[str, Any] | None) -> None:
"""Convert a scalar Birdseye mode to composable activity types."""
"""Convert a scalar Birdseye mode to an activity mode list."""
if not birdseye or not isinstance(birdseye.get("mode"), str):
return
legacy_mode = birdseye["mode"]
activity_types = ("continuous", "motion", "objects", "stationary_objects")
if legacy_mode not in activity_types:
if legacy_mode not in _LEGACY_BIRDSEYE_MODES:
return
birdseye["mode"] = {
activity_type: activity_type == legacy_mode for activity_type in activity_types
}
del birdseye["mode"]
birdseye["modes"] = [_LEGACY_BIRDSEYE_MODES[legacy_mode]]
def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -70,25 +70,9 @@
"label": "Enable Birdseye",
"description": "Enable or disable the Birdseye view feature."
},
"mode": {
"modes": {
"label": "Activity types",
"description": "Activity types that include cameras in Birdseye.",
"continuous": {
"label": "Continuous",
"description": "Always include the camera in Birdseye."
},
"motion": {
"label": "Motion",
"description": "Include the camera in Birdseye when motion is detected."
},
"objects": {
"label": "Active objects",
"description": "Include the camera in Birdseye while an active object is tracked."
},
"stationary_objects": {
"label": "Stationary objects",
"description": "Include the camera in Birdseye while a stationary object is tracked."
}
"description": "Activity types that include cameras in Birdseye."
},
"order": {
"label": "Position",

View File

@ -557,25 +557,9 @@
"label": "Enable Birdseye",
"description": "Enable or disable the Birdseye view feature."
},
"mode": {
"modes": {
"label": "Activity types",
"description": "Activity types that include cameras in Birdseye.",
"continuous": {
"label": "Continuous",
"description": "Always include the camera in Birdseye."
},
"motion": {
"label": "Motion",
"description": "Include the camera in Birdseye when motion is detected."
},
"objects": {
"label": "Active objects",
"description": "Include the camera in Birdseye while an active object is tracked."
},
"stationary_objects": {
"label": "Stationary objects",
"description": "Include the camera in Birdseye while a stationary object is tracked."
}
"description": "Activity types that include cameras in Birdseye."
},
"restream": {
"label": "Restream RTSP",

View File

@ -1657,6 +1657,16 @@
"summary": "{{count}} selected",
"empty": "No zones available"
},
"birdseyeModes": {
"summary": "{{count}} selected",
"options": {
"continuous": "Continuous",
"motion": "Motion",
"all_objects": "All objects",
"alerts": "Alerts",
"detections": "Detections"
}
},
"inputRoles": {
"summary": "{{count}} roles selected",
"empty": "No roles available",
@ -1856,11 +1866,6 @@
}
},
"birdseye": {
"trackingMode": {
"objects": "Objects",
"motion": "Motion",
"continuous": "Continuous"
},
"cameraOrder": {
"label": "Camera order",
"description": "Drag cameras to set their order in the Birdseye layout.",

View File

@ -10,29 +10,26 @@ const birdseye: SectionConfigOverrides = {
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
const mode = ctx.formData?.mode;
if (!mode || typeof mode !== "object" || Array.isArray(mode)) {
const modes = ctx.formData?.modes;
if (!Array.isArray(modes)) {
return false;
}
return (
(mode.objects === true || mode.stationary_objects === true) &&
modes.includes("all_objects") &&
ctx.fullCameraConfig.detect?.enabled === false
);
},
},
],
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
fieldOrder: ["enabled", "modes", "order"],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: ["enabled", "mode"],
overrideFields: ["enabled", "modes"],
uiSchema: {
mode: {
continuous: { "ui:size": "xs" },
motion: { "ui:size": "xs" },
objects: { "ui:size": "xs" },
stationary_objects: { "ui:size": "xs" },
modes: {
"ui:widget": "birdseyeModes",
},
},
},
@ -43,7 +40,7 @@ const birdseye: SectionConfigOverrides = {
"width",
"height",
"quality",
"mode",
"modes",
"layout",
"inactivity_threshold",
"idle_heartbeat_fps",
@ -59,7 +56,7 @@ const birdseye: SectionConfigOverrides = {
"idle_heartbeat_fps",
],
uiSchema: {
mode: {
modes: {
"ui:after": { render: "BirdseyeCameraReorder" },
},
},

View File

@ -22,6 +22,7 @@ import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { BirdseyeModesWidget } from "./widgets/BirdseyeModesWidget";
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
import { GenAIModelWidget } from "./widgets/GenAIModelWidget";
@ -87,6 +88,7 @@ export const frigateTheme: FrigateTheme = {
audioLabels: AudioLabelSwitchesWidget,
reviewLabels: ReviewLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget,
birdseyeModes: BirdseyeModesWidget,
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
semanticSearchModel: SemanticSearchModelWidget,

View File

@ -0,0 +1,44 @@
// Birdseye Modes Widget - For selecting Birdseye activity types via switches
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
// order matches BirdseyeModeEnum in frigate/config/camera/birdseye.py
const BIRDSEYE_MODES: string[] = [
"continuous",
"motion",
"all_objects",
"alerts",
"detections",
];
function getBirdseyeModes(): string[] {
return BIRDSEYE_MODES;
}
function getBirdseyeModeLabel(mode: string, context?: FormContext): string {
const t = context?.t;
if (!t) {
return mode;
}
return t(`configForm.birdseyeModes.options.${mode}`, {
ns: "views/settings",
defaultValue: mode,
});
}
export function BirdseyeModesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getBirdseyeModes,
getDisplayLabel: getBirdseyeModeLabel,
i18nKey: "birdseyeModes",
}}
/>
);
}

View File

@ -480,7 +480,7 @@ export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
key: "audio_transcription",
compareFields: ["enabled", "live_enabled"],
},
{ key: "birdseye", compareFields: ["enabled", "mode"] },
{ key: "birdseye", compareFields: ["enabled", "modes"] },
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
{
key: "ffmpeg",

View File

@ -13,18 +13,18 @@ export interface UiConfig {
export interface BirdseyeConfig {
enabled: boolean;
height: number;
mode: BirdseyeModeConfig;
modes: BirdseyeMode[];
quality: number;
restream: boolean;
width: number;
}
export interface BirdseyeModeConfig {
continuous: boolean;
motion: boolean;
objects: boolean;
stationary_objects: boolean;
}
export type BirdseyeMode =
| "continuous"
| "motion"
| "all_objects"
| "alerts"
| "detections";
export interface FaceRecognitionConfig {
enabled: boolean;
@ -56,7 +56,7 @@ export interface CameraConfig {
best_image_timeout: number;
birdseye: {
enabled: boolean;
mode: BirdseyeModeConfig;
modes: BirdseyeMode[];
order: number;
};
detect: {