mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-02 04:46:50 +03: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:
@@ -252,16 +252,14 @@ birdseye:
|
|||||||
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
|
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
|
||||||
quality: 8
|
quality: 8
|
||||||
# Optional: Activity types that include cameras in Birdseye (default: shown below)
|
# Optional: Activity types that include cameras in Birdseye (default: shown below)
|
||||||
# Multiple activity types can be enabled at the same time.
|
# Multiple activity types can be listed at the same time.
|
||||||
mode:
|
# continuous: all cameras are included always
|
||||||
# Optional: All cameras are included always (default: shown below)
|
# motion: included if motion was detected within the inactivity threshold
|
||||||
continuous: False
|
# all_objects: included if a tracked object was present within the inactivity threshold
|
||||||
# Optional: Cameras are included if motion was detected in the last 30 seconds (default: shown below)
|
# alerts: included while an alert review item is in progress
|
||||||
motion: False
|
# detections: included while a detection review item is in progress
|
||||||
# Optional: Cameras are included if they have had an active tracked object within the last 30 seconds (default: shown below)
|
modes:
|
||||||
objects: True
|
- all_objects
|
||||||
# Optional: Cameras are included while they have a stationary tracked object (default: shown below)
|
|
||||||
stationary_objects: False
|
|
||||||
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
|
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
|
||||||
inactivity_threshold: 30
|
inactivity_threshold: 30
|
||||||
# Optional: Configure the birdseye layout
|
# 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 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
|
- **continuous:** The camera is always included
|
||||||
- **motion:** The camera is included when motion was detected within the last 30 seconds
|
- **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
|
- **all_objects:** The camera is included when a tracked object is present, active or stationary
|
||||||
- **stationary_objects:** The camera is included while a stationary object is tracked
|
- **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
|
### Custom Birdseye Icon
|
||||||
|
|
||||||
@@ -50,20 +53,19 @@ To include a camera in Birdseye view only for specific circumstances, or exclude
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|
||||||
```yaml {8-11,13-15}
|
```yaml {10-12,15-16}
|
||||||
# Include all cameras by default in Birdseye view
|
# Include all cameras by default in Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
enabled: True
|
enabled: True
|
||||||
mode:
|
modes:
|
||||||
continuous: True
|
- continuous
|
||||||
|
|
||||||
cameras:
|
cameras:
|
||||||
front:
|
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:
|
birdseye:
|
||||||
mode:
|
modes:
|
||||||
continuous: False
|
- alerts
|
||||||
objects: True
|
|
||||||
back:
|
back:
|
||||||
# Exclude the "back" camera from Birdseye view
|
# Exclude the "back" camera from Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
@@ -75,7 +77,7 @@ cameras:
|
|||||||
|
|
||||||
### Birdseye Inactivity
|
### 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>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<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
|
# Include all cameras by default in Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
enabled: True
|
enabled: True
|
||||||
mode: continuous
|
modes:
|
||||||
|
- continuous
|
||||||
|
|
||||||
cameras:
|
cameras:
|
||||||
front:
|
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`.
|
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
|
_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._
|
the camera to be removed from the view._
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| -------------------- | ---------------------------------------------------------------- |
|
| ------------- | ---------------------------------------------------------------- |
|
||||||
| `CONTINUOUS` | Always included |
|
| `CONTINUOUS` | Always included |
|
||||||
| `MOTION` | Shown if motion was detected within the last 30 seconds |
|
| `MOTION` | Shown if motion was detected within the last 30 seconds |
|
||||||
| `OBJECTS` | Shown if an active object was tracked within the last 30 seconds |
|
| `ALL_OBJECTS` | Shown if a tracked object was present within the last 30 seconds |
|
||||||
| `STATIONARY_OBJECTS` | Shown while a stationary object is tracked |
|
| `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`
|
### `frigate/<camera_name>/notifications/set`
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-2
@@ -713,7 +713,7 @@ paths:
|
|||||||
| `improve_contrast` | `ON`, `OFF` |
|
| `improve_contrast` | `ON`, `OFF` |
|
||||||
| `ptz_autotracker` | `ON`, `OFF` |
|
| `ptz_autotracker` | `ON`, `OFF` |
|
||||||
| `birdseye` | `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_contour_area` | integer |
|
||||||
| `motion_threshold` | integer |
|
| `motion_threshold` | integer |
|
||||||
| `motion_mask` | `ON`, `OFF` |
|
| `motion_mask` | `ON`, `OFF` |
|
||||||
@@ -803,7 +803,7 @@ paths:
|
|||||||
| `improve_contrast` | `ON`, `OFF` |
|
| `improve_contrast` | `ON`, `OFF` |
|
||||||
| `ptz_autotracker` | `ON`, `OFF` |
|
| `ptz_autotracker` | `ON`, `OFF` |
|
||||||
| `birdseye` | `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_contour_area` | integer |
|
||||||
| `motion_threshold` | integer |
|
| `motion_threshold` | integer |
|
||||||
| `motion_mask` | `ON`, `OFF` |
|
| `motion_mask` | `ON`, `OFF` |
|
||||||
|
|||||||
@@ -1386,7 +1386,7 @@ def camera_set(
|
|||||||
| `improve_contrast` | `ON`, `OFF` |
|
| `improve_contrast` | `ON`, `OFF` |
|
||||||
| `ptz_autotracker` | `ON`, `OFF` |
|
| `ptz_autotracker` | `ON`, `OFF` |
|
||||||
| `birdseye` | `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_contour_area` | integer |
|
||||||
| `motion_threshold` | integer |
|
| `motion_threshold` | integer |
|
||||||
| `motion_mask` | `ON`, `OFF` |
|
| `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.base_communicator import Communicator
|
||||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||||
from frigate.comms.webpush import WebPushClient
|
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 (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
@@ -84,7 +88,7 @@ class Dispatcher:
|
|||||||
"recordings": self._on_recordings_command,
|
"recordings": self._on_recordings_command,
|
||||||
"snapshots": self._on_snapshots_command,
|
"snapshots": self._on_snapshots_command,
|
||||||
"birdseye": self._on_birdseye_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_alerts": self._on_alerts_command,
|
||||||
"review_detections": self._on_detections_command,
|
"review_detections": self._on_detections_command,
|
||||||
"object_descriptions": self._on_object_description_command,
|
"object_descriptions": self._on_object_description_command,
|
||||||
@@ -879,12 +883,12 @@ class Dispatcher:
|
|||||||
)
|
)
|
||||||
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
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."""
|
"""Callback for birdseye mode topic."""
|
||||||
|
|
||||||
mode = BirdseyeModeConfig.from_mqtt_payload(payload)
|
modes = birdseye_modes_from_mqtt_payload(payload)
|
||||||
if mode is None:
|
if modes is None:
|
||||||
logger.info("Invalid birdseye_mode command: %s", payload)
|
logger.info("Invalid birdseye_modes command: %s", payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
birdseye_settings = self.config.cameras[camera_name].birdseye
|
birdseye_settings = self.config.cameras[camera_name].birdseye
|
||||||
@@ -893,9 +897,9 @@ class Dispatcher:
|
|||||||
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
birdseye_settings.mode = mode
|
birdseye_settings.modes = modes
|
||||||
logger.info(
|
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(
|
self.config_updater.publish_update(
|
||||||
@@ -903,7 +907,9 @@ class Dispatcher:
|
|||||||
birdseye_settings,
|
birdseye_settings,
|
||||||
)
|
)
|
||||||
self.publish(
|
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:
|
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 paho.mqtt.enums import CallbackAPIVersion
|
||||||
|
|
||||||
from frigate.comms.base_communicator import Communicator
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -123,9 +123,9 @@ class MqttClient(Communicator):
|
|||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
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
|
if camera.birdseye.enabled
|
||||||
else "OFF"
|
else "OFF"
|
||||||
),
|
),
|
||||||
@@ -270,7 +270,7 @@ class MqttClient(Communicator):
|
|||||||
"motion_threshold",
|
"motion_threshold",
|
||||||
"motion_contour_area",
|
"motion_contour_area",
|
||||||
"birdseye",
|
"birdseye",
|
||||||
"birdseye_mode",
|
"birdseye_modes",
|
||||||
"review_alerts",
|
"review_alerts",
|
||||||
"review_detections",
|
"review_detections",
|
||||||
"object_descriptions",
|
"object_descriptions",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
@@ -6,78 +8,58 @@ __all__ = [
|
|||||||
"BirdseyeCameraConfig",
|
"BirdseyeCameraConfig",
|
||||||
"BirdseyeConfig",
|
"BirdseyeConfig",
|
||||||
"BirdseyeLayoutConfig",
|
"BirdseyeLayoutConfig",
|
||||||
"BirdseyeModeConfig",
|
"BirdseyeModeEnum",
|
||||||
|
"birdseye_modes_from_mqtt_payload",
|
||||||
|
"birdseye_modes_to_mqtt_payload",
|
||||||
]
|
]
|
||||||
|
|
||||||
BIRDSEYE_ACTIVITY_TYPES = (
|
# canonical MQTT payload for an empty mode list
|
||||||
"objects",
|
MQTT_NO_MODES = "NONE"
|
||||||
"motion",
|
|
||||||
"stationary_objects",
|
|
||||||
"continuous",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BirdseyeModeConfig(FrigateBaseModel):
|
class BirdseyeModeEnum(str, Enum):
|
||||||
continuous: bool = Field(
|
continuous = "continuous"
|
||||||
default=False,
|
motion = "motion"
|
||||||
title="Continuous",
|
all_objects = "all_objects"
|
||||||
description="Always include the camera in Birdseye.",
|
alerts = "alerts"
|
||||||
)
|
detections = "detections"
|
||||||
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.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mqtt_payload(cls, payload: str) -> "BirdseyeModeConfig | None":
|
def birdseye_modes_from_mqtt_payload(payload: str) -> list[BirdseyeModeEnum] | None:
|
||||||
"""Create mode options from an uppercase MQTT payload."""
|
"""Parse an uppercase MQTT payload into activity modes, or None when invalid."""
|
||||||
raw_modes = payload.split(",")
|
raw_modes = payload.split(",")
|
||||||
if not raw_modes or any(not mode for mode in raw_modes):
|
|
||||||
|
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
|
return None
|
||||||
|
|
||||||
modes = [mode.lower() for mode in raw_modes]
|
if mode in modes:
|
||||||
if any(
|
|
||||||
raw_mode != mode.upper() or mode not in BIRDSEYE_ACTIVITY_TYPES
|
|
||||||
for raw_mode, mode in zip(raw_modes, modes)
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(modes) != len(set(modes)):
|
modes.append(mode)
|
||||||
return None
|
|
||||||
|
|
||||||
return cls(**{mode: True for mode in modes})
|
return 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
|
|
||||||
|
|
||||||
|
|
||||||
def default_birdseye_mode() -> BirdseyeModeConfig:
|
def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
|
||||||
"""Return the default Birdseye mode configuration."""
|
"""Serialize activity modes for MQTT state topics."""
|
||||||
return BirdseyeModeConfig(objects=True)
|
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):
|
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||||
@@ -101,8 +83,8 @@ class BirdseyeConfig(FrigateBaseModel):
|
|||||||
title="Enable Birdseye",
|
title="Enable Birdseye",
|
||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
mode: BirdseyeModeConfig = Field(
|
modes: list[BirdseyeModeEnum] = Field(
|
||||||
default_factory=default_birdseye_mode,
|
default_factory=default_birdseye_modes,
|
||||||
title="Activity types",
|
title="Activity types",
|
||||||
description="Activity types that include cameras in Birdseye.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
@@ -156,8 +138,8 @@ class BirdseyeCameraConfig(BaseModel):
|
|||||||
title="Enable Birdseye",
|
title="Enable Birdseye",
|
||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
mode: BirdseyeModeConfig = Field(
|
modes: list[BirdseyeModeEnum] = Field(
|
||||||
default_factory=default_birdseye_mode,
|
default_factory=default_birdseye_modes,
|
||||||
title="Activity types",
|
title="Activity types",
|
||||||
description="Activity types that include cameras in Birdseye.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from .auth import AuthConfig
|
|||||||
from .base import FrigateBaseModel
|
from .base import FrigateBaseModel
|
||||||
from .camera import CameraConfig, CameraLiveConfig
|
from .camera import CameraConfig, CameraLiveConfig
|
||||||
from .camera.audio import AudioConfig, AudioFilterConfig
|
from .camera.audio import AudioConfig, AudioFilterConfig
|
||||||
from .camera.birdseye import BirdseyeConfig, BirdseyeModeConfig
|
from .camera.birdseye import BirdseyeConfig
|
||||||
from .camera.detect import DetectConfig
|
from .camera.detect import DetectConfig
|
||||||
from .camera.ffmpeg import FfmpegConfig
|
from .camera.ffmpeg import FfmpegConfig
|
||||||
from .camera.genai import GenAIConfig, GenAIRoleEnum
|
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:
|
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():
|
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:
|
if profile.zones:
|
||||||
for zone_name in profile.zones:
|
for zone_name in profile.zones:
|
||||||
if zone_name not in camera_config.zones:
|
if zone_name not in camera_config.zones:
|
||||||
@@ -1033,10 +1021,6 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
self.cameras[name] = camera_config
|
self.cameras[name] = camera_config
|
||||||
|
|
||||||
verify_config_roles(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_valid_live_stream_names(self, camera_config)
|
||||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||||
verify_zone_objects_are_tracked(camera_config)
|
verify_zone_objects_are_tracked(camera_config)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from frigate.config.camera.birdseye import birdseye_modes_to_mqtt_payload
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
@@ -42,9 +43,11 @@ SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
|
|||||||
"birdseye": [
|
"birdseye": [
|
||||||
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
||||||
(
|
(
|
||||||
"birdseye_mode",
|
"birdseye_modes",
|
||||||
lambda c: (
|
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",
|
"motion",
|
||||||
"enabled",
|
"enabled",
|
||||||
"birdseye",
|
"birdseye",
|
||||||
"birdseye_mode",
|
"birdseye_modes",
|
||||||
"improve_contrast",
|
"improve_contrast",
|
||||||
"ptz_autotracker",
|
"ptz_autotracker",
|
||||||
"motion_contour_area",
|
"motion_contour_area",
|
||||||
@@ -468,7 +468,7 @@ def get_tool_definitions(
|
|||||||
],
|
],
|
||||||
"description": (
|
"description": (
|
||||||
"The feature to change. Most features accept ON or OFF. "
|
"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. "
|
"motion_contour_area and motion_threshold accept a number. "
|
||||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||||
),
|
),
|
||||||
|
|||||||
+72
-36
@@ -17,9 +17,11 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
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.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
||||||
from frigate.output.ws_auth import ws_has_camera_access
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
copy_yuv_to_position,
|
copy_yuv_to_position,
|
||||||
@@ -33,9 +35,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class BirdseyeActivity:
|
class BirdseyeActivity:
|
||||||
"""Activity signals used to decide whether a camera is shown in Birdseye."""
|
"""Activity signals used to decide whether a camera is shown in Birdseye."""
|
||||||
|
|
||||||
has_active_object: bool
|
has_object: bool
|
||||||
has_stationary_object: bool
|
|
||||||
has_motion: bool
|
has_motion: bool
|
||||||
|
severity: str | None
|
||||||
|
|
||||||
|
|
||||||
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
||||||
@@ -367,6 +369,7 @@ class BirdsEyeFrameManager:
|
|||||||
settings.detect.height,
|
settings.detect.height,
|
||||||
],
|
],
|
||||||
"last_active_frame": 0.0,
|
"last_active_frame": 0.0,
|
||||||
|
"live_active": False,
|
||||||
"current_frame": 0.0,
|
"current_frame": 0.0,
|
||||||
"layout_frame": 0.0,
|
"layout_frame": 0.0,
|
||||||
"channel_dims": {
|
"channel_dims": {
|
||||||
@@ -418,16 +421,32 @@ class BirdsEyeFrameManager:
|
|||||||
channel_dims,
|
channel_dims,
|
||||||
)
|
)
|
||||||
|
|
||||||
def camera_active(
|
def camera_threshold_active(
|
||||||
self,
|
self,
|
||||||
mode: BirdseyeModeConfig,
|
modes: list[BirdseyeModeEnum],
|
||||||
activity: BirdseyeActivity,
|
activity: BirdseyeActivity,
|
||||||
) -> bool:
|
) -> 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 (
|
return (
|
||||||
mode.continuous
|
BirdseyeModeEnum.continuous in modes
|
||||||
or (mode.motion and activity.has_motion)
|
or (
|
||||||
or (mode.objects and activity.has_active_object)
|
BirdseyeModeEnum.alerts in modes
|
||||||
or (mode.stationary_objects and activity.has_stationary_object)
|
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]]:
|
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].birdseye.enabled
|
||||||
and self.config.cameras[cam].enabled_in_config
|
and self.config.cameras[cam].enabled_in_config
|
||||||
and self.config.cameras[cam].enabled
|
and self.config.cameras[cam].enabled
|
||||||
and cam_data["last_active_frame"] > 0
|
and (
|
||||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
cam_data["live_active"]
|
||||||
< self.config.birdseye.inactivity_threshold
|
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}")
|
logger.debug(f"Active cameras: {active_cameras}")
|
||||||
@@ -478,7 +503,9 @@ class BirdsEyeFrameManager:
|
|||||||
limited_active_cameras = sorted(
|
limited_active_cameras = sorted(
|
||||||
active_cameras,
|
active_cameras,
|
||||||
key=lambda active_camera: (
|
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"]
|
- self.cameras[active_camera]["last_active_frame"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -754,10 +781,10 @@ class BirdsEyeFrameManager:
|
|||||||
|
|
||||||
# disabling birdseye is a little tricky
|
# disabling birdseye is a little tricky
|
||||||
if not camera_config.birdseye.enabled or not camera_config.enabled:
|
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)
|
# if we've rendered a frame (we have activity state) then clear it
|
||||||
# then we need to set it to zero
|
if camera_state["last_active_frame"] > 0 or camera_state["live_active"]:
|
||||||
if camera_state["last_active_frame"] > 0:
|
|
||||||
camera_state["last_active_frame"] = 0
|
camera_state["last_active_frame"] = 0
|
||||||
|
camera_state["live_active"] = False
|
||||||
force_update = True
|
force_update = True
|
||||||
else:
|
else:
|
||||||
return False, False
|
return False, False
|
||||||
@@ -765,12 +792,13 @@ class BirdsEyeFrameManager:
|
|||||||
# update the last active frame for the camera
|
# update the last active frame for the camera
|
||||||
camera_state["current_frame"] = frame.copy()
|
camera_state["current_frame"] = frame.copy()
|
||||||
camera_state["current_frame_time"] = frame_time
|
camera_state["current_frame_time"] = frame_time
|
||||||
if self.camera_active(
|
modes = camera_config.birdseye.modes
|
||||||
camera_config.birdseye.mode,
|
|
||||||
activity,
|
if self.camera_threshold_active(modes, activity):
|
||||||
):
|
|
||||||
camera_state["last_active_frame"] = frame_time
|
camera_state["last_active_frame"] = frame_time
|
||||||
|
|
||||||
|
camera_state["live_active"] = self.camera_live_active(modes, activity)
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
# limit output to 10 fps
|
# limit output to 10 fps
|
||||||
@@ -828,6 +856,8 @@ class Birdseye:
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.requestor = InterProcessRequestor()
|
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_fps: float = self.config.birdseye.idle_heartbeat_fps
|
||||||
self._idle_interval: float | None = (
|
self._idle_interval: float | None = (
|
||||||
(1.0 / self.idle_fps) if self.idle_fps > 0 else 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.birdseye_manager.clear_frame()
|
||||||
self.__send_new_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:
|
def add_camera(self, camera: str) -> None:
|
||||||
"""Add a camera to the birdseye manager."""
|
"""Add a camera to the birdseye manager."""
|
||||||
self.birdseye_manager.add_camera(camera)
|
self.birdseye_manager.add_camera(camera)
|
||||||
@@ -866,6 +911,7 @@ class Birdseye:
|
|||||||
def remove_camera(self, camera: str) -> None:
|
def remove_camera(self, camera: str) -> None:
|
||||||
"""Remove a camera from the birdseye manager."""
|
"""Remove a camera from the birdseye manager."""
|
||||||
self.birdseye_manager.remove_camera(camera)
|
self.birdseye_manager.remove_camera(camera)
|
||||||
|
self.review_severity.pop(camera, None)
|
||||||
logger.debug(f"Removed camera {camera} from birdseye")
|
logger.debug(f"Removed camera {camera} from birdseye")
|
||||||
|
|
||||||
def write_data(
|
def write_data(
|
||||||
@@ -876,24 +922,13 @@ class Birdseye:
|
|||||||
frame_time: float,
|
frame_time: float,
|
||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
) -> None:
|
) -> 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(
|
activity = BirdseyeActivity(
|
||||||
has_active_object=has_active_object,
|
has_object=any(
|
||||||
has_stationary_object=has_stationary_object,
|
not tracked_object["false_positive"]
|
||||||
|
for tracked_object in current_tracked_objects
|
||||||
|
),
|
||||||
has_motion=bool(motion_boxes),
|
has_motion=bool(motion_boxes),
|
||||||
|
severity=self.review_severity.get(camera),
|
||||||
)
|
)
|
||||||
|
|
||||||
frame_changed, frame_layout_changed = self.birdseye_manager.update(
|
frame_changed, frame_layout_changed = self.birdseye_manager.update(
|
||||||
@@ -919,5 +954,6 @@ class Birdseye:
|
|||||||
self.__send_new_frame()
|
self.__send_new_frame()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
self.review_subscriber.stop()
|
||||||
self.converter.join()
|
self.converter.join()
|
||||||
self.broadcaster.join()
|
self.broadcaster.join()
|
||||||
|
|||||||
@@ -188,6 +188,11 @@ class OutputProcess(FrigateProcess):
|
|||||||
self.config.birdseye = birdseye_config
|
self.config.birdseye = birdseye_config
|
||||||
logger.debug("Applied dynamic birdseye config update")
|
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
|
# check if there is an updated config
|
||||||
updates = config_subscriber.check_for_updates()
|
updates = config_subscriber.check_for_updates()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
|
|||||||
|
|
||||||
import ruamel.yaml
|
import ruamel.yaml
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
@@ -383,22 +383,15 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
Global birdseye only seeds enabled and mode; the camera copies are what
|
Global birdseye only seeds enabled and mode; the camera copies are what
|
||||||
the output process actually reads. Sending just the global object makes
|
the output process actually reads. Sending just the global object makes
|
||||||
a worker guess which cameras were inheriting, and the only available
|
a worker guess which cameras were inheriting, and the only available
|
||||||
guess (mode still equals the previous global) wrongly claims a camera
|
guess (modes still equals the previous global) wrongly claims a camera
|
||||||
whose explicit yaml mode happens to match.
|
whose explicit yaml modes happens to match.
|
||||||
"""
|
"""
|
||||||
self.minimal_config["birdseye"] = {
|
self.minimal_config["birdseye"] = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": {"motion": True},
|
"modes": ["motion"],
|
||||||
}
|
}
|
||||||
# explicit override that matches the global value being replaced
|
# explicit override that matches the global value being replaced
|
||||||
self.minimal_config["cameras"]["front_door"]["birdseye"] = {
|
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"modes": ["motion"]}
|
||||||
"mode": {
|
|
||||||
"continuous": False,
|
|
||||||
"motion": True,
|
|
||||||
"objects": False,
|
|
||||||
"stationary_objects": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config_path = self._write_config_file()
|
config_path = self._write_config_file()
|
||||||
mock_find_config.return_value = config_path
|
mock_find_config.return_value = config_path
|
||||||
@@ -409,16 +402,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
resp = client.put(
|
resp = client.put(
|
||||||
"/config/set",
|
"/config/set",
|
||||||
json={
|
json={
|
||||||
"config_data": {
|
"config_data": {"birdseye": {"modes": ["continuous"]}},
|
||||||
"birdseye": {
|
|
||||||
"mode": {
|
|
||||||
"continuous": True,
|
|
||||||
"motion": False,
|
|
||||||
"objects": False,
|
|
||||||
"stationary_objects": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"update_topic": "config/birdseye",
|
"update_topic": "config/birdseye",
|
||||||
"requires_restart": 0,
|
"requires_restart": 0,
|
||||||
},
|
},
|
||||||
@@ -430,7 +414,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
mock_publisher.publisher.publish.assert_called_once()
|
mock_publisher.publisher.publish.assert_called_once()
|
||||||
topic, settings = mock_publisher.publisher.publish.call_args[0]
|
topic, settings = mock_publisher.publisher.publish.call_args[0]
|
||||||
self.assertEqual(topic, "config/birdseye")
|
self.assertEqual(topic, "config/birdseye")
|
||||||
self.assertEqual(settings.mode.to_mqtt_payload(), "CONTINUOUS")
|
self.assertEqual(settings.modes, [BirdseyeModeEnum.continuous])
|
||||||
|
|
||||||
published = {
|
published = {
|
||||||
call[0][0].camera: call[0][1]
|
call[0][0].camera: call[0][1]
|
||||||
@@ -445,10 +429,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
|
|
||||||
# the override survives, the inheriting camera follows global
|
# the override survives, the inheriting camera follows global
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
published["front_door"].mode.to_mqtt_payload(), "MOTION"
|
published["front_door"].modes, [BirdseyeModeEnum.motion]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
published["back_yard"].mode.to_mqtt_payload(), "CONTINUOUS"
|
published["back_yard"].modes, [BirdseyeModeEnum.continuous]
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
|
|||||||
+241
-37
@@ -4,7 +4,12 @@ import multiprocessing as mp
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import Mock
|
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 (
|
from frigate.output.birdseye import (
|
||||||
Birdseye,
|
Birdseye,
|
||||||
BirdseyeActivity,
|
BirdseyeActivity,
|
||||||
@@ -215,11 +220,7 @@ class TestBirdseyeActivity(unittest.TestCase):
|
|||||||
"mqtt": {"enabled": False},
|
"mqtt": {"enabled": False},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": {
|
"modes": ["motion", "all_objects"],
|
||||||
"motion": True,
|
|
||||||
"objects": True,
|
|
||||||
"stationary_objects": True,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"front": {
|
"front": {
|
||||||
@@ -234,53 +235,93 @@ class TestBirdseyeActivity(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.manager = BirdsEyeFrameManager(FrigateConfig(**config), mp.Event())
|
self.manager = BirdsEyeFrameManager(FrigateConfig(**config), mp.Event())
|
||||||
|
|
||||||
def test_existing_modes_keep_their_activity_rules(self):
|
def test_each_mode_matches_only_its_own_activity(self):
|
||||||
continuous = BirdseyeModeConfig(continuous=True)
|
motion_activity = BirdseyeActivity(
|
||||||
motion = BirdseyeModeConfig(motion=True)
|
has_object=False, has_motion=True, severity=None
|
||||||
objects = BirdseyeModeConfig(objects=True)
|
)
|
||||||
|
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)
|
assert self.manager.camera_threshold_active(
|
||||||
motion_activity = BirdseyeActivity(False, False, True)
|
[BirdseyeModeEnum.motion], motion_activity
|
||||||
stationary_activity = BirdseyeActivity(False, True, False)
|
)
|
||||||
active_object_activity = BirdseyeActivity(True, False, False)
|
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)
|
def test_continuous_is_not_threshold_activity(self):
|
||||||
assert self.manager.camera_active(motion, motion_activity)
|
no_activity = BirdseyeActivity(
|
||||||
assert not self.manager.camera_active(motion, stationary_activity)
|
has_object=False, has_motion=False, severity=None
|
||||||
assert self.manager.camera_active(objects, active_object_activity)
|
)
|
||||||
assert not self.manager.camera_active(objects, stationary_activity)
|
|
||||||
|
assert not self.manager.camera_threshold_active(
|
||||||
|
[BirdseyeModeEnum.continuous], no_activity
|
||||||
|
)
|
||||||
|
|
||||||
def test_modes_can_be_combined(self):
|
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_threshold_active(
|
||||||
assert self.manager.camera_active(mode, BirdseyeActivity(False, True, False))
|
modes, BirdseyeActivity(has_object=False, has_motion=True, severity=None)
|
||||||
assert not self.manager.camera_active(
|
)
|
||||||
mode, BirdseyeActivity(False, False, False)
|
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):
|
def test_empty_modes_never_activate(self):
|
||||||
stationary_objects = BirdseyeModeConfig(stationary_objects=True)
|
activity = BirdseyeActivity(has_object=True, has_motion=True, severity=None)
|
||||||
|
|
||||||
assert self.manager.camera_active(
|
assert not self.manager.camera_threshold_active([], activity)
|
||||||
stationary_objects, BirdseyeActivity(False, True, False)
|
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(
|
idle = BirdseyeActivity(has_object=False, has_motion=False, severity=None)
|
||||||
stationary_objects, BirdseyeActivity(True, False, False)
|
|
||||||
|
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.__new__(Birdseye)
|
||||||
birdseye.birdseye_manager = Mock()
|
birdseye.birdseye_manager = Mock()
|
||||||
birdseye.birdseye_manager.update.return_value = (False, False)
|
birdseye.birdseye_manager.update.return_value = (False, False)
|
||||||
birdseye._idle_interval = None
|
birdseye._idle_interval = None
|
||||||
|
birdseye.review_severity = {}
|
||||||
frame = Mock()
|
frame = Mock()
|
||||||
|
|
||||||
birdseye.write_data(
|
birdseye.write_data(
|
||||||
"front",
|
"front",
|
||||||
[
|
[
|
||||||
{"stationary": True, "false_positive": True},
|
{"stationary": True, "false_positive": True},
|
||||||
{"stationary": False, "false_positive": True},
|
|
||||||
{"stationary": True, "false_positive": False},
|
{"stationary": True, "false_positive": False},
|
||||||
],
|
],
|
||||||
[[0, 0, 10, 10]],
|
[[0, 0, 10, 10]],
|
||||||
@@ -289,26 +330,36 @@ class TestBirdseyeActivity(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
birdseye.birdseye_manager.update.assert_called_once_with(
|
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.__new__(Birdseye)
|
||||||
birdseye.birdseye_manager = Mock()
|
birdseye.birdseye_manager = Mock()
|
||||||
birdseye.birdseye_manager.update.return_value = (False, False)
|
birdseye.birdseye_manager.update.return_value = (False, False)
|
||||||
birdseye._idle_interval = None
|
birdseye._idle_interval = None
|
||||||
|
birdseye.review_severity = {}
|
||||||
frame = Mock()
|
frame = Mock()
|
||||||
|
|
||||||
birdseye.write_data(
|
birdseye.write_data(
|
||||||
"front",
|
"front",
|
||||||
[{"stationary": True, "false_positive": True}],
|
[
|
||||||
|
{"stationary": True, "false_positive": True},
|
||||||
|
{"stationary": False, "false_positive": True},
|
||||||
|
],
|
||||||
[],
|
[],
|
||||||
1.0,
|
1.0,
|
||||||
frame,
|
frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
birdseye.birdseye_manager.update.assert_called_once_with(
|
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):
|
def setUp(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"enabled": False},
|
"mqtt": {"enabled": False},
|
||||||
"birdseye": {"enabled": True, "mode": {"continuous": True}},
|
"birdseye": {"enabled": True, "modes": ["continuous"]},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
camera: {
|
camera: {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -340,6 +391,7 @@ class TestBirdseyeCameraOrder(unittest.TestCase):
|
|||||||
camera_data["current_frame"] = None
|
camera_data["current_frame"] = None
|
||||||
camera_data["current_frame_time"] = 1.0
|
camera_data["current_frame_time"] = 1.0
|
||||||
camera_data["last_active_frame"] = 1.0
|
camera_data["last_active_frame"] = 1.0
|
||||||
|
camera_data["live_active"] = True
|
||||||
|
|
||||||
def layout_order(self) -> list[str]:
|
def layout_order(self) -> list[str]:
|
||||||
"""Return the cameras in the order the current layout renders them."""
|
"""Return the cameras in the order the current layout renders them."""
|
||||||
@@ -377,3 +429,155 @@ class TestBirdseyeCameraOrder(unittest.TestCase):
|
|||||||
|
|
||||||
assert not layout_changed
|
assert not layout_changed
|
||||||
assert self.layout_order() == ["back", "front", "side"]
|
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,
|
||||||
|
)
|
||||||
|
|||||||
+23
-96
@@ -8,7 +8,7 @@ import numpy as np
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from ruamel.yaml.constructor import DuplicateKeyError
|
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.const import MODEL_CACHE_DIR
|
||||||
from frigate.detectors import DetectorTypeEnum
|
from frigate.detectors import DetectorTypeEnum
|
||||||
from frigate.util.builtin import deep_merge
|
from frigate.util.builtin import deep_merge
|
||||||
@@ -171,7 +171,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
def test_override_birdseye(self):
|
def test_override_birdseye(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"birdseye": {"enabled": True, "mode": {"continuous": True}},
|
"birdseye": {"enabled": True, "modes": ["continuous"]},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"back": {
|
"back": {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -186,7 +186,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"mode": {"continuous": False, "motion": True},
|
"modes": ["motion"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -194,18 +194,16 @@ class TestConfig(unittest.TestCase):
|
|||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert not frigate_config.cameras["back"].birdseye.enabled
|
assert not frigate_config.cameras["back"].birdseye.enabled
|
||||||
mode = frigate_config.cameras["back"].birdseye.mode
|
assert frigate_config.cameras["back"].birdseye.modes == [
|
||||||
assert mode.motion
|
BirdseyeModeEnum.motion
|
||||||
assert not mode.continuous
|
]
|
||||||
assert not mode.objects
|
|
||||||
assert not mode.stationary_objects
|
|
||||||
|
|
||||||
def test_override_birdseye_non_inheritable(self):
|
def test_override_birdseye_non_inheritable(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": {"continuous": True},
|
"modes": ["continuous"],
|
||||||
"height": 1920,
|
"height": 1920,
|
||||||
},
|
},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@@ -229,109 +227,38 @@ class TestConfig(unittest.TestCase):
|
|||||||
|
|
||||||
def test_inherit_birdseye(self):
|
def test_inherit_birdseye(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
**self.minimal,
|
||||||
"birdseye": {"enabled": True, "mode": {"continuous": True}},
|
"birdseye": {"enabled": True, "modes": ["continuous"]},
|
||||||
"cameras": {
|
|
||||||
"back": {
|
|
||||||
"ffmpeg": {
|
|
||||||
"inputs": [
|
|
||||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"detect": {
|
|
||||||
"height": 1080,
|
|
||||||
"width": 1920,
|
|
||||||
"fps": 5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert frigate_config.cameras["back"].birdseye.enabled
|
assert frigate_config.cameras["back"].birdseye.enabled
|
||||||
mode = frigate_config.cameras["back"].birdseye.mode
|
assert frigate_config.cameras["back"].birdseye.modes == [
|
||||||
assert mode.continuous
|
BirdseyeModeEnum.continuous
|
||||||
assert not mode.motion
|
]
|
||||||
assert not mode.objects
|
|
||||||
assert not mode.stationary_objects
|
|
||||||
|
|
||||||
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 = {
|
config = {
|
||||||
**self.minimal,
|
**self.minimal,
|
||||||
"birdseye": {
|
"birdseye": {"modes": ["motion", "all_objects"]},
|
||||||
"mode": {
|
|
||||||
"motion": True,
|
|
||||||
"stationary_objects": True,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
config["cameras"]["back"]["birdseye"] = {"modes": ["alerts"]}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
mode = frigate_config.cameras["back"].birdseye.mode
|
assert frigate_config.cameras["back"].birdseye.modes == [
|
||||||
assert mode.motion
|
BirdseyeModeEnum.alerts
|
||||||
assert mode.stationary_objects
|
]
|
||||||
assert not mode.continuous
|
|
||||||
assert not mode.objects
|
|
||||||
|
|
||||||
def test_birdseye_requires_an_activity_type(self):
|
def test_camera_can_select_no_modes(self):
|
||||||
config = {
|
config = {
|
||||||
**self.minimal,
|
**self.minimal,
|
||||||
"birdseye": {
|
"birdseye": {"modes": ["motion"]},
|
||||||
"mode": {
|
|
||||||
"continuous": False,
|
|
||||||
"motion": False,
|
|
||||||
"objects": False,
|
|
||||||
"stationary_objects": False,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
config["cameras"]["back"]["birdseye"] = {"modes": []}
|
||||||
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}}
|
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
mode = frigate_config.cameras["back"].birdseye.mode
|
assert frigate_config.cameras["back"].birdseye.modes == []
|
||||||
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
|
|
||||||
|
|
||||||
def test_override_tracked_objects(self):
|
def test_override_tracked_objects(self):
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from frigate.app import FrigateApp
|
from frigate.app import FrigateApp
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||||
from frigate.config import BirdseyeModeConfig
|
from frigate.config import BirdseyeModeEnum
|
||||||
|
|
||||||
|
|
||||||
def _make_camera_mock(
|
def _make_camera_mock(
|
||||||
@@ -53,7 +53,7 @@ def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
|
|||||||
|
|
||||||
|
|
||||||
class TestBirdseyeModeCommands(unittest.TestCase):
|
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:
|
def setUp(self) -> None:
|
||||||
self.camera = _make_camera_mock()
|
self.camera = _make_camera_mock()
|
||||||
@@ -62,30 +62,33 @@ class TestBirdseyeModeCommands(unittest.TestCase):
|
|||||||
self.dispatcher.publish = MagicMock()
|
self.dispatcher.publish = MagicMock()
|
||||||
|
|
||||||
def test_combined_modes_are_accepted(self) -> None:
|
def test_combined_modes_are_accepted(self) -> None:
|
||||||
self.dispatcher._on_birdseye_mode_command(
|
self.dispatcher._on_birdseye_modes_command("front_door", "ALERTS,MOTION")
|
||||||
"front_door", "STATIONARY_OBJECTS,MOTION"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.camera.birdseye.mode,
|
self.camera.birdseye.modes,
|
||||||
BirdseyeModeConfig(motion=True, stationary_objects=True),
|
[BirdseyeModeEnum.alerts, BirdseyeModeEnum.motion],
|
||||||
)
|
)
|
||||||
self.dispatcher.config_updater.publish_update.assert_called_once()
|
self.dispatcher.config_updater.publish_update.assert_called_once()
|
||||||
self.dispatcher.publish.assert_called_once_with(
|
self.dispatcher.publish.assert_called_once_with(
|
||||||
"front_door/birdseye_mode/state",
|
"front_door/birdseye_modes/state",
|
||||||
"MOTION,STATIONARY_OBJECTS",
|
"MOTION,ALERTS",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_single_activity_type_is_accepted(self) -> None:
|
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.assertEqual(self.camera.birdseye.modes, [BirdseyeModeEnum.all_objects])
|
||||||
self.camera.birdseye.mode,
|
|
||||||
BirdseyeModeConfig(objects=True),
|
|
||||||
)
|
|
||||||
self.dispatcher.publish.assert_called_once_with(
|
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:
|
def test_unknown_mode_is_rejected(self) -> None:
|
||||||
@@ -93,13 +96,12 @@ class TestBirdseyeModeCommands(unittest.TestCase):
|
|||||||
"UNKNOWN",
|
"UNKNOWN",
|
||||||
"motion",
|
"motion",
|
||||||
"MOTION_OBJECTS",
|
"MOTION_OBJECTS",
|
||||||
"NONE",
|
|
||||||
"NONE,MOTION",
|
"NONE,MOTION",
|
||||||
"MOTION,MOTION",
|
"MOTION,MOTION",
|
||||||
"MOTION,",
|
"MOTION,",
|
||||||
):
|
):
|
||||||
with self.subTest(payload=payload):
|
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()
|
self.dispatcher.config_updater.publish_update.assert_not_called()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
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.camera.profile import CameraProfileConfig
|
||||||
from frigate.config.profile import ProfileDefinitionConfig
|
from frigate.config.profile import ProfileDefinitionConfig
|
||||||
from frigate.config.profile_manager import PERSISTENCE_FILE, ProfileManager
|
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
|
assert self.config.cameras["front"].enabled is False
|
||||||
|
|
||||||
@patch.object(ProfileManager, "_persist_active_profile")
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
def test_profile_can_disable_inherited_birdseye_activity(self, mock_persist):
|
def test_profile_replaces_birdseye_activity_modes(self, mock_persist):
|
||||||
"""A false-only mode override inherits the other base activity types."""
|
"""A profile mode list replaces the base list rather than merging into it."""
|
||||||
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||||
base_mode = self.config.cameras["front"].birdseye.mode
|
self.config.cameras["front"].birdseye.modes = [
|
||||||
base_mode.motion = True
|
BirdseyeModeEnum.motion,
|
||||||
base_mode.objects = True
|
BirdseyeModeEnum.all_objects,
|
||||||
|
]
|
||||||
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||||
birdseye={"mode": {"motion": False}}
|
birdseye={"modes": ["alerts"]}
|
||||||
)
|
)
|
||||||
self.manager = ProfileManager(self.config, self.mock_updater)
|
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||||
|
|
||||||
err = self.manager.activate_profile("away")
|
err = self.manager.activate_profile("away")
|
||||||
|
|
||||||
assert err is None
|
assert err is None
|
||||||
mode = self.config.cameras["front"].birdseye.mode
|
assert self.config.cameras["front"].birdseye.modes == [BirdseyeModeEnum.alerts]
|
||||||
assert not mode.motion
|
|
||||||
assert mode.objects
|
|
||||||
|
|
||||||
@patch.object(ProfileManager, "_persist_active_profile")
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
def test_deactivate_restores_enabled(self, mock_persist):
|
def test_deactivate_restores_enabled(self, mock_persist):
|
||||||
|
|||||||
+13
-6
@@ -533,19 +533,26 @@ def _convert_legacy_mask_to_dict(
|
|||||||
return result
|
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:
|
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):
|
if not birdseye or not isinstance(birdseye.get("mode"), str):
|
||||||
return
|
return
|
||||||
|
|
||||||
legacy_mode = birdseye["mode"]
|
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
|
return
|
||||||
|
|
||||||
birdseye["mode"] = {
|
del birdseye["mode"]
|
||||||
activity_type: activity_type == legacy_mode for activity_type in activity_types
|
birdseye["modes"] = [_LEGACY_BIRDSEYE_MODES[legacy_mode]]
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
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",
|
"label": "Enable Birdseye",
|
||||||
"description": "Enable or disable the Birdseye view feature."
|
"description": "Enable or disable the Birdseye view feature."
|
||||||
},
|
},
|
||||||
"mode": {
|
"modes": {
|
||||||
"label": "Activity types",
|
"label": "Activity types",
|
||||||
"description": "Activity types that include cameras in Birdseye.",
|
"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."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"order": {
|
"order": {
|
||||||
"label": "Position",
|
"label": "Position",
|
||||||
|
|||||||
@@ -557,25 +557,9 @@
|
|||||||
"label": "Enable Birdseye",
|
"label": "Enable Birdseye",
|
||||||
"description": "Enable or disable the Birdseye view feature."
|
"description": "Enable or disable the Birdseye view feature."
|
||||||
},
|
},
|
||||||
"mode": {
|
"modes": {
|
||||||
"label": "Activity types",
|
"label": "Activity types",
|
||||||
"description": "Activity types that include cameras in Birdseye.",
|
"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."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"restream": {
|
"restream": {
|
||||||
"label": "Restream RTSP",
|
"label": "Restream RTSP",
|
||||||
|
|||||||
@@ -1657,6 +1657,16 @@
|
|||||||
"summary": "{{count}} selected",
|
"summary": "{{count}} selected",
|
||||||
"empty": "No zones available"
|
"empty": "No zones available"
|
||||||
},
|
},
|
||||||
|
"birdseyeModes": {
|
||||||
|
"summary": "{{count}} selected",
|
||||||
|
"options": {
|
||||||
|
"continuous": "Continuous",
|
||||||
|
"motion": "Motion",
|
||||||
|
"all_objects": "All objects",
|
||||||
|
"alerts": "Alerts",
|
||||||
|
"detections": "Detections"
|
||||||
|
}
|
||||||
|
},
|
||||||
"inputRoles": {
|
"inputRoles": {
|
||||||
"summary": "{{count}} roles selected",
|
"summary": "{{count}} roles selected",
|
||||||
"empty": "No roles available",
|
"empty": "No roles available",
|
||||||
@@ -1856,11 +1866,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"trackingMode": {
|
|
||||||
"objects": "Objects",
|
|
||||||
"motion": "Motion",
|
|
||||||
"continuous": "Continuous"
|
|
||||||
},
|
|
||||||
"cameraOrder": {
|
"cameraOrder": {
|
||||||
"label": "Camera order",
|
"label": "Camera order",
|
||||||
"description": "Drag cameras to set their order in the Birdseye layout.",
|
"description": "Drag cameras to set their order in the Birdseye layout.",
|
||||||
|
|||||||
@@ -10,29 +10,26 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
severity: "info",
|
severity: "info",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
const mode = ctx.formData?.mode;
|
const modes = ctx.formData?.modes;
|
||||||
if (!mode || typeof mode !== "object" || Array.isArray(mode)) {
|
if (!Array.isArray(modes)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(mode.objects === true || mode.stationary_objects === true) &&
|
modes.includes("all_objects") &&
|
||||||
ctx.fullCameraConfig.detect?.enabled === false
|
ctx.fullCameraConfig.detect?.enabled === false
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: ["enabled", "mode", "order"],
|
fieldOrder: ["enabled", "modes", "order"],
|
||||||
hiddenFields: ["order"],
|
hiddenFields: ["order"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
overrideFields: ["enabled", "mode"],
|
overrideFields: ["enabled", "modes"],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
mode: {
|
modes: {
|
||||||
continuous: { "ui:size": "xs" },
|
"ui:widget": "birdseyeModes",
|
||||||
motion: { "ui:size": "xs" },
|
|
||||||
objects: { "ui:size": "xs" },
|
|
||||||
stationary_objects: { "ui:size": "xs" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,7 +40,7 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
"width",
|
"width",
|
||||||
"height",
|
"height",
|
||||||
"quality",
|
"quality",
|
||||||
"mode",
|
"modes",
|
||||||
"layout",
|
"layout",
|
||||||
"inactivity_threshold",
|
"inactivity_threshold",
|
||||||
"idle_heartbeat_fps",
|
"idle_heartbeat_fps",
|
||||||
@@ -59,7 +56,7 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
"idle_heartbeat_fps",
|
"idle_heartbeat_fps",
|
||||||
],
|
],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
mode: {
|
modes: {
|
||||||
"ui:after": { render: "BirdseyeCameraReorder" },
|
"ui:after": { render: "BirdseyeCameraReorder" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
|
|||||||
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
|
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
|
||||||
import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget";
|
import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget";
|
||||||
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
|
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
|
||||||
|
import { BirdseyeModesWidget } from "./widgets/BirdseyeModesWidget";
|
||||||
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
|
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
|
||||||
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
||||||
import { GenAIModelWidget } from "./widgets/GenAIModelWidget";
|
import { GenAIModelWidget } from "./widgets/GenAIModelWidget";
|
||||||
@@ -87,6 +88,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
audioLabels: AudioLabelSwitchesWidget,
|
audioLabels: AudioLabelSwitchesWidget,
|
||||||
reviewLabels: ReviewLabelSwitchesWidget,
|
reviewLabels: ReviewLabelSwitchesWidget,
|
||||||
zoneNames: ZoneSwitchesWidget,
|
zoneNames: ZoneSwitchesWidget,
|
||||||
|
birdseyeModes: BirdseyeModesWidget,
|
||||||
timezoneSelect: TimezoneSelectWidget,
|
timezoneSelect: TimezoneSelectWidget,
|
||||||
optionalField: OptionalFieldWidget,
|
optionalField: OptionalFieldWidget,
|
||||||
semanticSearchModel: SemanticSearchModelWidget,
|
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",
|
key: "audio_transcription",
|
||||||
compareFields: ["enabled", "live_enabled"],
|
compareFields: ["enabled", "live_enabled"],
|
||||||
},
|
},
|
||||||
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
{ key: "birdseye", compareFields: ["enabled", "modes"] },
|
||||||
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
||||||
{
|
{
|
||||||
key: "ffmpeg",
|
key: "ffmpeg",
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export interface UiConfig {
|
|||||||
export interface BirdseyeConfig {
|
export interface BirdseyeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
mode: BirdseyeModeConfig;
|
modes: BirdseyeMode[];
|
||||||
quality: number;
|
quality: number;
|
||||||
restream: boolean;
|
restream: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BirdseyeModeConfig {
|
export type BirdseyeMode =
|
||||||
continuous: boolean;
|
| "continuous"
|
||||||
motion: boolean;
|
| "motion"
|
||||||
objects: boolean;
|
| "all_objects"
|
||||||
stationary_objects: boolean;
|
| "alerts"
|
||||||
}
|
| "detections";
|
||||||
|
|
||||||
export interface FaceRecognitionConfig {
|
export interface FaceRecognitionConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -56,7 +56,7 @@ export interface CameraConfig {
|
|||||||
best_image_timeout: number;
|
best_image_timeout: number;
|
||||||
birdseye: {
|
birdseye: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mode: BirdseyeModeConfig;
|
modes: BirdseyeMode[];
|
||||||
order: number;
|
order: number;
|
||||||
};
|
};
|
||||||
detect: {
|
detect: {
|
||||||
|
|||||||
Reference in New Issue
Block a user