mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
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:
parent
36607133e5
commit
5e37b2c5c2
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
4
docs/static/frigate-api.yaml
vendored
4
docs/static/frigate-api.yaml
vendored
@ -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` |
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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='*')."
|
||||
),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user