mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 23:02:49 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8935ec1b2 | ||
|
|
3180d63df1 | ||
|
|
fa1c41974f |
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
|
|||||||
|
|
||||||
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
|
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
|
||||||
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
|
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
|
||||||
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
|
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
|
||||||
4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
|
4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
|
||||||
|
|
||||||
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
|
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
|
||||||
|
|
||||||
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
|
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -77,7 +77,7 @@ from frigate.notices.registry import NoticeRegistry
|
|||||||
from frigate.object_detection.base import ObjectDetectProcess
|
from frigate.object_detection.base import ObjectDetectProcess
|
||||||
from frigate.object_detection.util import detection_frame_size
|
from frigate.object_detection.util import detection_frame_size
|
||||||
from frigate.output.output import OutputProcess
|
from frigate.output.output import OutputProcess
|
||||||
from frigate.ptz.autotrack import PtzAutoTracker
|
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||||
from frigate.ptz.onvif import OnvifController
|
from frigate.ptz.onvif import OnvifController
|
||||||
from frigate.record.cleanup import RecordingCleanup
|
from frigate.record.cleanup import RecordingCleanup
|
||||||
from frigate.record.export import migrate_exports
|
from frigate.record.export import migrate_exports
|
||||||
@@ -166,7 +166,11 @@ class FrigateApp:
|
|||||||
# create camera_metrics
|
# create camera_metrics
|
||||||
for camera_name in self.config.cameras.keys():
|
for camera_name in self.config.cameras.keys():
|
||||||
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
|
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
|
||||||
self.ptz_metrics[camera_name] = PTZMetrics()
|
self.ptz_metrics[camera_name] = PTZMetrics(
|
||||||
|
autotracker_enabled=self.config.cameras[
|
||||||
|
camera_name
|
||||||
|
].onvif.autotracking.enabled
|
||||||
|
)
|
||||||
|
|
||||||
def init_queues(self) -> None:
|
def init_queues(self) -> None:
|
||||||
# Queue for cameras to push tracked objects to
|
# Queue for cameras to push tracked objects to
|
||||||
@@ -439,7 +443,7 @@ class FrigateApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def start_ptz_autotracker(self) -> None:
|
def start_ptz_autotracker(self) -> None:
|
||||||
self.ptz_autotracker_thread = PtzAutoTracker(
|
self.ptz_autotracker_thread = PtzAutoTrackerThread(
|
||||||
self.config,
|
self.config,
|
||||||
self.onvif_controller,
|
self.onvif_controller,
|
||||||
self.ptz_metrics,
|
self.ptz_metrics,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class CameraMetrics:
|
|||||||
|
|
||||||
|
|
||||||
class PTZMetrics:
|
class PTZMetrics:
|
||||||
|
autotracker_enabled: Synchronized
|
||||||
|
|
||||||
start_time: Synchronized
|
start_time: Synchronized
|
||||||
stop_time: Synchronized
|
stop_time: Synchronized
|
||||||
frame_time: Synchronized
|
frame_time: Synchronized
|
||||||
@@ -50,10 +52,13 @@ class PTZMetrics:
|
|||||||
max_zoom: Synchronized
|
max_zoom: Synchronized
|
||||||
min_zoom: Synchronized
|
min_zoom: Synchronized
|
||||||
|
|
||||||
|
tracking_active: Event
|
||||||
motor_stopped: Event
|
motor_stopped: Event
|
||||||
reset: Event
|
reset: Event
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, autotracker_enabled: bool):
|
||||||
|
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
|
||||||
|
|
||||||
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
|
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||||
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
|
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||||
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
|
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||||
@@ -61,6 +66,7 @@ class PTZMetrics:
|
|||||||
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||||
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||||
|
|
||||||
|
self.tracking_active = mp.Event()
|
||||||
self.motor_stopped = mp.Event()
|
self.motor_stopped = mp.Event()
|
||||||
self.reset = mp.Event()
|
self.reset = mp.Event()
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ class CameraMaintainer(threading.Thread):
|
|||||||
|
|
||||||
if runtime:
|
if runtime:
|
||||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||||
self.ptz_metrics[name] = PTZMetrics()
|
self.ptz_metrics[name] = PTZMetrics(
|
||||||
|
autotracker_enabled=config.onvif.autotracking.enabled
|
||||||
|
)
|
||||||
self.region_grids[name] = get_camera_regions_grid(
|
self.region_grids[name] = get_camera_regions_grid(
|
||||||
name,
|
name,
|
||||||
config.detect,
|
config.detect,
|
||||||
|
|||||||
+13
-9
@@ -16,7 +16,7 @@ from frigate.config import (
|
|||||||
ZoomingModeEnum,
|
ZoomingModeEnum,
|
||||||
)
|
)
|
||||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||||
from frigate.track.tracked_object import TrackedObject
|
from frigate.track.tracked_object import TrackedObject
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
@@ -35,7 +35,7 @@ class CameraState:
|
|||||||
name: str,
|
name: str,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
frame_manager: SharedMemoryFrameManager,
|
frame_manager: SharedMemoryFrameManager,
|
||||||
ptz_autotracker_thread: PtzAutoTracker,
|
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -115,13 +115,17 @@ class CameraState:
|
|||||||
# draw thicker box around ptz autotracked object
|
# draw thicker box around ptz autotracked object
|
||||||
if (
|
if (
|
||||||
self.camera_config.onvif.autotracking.enabled
|
self.camera_config.onvif.autotracking.enabled
|
||||||
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
|
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
|
||||||
and self.ptz_autotracker_thread.tracked_object[self.name]
|
self.name
|
||||||
|
)
|
||||||
|
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||||
|
self.name
|
||||||
|
]
|
||||||
is not None
|
is not None
|
||||||
and obj["id"]
|
and obj["id"]
|
||||||
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
|
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||||
self.name
|
self.name
|
||||||
].obj_data["id"]
|
].obj_data["id"] # type: ignore[attr-defined]
|
||||||
and obj["frame_time"] == frame_time
|
and obj["frame_time"] == frame_time
|
||||||
):
|
):
|
||||||
thickness = 5
|
thickness = 5
|
||||||
@@ -134,9 +138,9 @@ class CameraState:
|
|||||||
and self.camera_config.detect.width is not None
|
and self.camera_config.detect.width is not None
|
||||||
and self.camera_config.detect.height is not None
|
and self.camera_config.detect.height is not None
|
||||||
):
|
):
|
||||||
max_target_box = calculate_max_target_box(
|
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
|
||||||
self.camera_config.onvif.autotracking.zoom_factor
|
self.name
|
||||||
)
|
]["max_target_box"] # type: ignore[index]
|
||||||
side_length = max_target_box * (
|
side_length = max_target_box * (
|
||||||
max(
|
max(
|
||||||
self.camera_config.detect.width,
|
self.camera_config.detect.width,
|
||||||
|
|||||||
@@ -759,13 +759,15 @@ class Dispatcher:
|
|||||||
"Autotracking must be enabled in the config to be turned on via MQTT."
|
"Autotracking must be enabled in the config to be turned on via MQTT."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not ptz_autotracker_settings.enabled:
|
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||||
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
||||||
|
self.ptz_metrics[camera_name].autotracker_enabled.value = True
|
||||||
self.ptz_metrics[camera_name].start_time.value = 0
|
self.ptz_metrics[camera_name].start_time.value = 0
|
||||||
ptz_autotracker_settings.enabled = True
|
ptz_autotracker_settings.enabled = True
|
||||||
elif payload == "OFF":
|
elif payload == "OFF":
|
||||||
if ptz_autotracker_settings.enabled:
|
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||||
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
||||||
|
self.ptz_metrics[camera_name].autotracker_enabled.value = False
|
||||||
self.ptz_metrics[camera_name].start_time.value = 0
|
self.ptz_metrics[camera_name].start_time.value = 0
|
||||||
ptz_autotracker_settings.enabled = False
|
ptz_autotracker_settings.enabled = False
|
||||||
|
|
||||||
|
|||||||
+3
-35
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import selectors
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -359,7 +358,7 @@ class MqttClient(Communicator):
|
|||||||
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
|
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
|
||||||
while not message_info.is_published() and time.monotonic() < deadline:
|
while not message_info.is_published() and time.monotonic() < deadline:
|
||||||
if (
|
if (
|
||||||
self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||||
!= mqtt.MQTT_ERR_SUCCESS
|
!= mqtt.MQTT_ERR_SUCCESS
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
@@ -369,37 +368,6 @@ class MqttClient(Communicator):
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _loop_client(self, timeout: float) -> int:
|
|
||||||
"""Drive Paho without select()'s limit on socket file descriptors."""
|
|
||||||
client = self.client
|
|
||||||
if client is None:
|
|
||||||
return mqtt.MQTT_ERR_NO_CONN
|
|
||||||
sock = client.socket()
|
|
||||||
if sock is None:
|
|
||||||
return mqtt.MQTT_ERR_NO_CONN
|
|
||||||
|
|
||||||
events = selectors.EVENT_READ
|
|
||||||
if client.want_write():
|
|
||||||
events |= selectors.EVENT_WRITE
|
|
||||||
# TLS can have decrypted bytes buffered even when the socket is not ready.
|
|
||||||
pending = hasattr(sock, "pending") and sock.pending() > 0
|
|
||||||
with selectors.DefaultSelector() as selector:
|
|
||||||
selector.register(sock, events)
|
|
||||||
ready = selector.select(0.0 if pending else timeout)
|
|
||||||
|
|
||||||
ready_events = 0
|
|
||||||
for _, mask in ready:
|
|
||||||
ready_events |= mask
|
|
||||||
if pending or ready_events & selectors.EVENT_READ:
|
|
||||||
result = client.loop_read()
|
|
||||||
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
|
|
||||||
return result
|
|
||||||
if ready_events & selectors.EVENT_WRITE:
|
|
||||||
result = client.loop_write()
|
|
||||||
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
|
|
||||||
return result
|
|
||||||
return client.loop_misc()
|
|
||||||
|
|
||||||
def _mqtt_loop_worker(self) -> None:
|
def _mqtt_loop_worker(self) -> None:
|
||||||
# The worker owns all socket I/O so reconnect, subscribe, and publish
|
# The worker owns all socket I/O so reconnect, subscribe, and publish
|
||||||
# ordering stays serialized in one place.
|
# ordering stays serialized in one place.
|
||||||
@@ -416,7 +384,7 @@ class MqttClient(Communicator):
|
|||||||
|
|
||||||
assert self.client is not None
|
assert self.client is not None
|
||||||
try:
|
try:
|
||||||
result = self._loop_client(timeout=MQTT_LOOP_TIMEOUT)
|
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT)
|
||||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||||
logger.warning("MQTT loop error: %s", err)
|
logger.warning("MQTT loop error: %s", err)
|
||||||
self._schedule_reconnect()
|
self._schedule_reconnect()
|
||||||
@@ -649,7 +617,7 @@ class MqttClient(Communicator):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
result = self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||||
logger.warning("MQTT publish wait failed: %s", err)
|
logger.warning("MQTT publish wait failed: %s", err)
|
||||||
self._schedule_reconnect()
|
self._schedule_reconnect()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class ImprovedMotionDetector(MotionDetector):
|
|||||||
config: RuntimeMotionConfig,
|
config: RuntimeMotionConfig,
|
||||||
fps: int,
|
fps: int,
|
||||||
ptz_metrics: PTZMetrics | None = None,
|
ptz_metrics: PTZMetrics | None = None,
|
||||||
autotracking_enabled: bool = False,
|
|
||||||
name: str = "improved",
|
name: str = "improved",
|
||||||
blur_radius: int = 1,
|
blur_radius: int = 1,
|
||||||
interpolation: int = cv2.INTER_NEAREST,
|
interpolation: int = cv2.INTER_NEAREST,
|
||||||
@@ -46,7 +45,6 @@ class ImprovedMotionDetector(MotionDetector):
|
|||||||
self.contrast_values[:, 1:2] = 255
|
self.contrast_values[:, 1:2] = 255
|
||||||
self.contrast_values_index = 0
|
self.contrast_values_index = 0
|
||||||
self.ptz_metrics = ptz_metrics
|
self.ptz_metrics = ptz_metrics
|
||||||
self.autotracking_enabled = autotracking_enabled
|
|
||||||
self.last_stop_time: float | None = None
|
self.last_stop_time: float | None = None
|
||||||
|
|
||||||
def is_calibrating(self) -> bool:
|
def is_calibrating(self) -> bool:
|
||||||
@@ -61,7 +59,8 @@ class ImprovedMotionDetector(MotionDetector):
|
|||||||
# if ptz motor is moving from autotracking, quickly return
|
# if ptz motor is moving from autotracking, quickly return
|
||||||
# a single box that is 80% of the frame
|
# a single box that is 80% of the frame
|
||||||
if self.ptz_metrics is not None and (
|
if self.ptz_metrics is not None and (
|
||||||
self.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
|
self.ptz_metrics.autotracker_enabled.value
|
||||||
|
and not self.ptz_metrics.motor_stopped.is_set()
|
||||||
):
|
):
|
||||||
return [
|
return [
|
||||||
(
|
(
|
||||||
@@ -163,7 +162,7 @@ class ImprovedMotionDetector(MotionDetector):
|
|||||||
# if so, reassign the average to the current frame so we begin with a new baseline
|
# if so, reassign the average to the current frame so we begin with a new baseline
|
||||||
if self.ptz_metrics is not None and (
|
if self.ptz_metrics is not None and (
|
||||||
# ensure we only do this for cameras with autotracking enabled
|
# ensure we only do this for cameras with autotracking enabled
|
||||||
self.autotracking_enabled
|
self.ptz_metrics.autotracker_enabled.value
|
||||||
and self.ptz_metrics.motor_stopped.is_set()
|
and self.ptz_metrics.motor_stopped.is_set()
|
||||||
and (
|
and (
|
||||||
self.last_stop_time is None
|
self.last_stop_time is None
|
||||||
|
|||||||
+318
-173
@@ -23,7 +23,6 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
|
|||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
CameraConfigUpdateTopic,
|
|
||||||
)
|
)
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
AUTOTRACKING_MAX_AREA_RATIO,
|
AUTOTRACKING_MAX_AREA_RATIO,
|
||||||
@@ -43,11 +42,6 @@ from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def calculate_max_target_box(zoom_factor: float) -> float:
|
|
||||||
"""Return the largest target box ratio allowed for a zoom factor."""
|
|
||||||
return AUTOTRACKING_MAX_AREA_RATIO ** (1 / zoom_factor)
|
|
||||||
|
|
||||||
|
|
||||||
def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
|
def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
|
||||||
# Determine if the PTZ was in motion at the set frame time
|
# Determine if the PTZ was in motion at the set frame time
|
||||||
# for non ptz/autotracking cameras, this will always return False
|
# for non ptz/autotracking cameras, this will always return False
|
||||||
@@ -186,7 +180,7 @@ class PtzMotionEstimator:
|
|||||||
return self.coord_transformations
|
return self.coord_transformations
|
||||||
|
|
||||||
|
|
||||||
class PtzAutoTracker(threading.Thread):
|
class PtzAutoTrackerThread(threading.Thread):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
@@ -196,14 +190,56 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(name="ptz_autotracker")
|
super().__init__(name="ptz_autotracker")
|
||||||
|
self.ptz_autotracker = PtzAutoTracker(
|
||||||
|
config, onvif, ptz_metrics, dispatcher, stop_event
|
||||||
|
)
|
||||||
|
self.stop_event = stop_event
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while not self.stop_event.wait(1):
|
||||||
|
self.ptz_autotracker.check_for_updates()
|
||||||
|
|
||||||
|
for camera, camera_config in list(self.config.cameras.items()):
|
||||||
|
if not camera_config.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if camera_config.onvif.autotracking.enabled:
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
self.ptz_autotracker.camera_maintenance(camera),
|
||||||
|
self.ptz_autotracker.onvif.loop,
|
||||||
|
)
|
||||||
|
# Wait for the coroutine to complete
|
||||||
|
future.result()
|
||||||
|
else:
|
||||||
|
# disabled dynamically by mqtt
|
||||||
|
if self.ptz_autotracker.tracked_object.get(camera):
|
||||||
|
self.ptz_autotracker.tracked_object[camera] = None
|
||||||
|
self.ptz_autotracker.tracked_object_history[camera].clear()
|
||||||
|
|
||||||
|
self.ptz_autotracker.config_subscriber.stop()
|
||||||
|
logger.info("Exiting autotracker...")
|
||||||
|
|
||||||
|
|
||||||
|
class PtzAutoTracker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
onvif: OnvifController,
|
||||||
|
ptz_metrics: PTZMetrics,
|
||||||
|
dispatcher: Dispatcher,
|
||||||
|
stop_event: MpEvent,
|
||||||
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.onvif = onvif
|
self.onvif = onvif
|
||||||
self.ptz_metrics = ptz_metrics
|
self.ptz_metrics = ptz_metrics
|
||||||
self.dispatcher = dispatcher
|
self.dispatcher = dispatcher
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.tracked_object: dict[str, TrackedObject | None] = {}
|
self.tracked_object: dict[str, object] = {}
|
||||||
self.tracked_object_history: dict[str, object] = {}
|
self.tracked_object_history: dict[str, object] = {}
|
||||||
self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
|
self.tracked_object_metrics: dict[str, object] = {}
|
||||||
|
self.object_types: dict[str, object] = {}
|
||||||
|
self.required_zones: dict[str, object] = {}
|
||||||
self.move_queues: dict[str, object] = {}
|
self.move_queues: dict[str, object] = {}
|
||||||
self.move_queue_locks: dict[str, object] = {}
|
self.move_queue_locks: dict[str, object] = {}
|
||||||
self.move_threads: dict[str, object] = {}
|
self.move_threads: dict[str, object] = {}
|
||||||
@@ -213,6 +249,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self.intercept: dict[str, object] = {}
|
self.intercept: dict[str, object] = {}
|
||||||
self.move_coefficients: dict[str, object] = {}
|
self.move_coefficients: dict[str, object] = {}
|
||||||
self.zoom_time: dict[str, float] = {}
|
self.zoom_time: dict[str, float] = {}
|
||||||
|
self.zoom_factor: dict[str, object] = {}
|
||||||
|
|
||||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
self.config,
|
self.config,
|
||||||
@@ -240,37 +277,44 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# Wait for the coroutine to complete
|
# Wait for the coroutine to complete
|
||||||
future.result()
|
future.result()
|
||||||
|
|
||||||
def run(self) -> None:
|
def check_for_updates(self) -> None:
|
||||||
while not self.stop_event.wait(1):
|
"""Apply camera config updates and mirror autotracking state to ptz metrics.
|
||||||
self.config_subscriber.check_for_updates()
|
|
||||||
|
|
||||||
for camera, camera_config in list(self.config.cameras.items()):
|
The camera processes read autotracker_enabled rather than the config, so it
|
||||||
if not camera_config.enabled:
|
has to follow every path that can change autotracking, not just the mqtt
|
||||||
|
toggle that writes it directly.
|
||||||
|
"""
|
||||||
|
updates = self.config_subscriber.check_for_updates()
|
||||||
|
|
||||||
|
for cameras in updates.values():
|
||||||
|
for camera in cameras:
|
||||||
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
metrics = self.ptz_metrics.get(camera)
|
||||||
|
|
||||||
|
# a camera added at runtime gets its metrics from the maintainer on
|
||||||
|
# another thread, which seeds them from this same config value
|
||||||
|
if camera_config is None or metrics is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if camera_config.onvif.autotracking.enabled:
|
metrics.autotracker_enabled.value = (
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
camera_config.onvif.autotracking.enabled
|
||||||
self.camera_maintenance(camera), self.onvif.loop
|
)
|
||||||
)
|
|
||||||
# Wait for the coroutine to complete
|
|
||||||
future.result()
|
|
||||||
else:
|
|
||||||
# disabled dynamically by mqtt
|
|
||||||
if self.tracked_object.get(camera):
|
|
||||||
self.tracked_object[camera] = None
|
|
||||||
self.tracked_object_history[camera].clear()
|
|
||||||
|
|
||||||
self.config_subscriber.stop()
|
|
||||||
logger.info("Exiting autotracker...")
|
|
||||||
|
|
||||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||||
logger.debug(f"{camera}: Autotracker init")
|
logger.debug(f"{camera}: Autotracker init")
|
||||||
|
|
||||||
|
self.object_types[camera] = camera_config.onvif.autotracking.track
|
||||||
|
self.required_zones[camera] = camera_config.onvif.autotracking.required_zones
|
||||||
|
self.zoom_factor[camera] = camera_config.onvif.autotracking.zoom_factor
|
||||||
|
|
||||||
self.tracked_object[camera] = None
|
self.tracked_object[camera] = None
|
||||||
self.tracked_object_history[camera] = deque(
|
self.tracked_object_history[camera] = deque(
|
||||||
maxlen=round(camera_config.detect.fps * 1.5)
|
maxlen=round(camera_config.detect.fps * 1.5)
|
||||||
)
|
)
|
||||||
self._reset_tracked_object_metrics(camera)
|
self.tracked_object_metrics[camera] = {
|
||||||
|
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||||
|
** (1 / self.zoom_factor[camera])
|
||||||
|
}
|
||||||
|
|
||||||
self.calibrating[camera] = False
|
self.calibrating[camera] = False
|
||||||
self.move_metrics[camera] = []
|
self.move_metrics[camera] = []
|
||||||
@@ -282,22 +326,43 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
# handle onvif constructor failing due to no connection
|
# handle onvif constructor failing due to no connection
|
||||||
if camera not in self.onvif.cams:
|
if camera not in self.onvif.cams:
|
||||||
self._disable(camera, "onvif connection failed")
|
logger.warning(
|
||||||
|
f"Disabling autotracking for {camera}: onvif connection failed"
|
||||||
|
)
|
||||||
|
camera_config.onvif.autotracking.enabled = False
|
||||||
|
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.onvif.cams[camera]["init"]:
|
if not self.onvif.cams[camera]["init"]:
|
||||||
if not await self.onvif._init_onvif(camera):
|
if not await self.onvif._init_onvif(camera):
|
||||||
self._disable(camera, "Unable to initialize onvif")
|
logger.warning(
|
||||||
|
f"Disabling autotracking for {camera}: Unable to initialize onvif"
|
||||||
|
)
|
||||||
|
camera_config.onvif.autotracking.enabled = False
|
||||||
|
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||||
return
|
return
|
||||||
|
|
||||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||||
self._disable(camera, "FOV relative movement not supported")
|
logger.warning(
|
||||||
|
f"Disabling autotracking for {camera}: FOV relative movement not supported"
|
||||||
|
)
|
||||||
|
camera_config.onvif.autotracking.enabled = False
|
||||||
|
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||||
return
|
return
|
||||||
|
|
||||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||||
|
|
||||||
if str(move_status_supported).lower() != "true":
|
if not (
|
||||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
isinstance(move_status_supported, bool) and move_status_supported
|
||||||
|
) and not (
|
||||||
|
isinstance(move_status_supported, str)
|
||||||
|
and move_status_supported.lower() == "true"
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling autotracking for {camera}: ONVIF MoveStatus not supported"
|
||||||
|
)
|
||||||
|
camera_config.onvif.autotracking.enabled = False
|
||||||
|
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.onvif.cams[camera]["init"]:
|
if self.onvif.cams[camera]["init"]:
|
||||||
@@ -309,41 +374,59 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if camera_config.onvif.autotracking.movement_weights:
|
if camera_config.onvif.autotracking.movement_weights:
|
||||||
(
|
if len(camera_config.onvif.autotracking.movement_weights) == 6:
|
||||||
self.ptz_metrics[camera].min_zoom.value,
|
camera_config.onvif.autotracking.movement_weights = [
|
||||||
self.ptz_metrics[camera].max_zoom.value,
|
float(val)
|
||||||
self.intercept[camera],
|
for val in camera_config.onvif.autotracking.movement_weights
|
||||||
*self.move_coefficients[camera],
|
]
|
||||||
self.zoom_time[camera],
|
self.ptz_metrics[
|
||||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
camera
|
||||||
|
].min_zoom.value = (
|
||||||
|
camera_config.onvif.autotracking.movement_weights[0]
|
||||||
|
)
|
||||||
|
self.ptz_metrics[
|
||||||
|
camera
|
||||||
|
].max_zoom.value = (
|
||||||
|
camera_config.onvif.autotracking.movement_weights[1]
|
||||||
|
)
|
||||||
|
self.intercept[camera] = (
|
||||||
|
camera_config.onvif.autotracking.movement_weights[2]
|
||||||
|
)
|
||||||
|
self.move_coefficients[camera] = (
|
||||||
|
camera_config.onvif.autotracking.movement_weights[3:5]
|
||||||
|
)
|
||||||
|
self.zoom_time[camera] = (
|
||||||
|
camera_config.onvif.autotracking.movement_weights[5]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
camera_config.onvif.autotracking.enabled = False
|
||||||
|
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||||
|
logger.warning(
|
||||||
|
f"Autotracker recalibration is required for {camera}. Disabling autotracking."
|
||||||
|
)
|
||||||
|
|
||||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||||
await self._calibrate_camera(camera)
|
await self._calibrate_camera(camera)
|
||||||
|
|
||||||
|
self.ptz_metrics[camera].tracking_active.clear()
|
||||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||||
self.autotracker_init[camera] = True
|
self.autotracker_init[camera] = True
|
||||||
|
|
||||||
def _disable(self, camera: str, reason: str) -> None:
|
def _write_config(self, camera):
|
||||||
logger.warning(f"Disabling autotracking for {camera}: {reason}")
|
config_file = find_config_file()
|
||||||
autotracking_config = self.config.cameras[camera].onvif.autotracking
|
|
||||||
autotracking_config.enabled = False
|
|
||||||
|
|
||||||
# the camera process holds its own copy of the config
|
logger.debug(
|
||||||
self.dispatcher.config_updater.publish_update(
|
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
|
||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
|
||||||
autotracking_config,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
update_yaml_file_bulk(
|
||||||
self.tracked_object_metrics[camera] = {}
|
config_file,
|
||||||
|
{
|
||||||
async def _wait_until_stopped(
|
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
||||||
self, camera: str, metrics: PTZMetrics | None = None
|
camera
|
||||||
) -> None:
|
].onvif.autotracking.movement_weights
|
||||||
metrics = metrics or self.ptz_metrics[camera]
|
},
|
||||||
|
)
|
||||||
while not metrics.motor_stopped.is_set():
|
|
||||||
await self.onvif.get_camera_status(camera)
|
|
||||||
|
|
||||||
async def _calibrate_camera(self, camera):
|
async def _calibrate_camera(self, camera):
|
||||||
# move the camera from the preset in steps and measure the time it takes to move that amount
|
# move the camera from the preset in steps and measure the time it takes to move that amount
|
||||||
@@ -376,7 +459,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||||
|
|
||||||
@@ -386,7 +470,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
|
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||||
|
|
||||||
@@ -403,7 +488,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||||
|
|
||||||
@@ -417,7 +503,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
zoom_stop_time = time.time()
|
zoom_stop_time = time.time()
|
||||||
|
|
||||||
@@ -431,7 +518,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
full_relative_stop_time = time.time()
|
full_relative_stop_time = time.time()
|
||||||
|
|
||||||
@@ -443,7 +531,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
self.zoom_time[camera] = (
|
self.zoom_time[camera] = (
|
||||||
full_relative_stop_time - full_relative_start_time
|
full_relative_stop_time - full_relative_start_time
|
||||||
@@ -469,7 +558,9 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self.ptz_metrics[camera].reset.set()
|
self.ptz_metrics[camera].reset.set()
|
||||||
self.ptz_metrics[camera].motor_stopped.clear()
|
self.ptz_metrics[camera].motor_stopped.clear()
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
# Wait until the camera finishes moving
|
||||||
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
for step in range(num_steps):
|
for step in range(num_steps):
|
||||||
pan = step_sizes[step]
|
pan = step_sizes[step]
|
||||||
@@ -478,7 +569,9 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
# Wait until the camera finishes moving
|
||||||
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
stop_time = time.time()
|
stop_time = time.time()
|
||||||
|
|
||||||
self.move_metrics[camera].append(
|
self.move_metrics[camera].append(
|
||||||
@@ -497,7 +590,9 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self.ptz_metrics[camera].reset.set()
|
self.ptz_metrics[camera].reset.set()
|
||||||
self.ptz_metrics[camera].motor_stopped.clear()
|
self.ptz_metrics[camera].motor_stopped.clear()
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
# Wait until the camera finishes moving
|
||||||
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
|
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
|
||||||
@@ -573,14 +668,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
|
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
update_yaml_file_bulk(
|
self._write_config(camera)
|
||||||
find_config_file(),
|
|
||||||
{
|
|
||||||
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
|
||||||
camera
|
|
||||||
].onvif.autotracking.movement_weights
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _predict_movement_time(self, camera, pan, tilt):
|
def _predict_movement_time(self, camera, pan, tilt):
|
||||||
combined_movement = abs(pan) + abs(tilt)
|
combined_movement = abs(pan) + abs(tilt)
|
||||||
@@ -594,18 +682,6 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
[self.tracked_object_history[camera][-1]["frame_time"] + time],
|
[self.tracked_object_history[camera][-1]["frame_time"] + time],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _predict_target_box(self, camera, predicted_time):
|
|
||||||
target_box = self.tracked_object_metrics[camera]["target_box"]
|
|
||||||
|
|
||||||
if not predicted_time:
|
|
||||||
return target_box
|
|
||||||
|
|
||||||
frame_shape = self.config.cameras[camera].frame_shape
|
|
||||||
|
|
||||||
return target_box + self._predict_area_after_time(camera, predicted_time) / (
|
|
||||||
frame_shape[0] * frame_shape[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
def _calculate_tracked_object_metrics(self, camera, obj):
|
def _calculate_tracked_object_metrics(self, camera, obj):
|
||||||
def remove_outliers(data):
|
def remove_outliers(data):
|
||||||
areas = [item["area"] for item in data]
|
areas = [item["area"] for item in data]
|
||||||
@@ -627,20 +703,19 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
return filtered_data
|
return filtered_data
|
||||||
|
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
tom = self.tracked_object_metrics[camera]
|
|
||||||
zoom_factor = camera_config.onvif.autotracking.zoom_factor
|
|
||||||
camera_width = camera_config.frame_shape[1]
|
camera_width = camera_config.frame_shape[1]
|
||||||
camera_height = camera_config.frame_shape[0]
|
camera_height = camera_config.frame_shape[0]
|
||||||
|
|
||||||
# Extract areas and calculate weighted average
|
# Extract areas and calculate weighted average
|
||||||
# grab the largest dimension of the bounding box and create a square from that
|
# grab the largest dimension of the bounding box and create a square from that
|
||||||
# Use a recent time window
|
# Filter out the initial frame and use a recent time window
|
||||||
current_time = obj.obj_data["frame_time"]
|
current_time = obj.obj_data["frame_time"]
|
||||||
time_window = 1.5 # seconds
|
time_window = 1.5 # seconds
|
||||||
history = [
|
history = [
|
||||||
entry
|
entry
|
||||||
for entry in self.tracked_object_history[camera]
|
for entry in self.tracked_object_history[camera]
|
||||||
if current_time - entry["frame_time"] <= time_window
|
if not entry.get("is_initial_frame", False)
|
||||||
|
and current_time - entry["frame_time"] <= time_window
|
||||||
]
|
]
|
||||||
if not history: # Fallback to latest if no recent entries
|
if not history: # Fallback to latest if no recent entries
|
||||||
history = [self.tracked_object_history[camera][-1]]
|
history = [self.tracked_object_history[camera][-1]]
|
||||||
@@ -673,29 +748,33 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
)
|
)
|
||||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
||||||
|
|
||||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq(
|
||||||
0
|
X.reshape(-1, 1), y, rcond=None
|
||||||
]
|
)[0]
|
||||||
else:
|
else:
|
||||||
tom["area_coefficients"] = np.array([0])
|
self.tracked_object_metrics[camera]["area_coefficients"] = np.array([0])
|
||||||
|
|
||||||
weights = np.arange(1, len(filtered_areas) + 1)
|
weights = np.arange(1, len(filtered_areas) + 1)
|
||||||
weighted_area = np.average(
|
weighted_area = np.average(
|
||||||
[item["area"] for item in filtered_areas], weights=weights
|
[item["area"] for item in filtered_areas], weights=weights
|
||||||
)
|
)
|
||||||
|
|
||||||
tom["target_box"] = (
|
self.tracked_object_metrics[camera]["target_box"] = (
|
||||||
weighted_area / (camera_width * camera_height)
|
weighted_area / (camera_width * camera_height)
|
||||||
) ** zoom_factor
|
) ** self.zoom_factor[camera]
|
||||||
|
|
||||||
if "original_target_box" not in tom:
|
if "original_target_box" not in self.tracked_object_metrics[camera]:
|
||||||
tom["original_target_box"] = tom["target_box"]
|
self.tracked_object_metrics[camera]["original_target_box"] = (
|
||||||
|
self.tracked_object_metrics[camera]["target_box"]
|
||||||
|
)
|
||||||
|
|
||||||
(
|
(
|
||||||
tom["valid_velocity"],
|
self.tracked_object_metrics[camera]["valid_velocity"],
|
||||||
tom["velocity"],
|
self.tracked_object_metrics[camera]["velocity"],
|
||||||
) = self._get_valid_velocity(camera, obj)
|
) = self._get_valid_velocity(camera, obj)
|
||||||
tom["distance"] = self._get_distance_threshold(camera, obj)
|
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold(
|
||||||
|
camera, obj
|
||||||
|
)
|
||||||
|
|
||||||
centroid_distance = np.linalg.norm(
|
centroid_distance = np.linalg.norm(
|
||||||
[
|
[
|
||||||
@@ -706,7 +785,9 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
|
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
|
||||||
|
|
||||||
tom["below_distance_threshold"] = centroid_distance < tom["distance"]
|
self.tracked_object_metrics[camera]["below_distance_threshold"] = (
|
||||||
|
centroid_distance < self.tracked_object_metrics[camera]["distance"]
|
||||||
|
)
|
||||||
|
|
||||||
async def _process_move_queue(self, camera):
|
async def _process_move_queue(self, camera):
|
||||||
move_queue = self.move_queues[camera]
|
move_queue = self.move_queues[camera]
|
||||||
@@ -752,12 +833,16 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
if pan != 0 or tilt != 0:
|
if pan != 0 or tilt != 0:
|
||||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera, metrics)
|
# Wait until the camera finishes moving
|
||||||
|
while not metrics.motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera, metrics)
|
# Wait until the camera finishes moving
|
||||||
|
while not metrics.motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
if camera_config.onvif.autotracking.movement_weights:
|
if camera_config.onvif.autotracking.movement_weights:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -793,20 +878,41 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
await move_queue.get()
|
await move_queue.get()
|
||||||
|
|
||||||
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
|
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
|
||||||
pan, tilt, zoom = (np.clip(value, -1, 1) for value in (pan, tilt, zoom))
|
def split_value(value, suppress_diff=True):
|
||||||
|
clipped = np.clip(value, -1, 1)
|
||||||
|
|
||||||
|
# don't make small movements
|
||||||
|
if -0.05 < clipped < 0.05 and suppress_diff:
|
||||||
|
diff = 0.0
|
||||||
|
else:
|
||||||
|
diff = value - clipped
|
||||||
|
|
||||||
|
return clipped, diff
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(pan != 0 or tilt != 0 or zoom != 0)
|
frame_time > self.ptz_metrics[camera].start_time.value
|
||||||
and frame_time > self.ptz_metrics[camera].start_time.value
|
|
||||||
and frame_time > self.ptz_metrics[camera].stop_time.value
|
and frame_time > self.ptz_metrics[camera].stop_time.value
|
||||||
and not self.move_queue_locks[camera].locked()
|
and not self.move_queue_locks[camera].locked()
|
||||||
):
|
):
|
||||||
logger.debug(
|
# we can split up any large moves caused by velocity estimated movements if necessary
|
||||||
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
|
# get an excess amount and assign it instead of 0 below
|
||||||
)
|
while pan != 0 or tilt != 0 or zoom != 0:
|
||||||
self.onvif.loop.call_soon_threadsafe(
|
pan, _ = split_value(pan)
|
||||||
self.move_queues[camera].put_nowait, (frame_time, pan, tilt, zoom)
|
tilt, _ = split_value(tilt)
|
||||||
)
|
zoom, _ = split_value(zoom, False)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
|
||||||
|
)
|
||||||
|
move_data = (frame_time, pan, tilt, zoom)
|
||||||
|
self.onvif.loop.call_soon_threadsafe(
|
||||||
|
self.move_queues[camera].put_nowait, move_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# reset values to not split up large movements
|
||||||
|
pan = 0
|
||||||
|
tilt = 0
|
||||||
|
zoom = 0
|
||||||
|
|
||||||
def _touching_frame_edges(self, camera, box):
|
def _touching_frame_edges(self, camera, box):
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
@@ -940,17 +1046,16 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
return distance_threshold
|
return distance_threshold
|
||||||
|
|
||||||
def _should_zoom_in(self, camera: str, box, predicted_time):
|
def _should_zoom_in(
|
||||||
|
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
|
||||||
|
):
|
||||||
# returns True if we should zoom in, False if we should zoom out, None to do nothing
|
# returns True if we should zoom in, False if we should zoom out, None to do nothing
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
tom = self.tracked_object_metrics[camera]
|
|
||||||
zoom_factor = camera_config.onvif.autotracking.zoom_factor
|
|
||||||
max_target_box = calculate_max_target_box(zoom_factor)
|
|
||||||
camera_width = camera_config.frame_shape[1]
|
camera_width = camera_config.frame_shape[1]
|
||||||
camera_height = camera_config.frame_shape[0]
|
camera_height = camera_config.frame_shape[0]
|
||||||
camera_fps = camera_config.detect.fps
|
camera_fps = camera_config.detect.fps
|
||||||
|
|
||||||
average_velocity = tom["velocity"]
|
average_velocity = self.tracked_object_metrics[camera]["velocity"]
|
||||||
|
|
||||||
bb_left, bb_top, bb_right, bb_bottom = box
|
bb_left, bb_top, bb_right, bb_bottom = box
|
||||||
|
|
||||||
@@ -968,11 +1073,13 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
touching_frame_edges = self._touching_frame_edges(camera, box)
|
touching_frame_edges = self._touching_frame_edges(camera, box)
|
||||||
|
|
||||||
# make sure object is centered in the frame
|
# make sure object is centered in the frame
|
||||||
below_distance_threshold = tom["below_distance_threshold"]
|
below_distance_threshold = self.tracked_object_metrics[camera][
|
||||||
|
"below_distance_threshold"
|
||||||
|
]
|
||||||
|
|
||||||
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
|
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
|
||||||
zoom_factor + 0.1
|
self.zoom_factor[camera] + 0.1
|
||||||
) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
|
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1)
|
||||||
|
|
||||||
# ensure object is not moving quickly
|
# ensure object is not moving quickly
|
||||||
below_velocity_threshold = np.all(
|
below_velocity_threshold = np.all(
|
||||||
@@ -980,16 +1087,30 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
|
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
|
||||||
) or np.all(average_velocity == 0)
|
) or np.all(average_velocity == 0)
|
||||||
|
|
||||||
calculated_target_box = self._predict_target_box(camera, predicted_time)
|
if not predicted_time:
|
||||||
|
calculated_target_box = self.tracked_object_metrics[camera]["target_box"]
|
||||||
|
else:
|
||||||
|
calculated_target_box = self.tracked_object_metrics[camera][
|
||||||
|
"target_box"
|
||||||
|
] + self._predict_area_after_time(camera, predicted_time) / (
|
||||||
|
camera_width * camera_height
|
||||||
|
)
|
||||||
|
|
||||||
below_area_threshold = calculated_target_box < max_target_box
|
below_area_threshold = (
|
||||||
|
calculated_target_box
|
||||||
|
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||||
|
)
|
||||||
|
|
||||||
# introduce some hysteresis to prevent a yo-yo zooming effect
|
# introduce some hysteresis to prevent a yo-yo zooming effect
|
||||||
zoom_out_hysteresis = (
|
zoom_out_hysteresis = (
|
||||||
calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
calculated_target_box
|
||||||
|
> self.tracked_object_metrics[camera]["max_target_box"]
|
||||||
|
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||||
)
|
)
|
||||||
zoom_in_hysteresis = (
|
zoom_in_hysteresis = (
|
||||||
calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
calculated_target_box
|
||||||
|
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||||
|
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||||
)
|
)
|
||||||
|
|
||||||
at_max_zoom = (
|
at_max_zoom = (
|
||||||
@@ -1001,29 +1122,31 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
== self.ptz_metrics[camera].min_zoom.value
|
== self.ptz_metrics[camera].min_zoom.value
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
# debug zooming
|
||||||
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
|
if debug_zooming:
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(
|
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
|
||||||
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
|
)
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(
|
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
|
||||||
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {tom['target_box']}, calculated: {calculated_target_box}, max: {max_target_box}"
|
)
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(
|
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {self.tracked_object_metrics[camera]['target_box']}, calculated: {calculated_target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}"
|
||||||
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (zoom_factor + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (zoom_factor + 0.1)}"
|
)
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(
|
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}"
|
||||||
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
|
)
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
|
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
|
||||||
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
|
)
|
||||||
logger.debug(
|
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
|
||||||
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
|
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(
|
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
|
||||||
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
|
)
|
||||||
)
|
logger.debug(
|
||||||
|
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
|
||||||
|
)
|
||||||
|
|
||||||
# Zoom in conditions (and)
|
# Zoom in conditions (and)
|
||||||
if (
|
if (
|
||||||
@@ -1114,7 +1237,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
zoom = self._get_zoom_amount(
|
zoom = self._get_zoom_amount(
|
||||||
camera, obj, predicted_box, predicted_movement_time
|
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1175,11 +1298,9 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
obj: TrackedObject,
|
obj: TrackedObject,
|
||||||
predicted_box,
|
predicted_box,
|
||||||
predicted_movement_time,
|
predicted_movement_time,
|
||||||
|
debug_zoom=True,
|
||||||
):
|
):
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
tom = self.tracked_object_metrics[camera]
|
|
||||||
zoom_factor = camera_config.onvif.autotracking.zoom_factor
|
|
||||||
max_target_box = calculate_max_target_box(zoom_factor)
|
|
||||||
|
|
||||||
# frame width and height
|
# frame width and height
|
||||||
camera_width = camera_config.frame_shape[1]
|
camera_width = camera_config.frame_shape[1]
|
||||||
@@ -1196,12 +1317,16 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# absolute zooming separately from pan/tilt
|
# absolute zooming separately from pan/tilt
|
||||||
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
|
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
|
||||||
# don't zoom on initial move
|
# don't zoom on initial move
|
||||||
if "target_box" not in tom:
|
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||||
zoom = current_zoom_level
|
zoom = current_zoom_level
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
result := self._should_zoom_in(
|
result := self._should_zoom_in(
|
||||||
camera, obj.obj_data["box"], predicted_movement_time
|
camera,
|
||||||
|
obj,
|
||||||
|
obj.obj_data["box"],
|
||||||
|
predicted_movement_time,
|
||||||
|
debug_zoom,
|
||||||
)
|
)
|
||||||
) is not None:
|
) is not None:
|
||||||
# divide zoom in 10 increments and always zoom out more than in
|
# divide zoom in 10 increments and always zoom out more than in
|
||||||
@@ -1217,35 +1342,46 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# relative zooming concurrently with pan/tilt
|
# relative zooming concurrently with pan/tilt
|
||||||
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
|
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
|
||||||
# this is our initial zoom in on a new object
|
# this is our initial zoom in on a new object
|
||||||
if "target_box" not in tom:
|
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||||
zoom = target_box**zoom_factor
|
zoom = target_box ** self.zoom_factor[camera]
|
||||||
if zoom > max_target_box:
|
if zoom > self.tracked_object_metrics[camera]["max_target_box"]:
|
||||||
zoom = -(1 - zoom)
|
zoom = -(1 - zoom)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: target box: {target_box}, max: {max_target_box}, calc zoom: {zoom}"
|
f"{camera}: target box: {target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}, calc zoom: {zoom}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
result := self._should_zoom_in(
|
result := self._should_zoom_in(
|
||||||
camera,
|
camera,
|
||||||
|
obj,
|
||||||
predicted_box
|
predicted_box
|
||||||
if camera_config.onvif.autotracking.movement_weights
|
if camera_config.onvif.autotracking.movement_weights
|
||||||
else obj.obj_data["box"],
|
else obj.obj_data["box"],
|
||||||
predicted_movement_time,
|
predicted_movement_time,
|
||||||
|
debug_zoom,
|
||||||
)
|
)
|
||||||
) is not None:
|
) is not None:
|
||||||
calculated_target_box = self._predict_target_box(
|
|
||||||
camera, predicted_movement_time
|
|
||||||
)
|
|
||||||
if predicted_movement_time:
|
if predicted_movement_time:
|
||||||
|
calculated_target_box = self.tracked_object_metrics[camera][
|
||||||
|
"target_box"
|
||||||
|
] + self._predict_area_after_time(
|
||||||
|
camera, predicted_movement_time
|
||||||
|
) / (camera_width * camera_height)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {tom['target_box']}, calculated box: {calculated_target_box}"
|
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {self.tracked_object_metrics[camera]['target_box']}, calculated box: {calculated_target_box}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
calculated_target_box = self.tracked_object_metrics[camera][
|
||||||
|
"target_box"
|
||||||
|
]
|
||||||
# zoom value
|
# zoom value
|
||||||
ratio = max_target_box / calculated_target_box
|
ratio = (
|
||||||
|
self.tracked_object_metrics[camera]["max_target_box"]
|
||||||
|
/ calculated_target_box
|
||||||
|
)
|
||||||
zoom = (ratio - 1) / (ratio + 1)
|
zoom = (ratio - 1) / (ratio + 1)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: limit: {max_target_box}, ratio: {ratio} zoom calculation: {zoom}"
|
f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}"
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
# zoom out with special condition if zooming out because of velocity, edges, etc.
|
# zoom out with special condition if zooming out because of velocity, edges, etc.
|
||||||
@@ -1258,6 +1394,9 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
return zoom
|
return zoom
|
||||||
|
|
||||||
|
def is_autotracking(self, camera: str):
|
||||||
|
return self.tracked_object[camera] is not None
|
||||||
|
|
||||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||||
if camera not in self.config.cameras:
|
if camera not in self.config.cameras:
|
||||||
return
|
return
|
||||||
@@ -1281,9 +1420,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# new object
|
# new object
|
||||||
self.tracked_object[camera] is None
|
self.tracked_object[camera] is None
|
||||||
and obj.camera_config.name == camera
|
and obj.camera_config.name == camera
|
||||||
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
|
and obj.obj_data["label"] in self.object_types[camera]
|
||||||
and set(obj.entered_zones)
|
and set(obj.entered_zones) & set(self.required_zones[camera])
|
||||||
& set(camera_config.onvif.autotracking.required_zones)
|
|
||||||
and not obj.previous["false_positive"]
|
and not obj.previous["false_positive"]
|
||||||
and not obj.false_positive
|
and not obj.false_positive
|
||||||
and not self.tracked_object_history[camera]
|
and not self.tracked_object_history[camera]
|
||||||
@@ -1292,6 +1430,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
|
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
|
||||||
)
|
)
|
||||||
|
self.ptz_metrics[camera].tracking_active.set()
|
||||||
self.dispatcher.publish(
|
self.dispatcher.publish(
|
||||||
f"{camera}/ptz_autotracker/active", "ON", retain=False
|
f"{camera}/ptz_autotracker/active", "ON", retain=False
|
||||||
)
|
)
|
||||||
@@ -1340,7 +1479,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# Should we check region (maybe too broad) or expand the previous object's box a bit and check that?
|
# Should we check region (maybe too broad) or expand the previous object's box a bit and check that?
|
||||||
self.tracked_object[camera] is None
|
self.tracked_object[camera] is None
|
||||||
and obj.camera_config.name == camera
|
and obj.camera_config.name == camera
|
||||||
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
|
and obj.obj_data["label"] in self.object_types[camera]
|
||||||
and not obj.previous["false_positive"]
|
and not obj.previous["false_positive"]
|
||||||
and not obj.false_positive
|
and not obj.false_positive
|
||||||
and self.tracked_object_history[camera]
|
and self.tracked_object_history[camera]
|
||||||
@@ -1376,7 +1515,10 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
|
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
|
||||||
)
|
)
|
||||||
self.tracked_object[camera] = None
|
self.tracked_object[camera] = None
|
||||||
self._reset_tracked_object_metrics(camera)
|
self.tracked_object_metrics[camera] = {
|
||||||
|
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||||
|
** (1 / self.zoom_factor[camera])
|
||||||
|
}
|
||||||
|
|
||||||
async def camera_maintenance(self, camera):
|
async def camera_maintenance(self, camera):
|
||||||
# bail and don't check anything if we're not set up yet, calibrating, or
|
# bail and don't check anything if we're not set up yet, calibrating, or
|
||||||
@@ -1413,7 +1555,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self.tracked_object[camera] = None
|
self.tracked_object[camera] = None
|
||||||
self.tracked_object_history[camera].clear()
|
self.tracked_object_history[camera].clear()
|
||||||
|
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
|
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
|
||||||
)
|
)
|
||||||
@@ -1423,8 +1566,10 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# update stored zoom level from preset
|
# update stored zoom level from preset
|
||||||
await self._wait_until_stopped(camera)
|
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||||
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
|
self.ptz_metrics[camera].tracking_active.clear()
|
||||||
self.dispatcher.publish(
|
self.dispatcher.publish(
|
||||||
f"{camera}/ptz_autotracker/active", "OFF", retain=False
|
f"{camera}/ptz_autotracker/active", "OFF", retain=False
|
||||||
)
|
)
|
||||||
|
|||||||
+304
-205
@@ -10,7 +10,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from onvif import ONVIFCamera, ONVIFService
|
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
||||||
|
from zeep.exceptions import Fault, TransportError
|
||||||
|
|
||||||
from frigate.camera import PTZMetrics
|
from frigate.camera import PTZMetrics
|
||||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||||
@@ -40,14 +41,6 @@ class OnvifCommandEnum(str, Enum):
|
|||||||
focus_out = "focus_out"
|
focus_out = "focus_out"
|
||||||
|
|
||||||
|
|
||||||
PAN_TILT_VELOCITY = {
|
|
||||||
OnvifCommandEnum.move_left: (-0.5, 0),
|
|
||||||
OnvifCommandEnum.move_right: (0.5, 0),
|
|
||||||
OnvifCommandEnum.move_up: (0, 0.5),
|
|
||||||
OnvifCommandEnum.move_down: (0, -0.5),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class OnvifController:
|
class OnvifController:
|
||||||
ptz_metrics: dict[str, PTZMetrics]
|
ptz_metrics: dict[str, PTZMetrics]
|
||||||
|
|
||||||
@@ -68,6 +61,14 @@ class OnvifController:
|
|||||||
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
||||||
self.loop_thread.start()
|
self.loop_thread.start()
|
||||||
|
|
||||||
|
self.camera_configs = {}
|
||||||
|
for cam_name, cam in config.cameras.items():
|
||||||
|
if not cam.enabled:
|
||||||
|
continue
|
||||||
|
if cam.onvif.host:
|
||||||
|
self.camera_configs[cam_name] = cam
|
||||||
|
self.status_locks[cam_name] = asyncio.Lock()
|
||||||
|
|
||||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
self.config,
|
self.config,
|
||||||
self.config.cameras,
|
self.config.cameras,
|
||||||
@@ -91,9 +92,8 @@ class OnvifController:
|
|||||||
|
|
||||||
async def _init_cameras(self) -> None:
|
async def _init_cameras(self) -> None:
|
||||||
"""Initialize all configured cameras."""
|
"""Initialize all configured cameras."""
|
||||||
for cam_name, cam in list(self.config.cameras.items()):
|
for cam_name in self.camera_configs:
|
||||||
if cam.enabled and cam.onvif.host:
|
await self._init_single_camera(cam_name)
|
||||||
await self._init_single_camera(cam_name)
|
|
||||||
|
|
||||||
async def _poll_config_updates(self) -> None:
|
async def _poll_config_updates(self) -> None:
|
||||||
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
||||||
@@ -129,9 +129,13 @@ class OnvifController:
|
|||||||
|
|
||||||
async def _remove_camera(self, cam_name: str) -> None:
|
async def _remove_camera(self, cam_name: str) -> None:
|
||||||
"""Tear down the ONVIF session for a camera removed at runtime."""
|
"""Tear down the ONVIF session for a camera removed at runtime."""
|
||||||
|
if cam_name not in self.cams and cam_name not in self.camera_configs:
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
|
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
|
||||||
await self._close_camera(cam_name)
|
await self._close_camera(cam_name)
|
||||||
self.cams.pop(cam_name, None)
|
self.cams.pop(cam_name, None)
|
||||||
|
self.camera_configs.pop(cam_name, None)
|
||||||
self.failed_cams.pop(cam_name, None)
|
self.failed_cams.pop(cam_name, None)
|
||||||
self.status_locks.pop(cam_name, None)
|
self.status_locks.pop(cam_name, None)
|
||||||
|
|
||||||
@@ -139,14 +143,25 @@ class OnvifController:
|
|||||||
"""Re-initialize a camera after config change."""
|
"""Re-initialize a camera after config change."""
|
||||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||||
|
|
||||||
# close existing session and reset state before re-init
|
# close existing session before re-init
|
||||||
await self._close_camera(cam_name)
|
await self._close_camera(cam_name)
|
||||||
|
|
||||||
|
cam = self.config.cameras.get(cam_name)
|
||||||
|
if not cam or not cam.onvif.host:
|
||||||
|
# ONVIF removed from config, clean up
|
||||||
|
self.cams.pop(cam_name, None)
|
||||||
|
self.camera_configs.pop(cam_name, None)
|
||||||
|
self.failed_cams.pop(cam_name, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# update stored config and reset state
|
||||||
|
self.camera_configs[cam_name] = cam
|
||||||
|
if cam_name not in self.status_locks:
|
||||||
|
self.status_locks[cam_name] = asyncio.Lock()
|
||||||
self.cams.pop(cam_name, None)
|
self.cams.pop(cam_name, None)
|
||||||
self.failed_cams.pop(cam_name, None)
|
self.failed_cams.pop(cam_name, None)
|
||||||
|
|
||||||
cam = self.config.cameras.get(cam_name)
|
await self._init_single_camera(cam_name)
|
||||||
if cam and cam.onvif.host:
|
|
||||||
await self._init_single_camera(cam_name)
|
|
||||||
|
|
||||||
async def _init_single_camera(self, cam_name: str) -> bool:
|
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||||
"""Initialize a single camera by name.
|
"""Initialize a single camera by name.
|
||||||
@@ -157,12 +172,11 @@ class OnvifController:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if initialization succeeded, False otherwise
|
bool: True if initialization succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
cam = self.config.cameras.get(cam_name)
|
if cam_name not in self.camera_configs:
|
||||||
if cam is None:
|
|
||||||
logger.error(f"No configuration found for camera {cam_name}")
|
logger.error(f"No configuration found for camera {cam_name}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.status_locks.setdefault(cam_name, asyncio.Lock())
|
cam = self.camera_configs[cam_name]
|
||||||
try:
|
try:
|
||||||
self.cams[cam_name] = {
|
self.cams[cam_name] = {
|
||||||
"onvif": ONVIFCamera(
|
"onvif": ONVIFCamera(
|
||||||
@@ -181,11 +195,12 @@ class OnvifController:
|
|||||||
"profiles": [],
|
"profiles": [],
|
||||||
}
|
}
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
|
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
|
||||||
# track initial failures
|
# track initial failures
|
||||||
self.failed_cams[cam_name] = {
|
self.failed_cams[cam_name] = {
|
||||||
"retry_attempts": 0,
|
"retry_attempts": 0,
|
||||||
|
"last_error": str(e),
|
||||||
"last_attempt": time.time(),
|
"last_attempt": time.time(),
|
||||||
}
|
}
|
||||||
return False
|
return False
|
||||||
@@ -196,8 +211,7 @@ class OnvifController:
|
|||||||
if camera_config is None:
|
if camera_config is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
cam = self.cams[camera_name]
|
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||||
onvif: ONVIFCamera = cam["onvif"]
|
|
||||||
try:
|
try:
|
||||||
await onvif.update_xaddrs()
|
await onvif.update_xaddrs()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -212,7 +226,7 @@ class OnvifController:
|
|||||||
# this will fire an exception if camera is not a ptz
|
# this will fire an exception if camera is not a ptz
|
||||||
capabilities = onvif.get_definition("ptz")
|
capabilities = onvif.get_definition("ptz")
|
||||||
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
|
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
||||||
)
|
)
|
||||||
@@ -221,7 +235,7 @@ class OnvifController:
|
|||||||
try:
|
try:
|
||||||
profiles = await media.GetProfiles()
|
profiles = await media.GetProfiles()
|
||||||
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
||||||
)
|
)
|
||||||
@@ -240,7 +254,7 @@ class OnvifController:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# store available profiles for API response and log for debugging
|
# store available profiles for API response and log for debugging
|
||||||
cam["profiles"] = [
|
self.cams[camera_name]["profiles"] = [
|
||||||
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
||||||
for p in valid_profiles
|
for p in valid_profiles
|
||||||
]
|
]
|
||||||
@@ -253,18 +267,19 @@ class OnvifController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
configured_profile = camera_config.onvif.profile
|
configured_profile = camera_config.onvif.profile
|
||||||
|
profile = None
|
||||||
|
|
||||||
if configured_profile is not None:
|
if configured_profile is not None:
|
||||||
# match by exact token first, then by name
|
# match by exact token first, then by name
|
||||||
profile = next(
|
for p in valid_profiles:
|
||||||
(
|
if p.token == configured_profile:
|
||||||
p
|
profile = p
|
||||||
for key in ("token", "Name")
|
break
|
||||||
for p in valid_profiles
|
if profile is None:
|
||||||
if getattr(p, key, None) == configured_profile
|
for p in valid_profiles:
|
||||||
),
|
if getattr(p, "Name", None) == configured_profile:
|
||||||
None,
|
profile = p
|
||||||
)
|
break
|
||||||
if profile is None:
|
if profile is None:
|
||||||
available = [
|
available = [
|
||||||
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
||||||
@@ -289,30 +304,39 @@ class OnvifController:
|
|||||||
|
|
||||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||||
|
|
||||||
configs = profile.PTZConfiguration
|
# get the PTZ config for the profile
|
||||||
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
|
try:
|
||||||
|
configs = profile.PTZConfiguration
|
||||||
|
logger.debug(
|
||||||
|
f"Onvif ptz config for media profile in {camera_name}: {configs}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid Onvif PTZ configuration for camera: {camera_name}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||||
cam["ptz"] = ptz
|
self.cams[camera_name]["ptz"] = ptz
|
||||||
|
|
||||||
try:
|
try:
|
||||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||||
imaging = None
|
imaging = None
|
||||||
cam["imaging"] = imaging
|
self.cams[camera_name]["imaging"] = imaging
|
||||||
try:
|
try:
|
||||||
video_sources = await media.GetVideoSources()
|
video_sources = await media.GetVideoSources()
|
||||||
if video_sources and len(video_sources) > 0:
|
if video_sources and len(video_sources) > 0:
|
||||||
cam["video_source_token"] = video_sources[0].token
|
self.cams[camera_name]["video_source_token"] = video_sources[0].token
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
|
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
|
||||||
cam["video_source_token"] = None
|
self.cams[camera_name]["video_source_token"] = None
|
||||||
|
|
||||||
# setup continuous moving request
|
# setup continuous moving request
|
||||||
move_request = ptz.create_type("ContinuousMove")
|
move_request = ptz.create_type("ContinuousMove")
|
||||||
move_request.ProfileToken = profile.token
|
move_request.ProfileToken = profile.token
|
||||||
cam["move_request"] = move_request
|
self.cams[camera_name]["move_request"] = move_request
|
||||||
|
|
||||||
# get PTZ configuration options for feature detection and relative movement
|
# get PTZ configuration options for feature detection and relative movement
|
||||||
ptz_config = None
|
ptz_config = None
|
||||||
@@ -325,7 +349,7 @@ class OnvifController:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
||||||
)
|
)
|
||||||
@@ -351,6 +375,18 @@ class OnvifController:
|
|||||||
autotracking_config.enabled_in_config and autotracking_config.enabled
|
autotracking_config.enabled_in_config and autotracking_config.enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# these are local and cost nothing to build, and autotracking can be enabled
|
||||||
|
# after a camera is initialized, so always create them rather than baking the
|
||||||
|
# current config value into init state
|
||||||
|
status_request = ptz.create_type("GetStatus")
|
||||||
|
status_request.ProfileToken = profile.token
|
||||||
|
self.cams[camera_name]["status_request"] = status_request
|
||||||
|
|
||||||
|
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||||
|
self.cams[camera_name]["service_capabilities_request"] = (
|
||||||
|
service_capabilities_request
|
||||||
|
)
|
||||||
|
|
||||||
# setup relative move request when FOV relative movement is supported
|
# setup relative move request when FOV relative movement is supported
|
||||||
if (
|
if (
|
||||||
fov_space_id is not None
|
fov_space_id is not None
|
||||||
@@ -359,7 +395,9 @@ class OnvifController:
|
|||||||
# one-off GetStatus to seed Translation field
|
# one-off GetStatus to seed Translation field
|
||||||
status = None
|
status = None
|
||||||
try:
|
try:
|
||||||
status = await ptz.GetStatus({"ProfileToken": profile.token})
|
one_off_status_request = ptz.create_type("GetStatus")
|
||||||
|
one_off_status_request.ProfileToken = profile.token
|
||||||
|
status = await ptz.GetStatus(one_off_status_request)
|
||||||
logger.debug(f"Onvif status for {camera_name}: {status}")
|
logger.debug(f"Onvif status for {camera_name}: {status}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
||||||
@@ -386,7 +424,7 @@ class OnvifController:
|
|||||||
# configure zoom on relative move request
|
# configure zoom on relative move request
|
||||||
if (
|
if (
|
||||||
autotracking_enabled
|
autotracking_enabled
|
||||||
and autotracking_config.zooming == ZoomingModeEnum.relative
|
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||||
):
|
):
|
||||||
zoom_space_id = next(
|
zoom_space_id = next(
|
||||||
(
|
(
|
||||||
@@ -425,16 +463,21 @@ class OnvifController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if rel_move_request.Speed is None:
|
if rel_move_request.Speed is None:
|
||||||
rel_move_request.Speed = configs.DefaultPTZSpeed
|
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
||||||
)
|
)
|
||||||
cam["relative_move_request"] = rel_move_request
|
self.cams[camera_name]["relative_move_request"] = rel_move_request
|
||||||
|
|
||||||
|
# setup absolute move request
|
||||||
|
abs_move_request = ptz.create_type("AbsoluteMove")
|
||||||
|
abs_move_request.ProfileToken = profile.token
|
||||||
|
self.cams[camera_name]["absolute_move_request"] = abs_move_request
|
||||||
|
|
||||||
# setup existing presets
|
# setup existing presets
|
||||||
try:
|
try:
|
||||||
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
||||||
presets = []
|
presets = []
|
||||||
|
|
||||||
@@ -447,7 +490,7 @@ class OnvifController:
|
|||||||
preset_name = preset_name.encode("latin-1").decode("utf-8")
|
preset_name = preset_name.encode("latin-1").decode("utf-8")
|
||||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||||
pass
|
pass
|
||||||
cam["presets"][preset_name.lower()] = preset["token"]
|
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
|
||||||
|
|
||||||
# get list of supported features
|
# get list of supported features
|
||||||
supported_features = []
|
supported_features = []
|
||||||
@@ -461,40 +504,64 @@ class OnvifController:
|
|||||||
if configs.DefaultRelativePanTiltTranslationSpace:
|
if configs.DefaultRelativePanTiltTranslationSpace:
|
||||||
supported_features.append("pt-r")
|
supported_features.append("pt-r")
|
||||||
|
|
||||||
spaces = getattr(ptz_config, "Spaces", None)
|
|
||||||
|
|
||||||
if configs.DefaultRelativeZoomTranslationSpace:
|
if configs.DefaultRelativeZoomTranslationSpace:
|
||||||
supported_features.append("zoom-r")
|
supported_features.append("zoom-r")
|
||||||
if getattr(spaces, "RelativeZoomTranslationSpace", None):
|
if ptz_config is not None:
|
||||||
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
|
try:
|
||||||
|
self.cams[camera_name]["relative_zoom_range"] = (
|
||||||
|
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||||
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
if configs.DefaultAbsoluteZoomPositionSpace:
|
if configs.DefaultAbsoluteZoomPositionSpace:
|
||||||
supported_features.append("zoom-a")
|
supported_features.append("zoom-a")
|
||||||
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
|
if ptz_config is not None:
|
||||||
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
|
try:
|
||||||
|
self.cams[camera_name]["absolute_zoom_range"] = (
|
||||||
|
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||||
|
)
|
||||||
|
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||||
|
except Exception as e:
|
||||||
|
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||||
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# autotracking zoom needs the range for its mode, and get_camera_status
|
# disable autotracking zoom if required ranges are unavailable
|
||||||
# reads the absolute range in both modes
|
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||||
zooming = autotracking_config.zooming
|
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||||
if zooming != ZoomingModeEnum.disabled and (
|
if "relative_zoom_range" not in self.cams[camera_name]:
|
||||||
"absolute_zoom_range" not in cam or f"{zooming.value}_zoom_range" not in cam
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
|
||||||
|
)
|
||||||
|
if autotracking_config.zooming == ZoomingModeEnum.absolute:
|
||||||
|
if "absolute_zoom_range" not in self.cams[camera_name]:
|
||||||
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.cams[camera_name]["video_source_token"] is not None
|
||||||
|
and imaging is not None
|
||||||
):
|
):
|
||||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
|
||||||
logger.warning(
|
|
||||||
f"Disabling autotracking zooming for {camera_name}: {zooming.value} zoom range unavailable"
|
|
||||||
)
|
|
||||||
|
|
||||||
if cam["video_source_token"] is not None and imaging is not None:
|
|
||||||
try:
|
try:
|
||||||
imaging_capabilities = await imaging.GetImagingSettings(
|
imaging_capabilities = await imaging.GetImagingSettings(
|
||||||
{"VideoSourceToken": cam["video_source_token"]}
|
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
hasattr(imaging_capabilities, "Focus")
|
hasattr(imaging_capabilities, "Focus")
|
||||||
and imaging_capabilities.Focus
|
and imaging_capabilities.Focus
|
||||||
):
|
):
|
||||||
supported_features.append("focus")
|
supported_features.append("focus")
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
||||||
|
|
||||||
# detect FOV relative movement support
|
# detect FOV relative movement support
|
||||||
@@ -503,18 +570,17 @@ class OnvifController:
|
|||||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||||
):
|
):
|
||||||
supported_features.append("pt-r-fov")
|
supported_features.append("pt-r-fov")
|
||||||
cam["relative_fov_range"] = (
|
self.cams[camera_name]["relative_fov_range"] = (
|
||||||
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
cam["features"] = supported_features
|
self.cams[camera_name]["features"] = supported_features
|
||||||
cam["init"] = True
|
self.cams[camera_name]["init"] = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _stop(self, camera_name: str) -> None:
|
async def _stop(self, camera_name: str) -> None:
|
||||||
cam = self.cams[camera_name]
|
move_request = self.cams[camera_name]["move_request"]
|
||||||
move_request = cam["move_request"]
|
await self.cams[camera_name]["ptz"].Stop(
|
||||||
await cam["ptz"].Stop(
|
|
||||||
{
|
{
|
||||||
"ProfileToken": move_request.ProfileToken,
|
"ProfileToken": move_request.ProfileToken,
|
||||||
"PanTilt": True,
|
"PanTilt": True,
|
||||||
@@ -522,75 +588,88 @@ class OnvifController:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
"focus" in cam["features"]
|
"focus" in self.cams[camera_name]["features"]
|
||||||
and cam["video_source_token"]
|
and self.cams[camera_name]["video_source_token"]
|
||||||
and cam["imaging"] is not None
|
and self.cams[camera_name]["imaging"] is not None
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
stop_request = cam["imaging"].create_type("Stop")
|
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||||
stop_request.VideoSourceToken = cam["video_source_token"]
|
stop_request.VideoSourceToken = self.cams[camera_name][
|
||||||
await cam["imaging"].Stop(stop_request)
|
"video_source_token"
|
||||||
except Exception as e:
|
]
|
||||||
|
await self.cams[camera_name]["imaging"].Stop(stop_request)
|
||||||
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
|
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
|
||||||
cam["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||||
cam = self.cams[camera_name]
|
if self.cams[camera_name]["active"]:
|
||||||
|
|
||||||
if cam["active"]:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{camera_name} is already performing an action, stopping..."
|
f"{camera_name} is already performing an action, stopping..."
|
||||||
)
|
)
|
||||||
await self._stop(camera_name)
|
await self._stop(camera_name)
|
||||||
|
|
||||||
if "pt" not in cam["features"]:
|
if "pt" not in self.cams[camera_name]["features"]:
|
||||||
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
||||||
return
|
return
|
||||||
|
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
move_request = cam["move_request"]
|
move_request = self.cams[camera_name]["move_request"]
|
||||||
|
|
||||||
x, y = PAN_TILT_VELOCITY[command]
|
if command == OnvifCommandEnum.move_left:
|
||||||
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
|
move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}}
|
||||||
|
elif command == OnvifCommandEnum.move_right:
|
||||||
|
move_request.Velocity = {"PanTilt": {"x": 0.5, "y": 0}}
|
||||||
|
elif command == OnvifCommandEnum.move_up:
|
||||||
|
move_request.Velocity = {
|
||||||
|
"PanTilt": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif command == OnvifCommandEnum.move_down:
|
||||||
|
move_request.Velocity = {
|
||||||
|
"PanTilt": {
|
||||||
|
"x": 0,
|
||||||
|
"y": -0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await cam["ptz"].ContinuousMove(move_request)
|
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
|
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
|
||||||
|
|
||||||
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
||||||
cam = self.cams[camera_name]
|
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
||||||
|
|
||||||
if "pt-r-fov" not in cam["features"]:
|
|
||||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||||
return
|
return
|
||||||
|
|
||||||
metrics = self.ptz_metrics.get(camera_name)
|
metrics = self.ptz_metrics.get(camera_name)
|
||||||
camera_config = self.config.cameras.get(camera_name)
|
|
||||||
|
|
||||||
if metrics is None or camera_config is None:
|
if metrics is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if cam["active"]:
|
if self.cams[camera_name]["active"]:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{camera_name} is already performing an action, not moving..."
|
f"{camera_name} is already performing an action, not moving..."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
|
|
||||||
# only track start_time for autotracking
|
# only track start_time for autotracking
|
||||||
if camera_config.onvif.autotracking.enabled:
|
if metrics.autotracker_enabled.value:
|
||||||
metrics.motor_stopped.clear()
|
metrics.motor_stopped.clear()
|
||||||
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
||||||
metrics.start_time.value = metrics.frame_time.value
|
metrics.start_time.value = metrics.frame_time.value
|
||||||
metrics.stop_time.value = 0
|
metrics.stop_time.value = 0
|
||||||
|
|
||||||
move_request = cam["relative_move_request"]
|
move_request = self.cams[camera_name]["relative_move_request"]
|
||||||
|
|
||||||
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
||||||
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
|
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
|
||||||
@@ -598,49 +677,55 @@ class OnvifController:
|
|||||||
pan,
|
pan,
|
||||||
[-1, 1],
|
[-1, 1],
|
||||||
[
|
[
|
||||||
cam["relative_fov_range"]["XRange"]["Min"],
|
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
|
||||||
cam["relative_fov_range"]["XRange"]["Max"],
|
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
tilt = numpy.interp(
|
tilt = numpy.interp(
|
||||||
tilt,
|
tilt,
|
||||||
[-1, 1],
|
[-1, 1],
|
||||||
[
|
[
|
||||||
cam["relative_fov_range"]["YRange"]["Min"],
|
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
|
||||||
cam["relative_fov_range"]["YRange"]["Max"],
|
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
move_speed = {"PanTilt": {"x": speed, "y": speed}}
|
move_request.Speed = {
|
||||||
|
"PanTilt": {
|
||||||
|
"x": speed,
|
||||||
|
"y": speed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
move_request.Translation.PanTilt.x = pan
|
move_request.Translation.PanTilt.x = pan
|
||||||
move_request.Translation.PanTilt.y = tilt
|
move_request.Translation.PanTilt.y = tilt
|
||||||
|
|
||||||
# include zoom if requested and camera supports relative zoom
|
# include zoom if requested and camera supports relative zoom
|
||||||
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
|
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||||
|
move_request.Speed = {
|
||||||
if include_zoom:
|
"PanTilt": {
|
||||||
move_speed["Zoom"] = {"x": speed}
|
"x": speed,
|
||||||
|
"y": speed,
|
||||||
|
},
|
||||||
|
"Zoom": {"x": speed},
|
||||||
|
}
|
||||||
move_request["Translation"]["Zoom"] = {"x": zoom}
|
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||||
|
|
||||||
move_request.Speed = move_speed
|
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||||
|
|
||||||
await cam["ptz"].RelativeMove(move_request)
|
|
||||||
|
|
||||||
# reset after the move request
|
# reset after the move request
|
||||||
move_request.Translation.PanTilt.x = 0
|
move_request.Translation.PanTilt.x = 0
|
||||||
move_request.Translation.PanTilt.y = 0
|
move_request.Translation.PanTilt.y = 0
|
||||||
|
|
||||||
if include_zoom:
|
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||||
del move_request["Translation"]["Zoom"]
|
del move_request["Translation"]["Zoom"]
|
||||||
|
|
||||||
cam["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||||
cam = self.cams[camera_name]
|
|
||||||
preset = preset.lower()
|
preset = preset.lower()
|
||||||
|
|
||||||
if preset not in cam["presets"]:
|
if preset not in self.cams[camera_name]["presets"]:
|
||||||
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -649,48 +734,44 @@ class OnvifController:
|
|||||||
if metrics is None:
|
if metrics is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
metrics.start_time.value = 0
|
metrics.start_time.value = 0
|
||||||
metrics.stop_time.value = 0
|
metrics.stop_time.value = 0
|
||||||
move_request = cam["move_request"]
|
move_request = self.cams[camera_name]["move_request"]
|
||||||
preset_token = cam["presets"][preset]
|
preset_token = self.cams[camera_name]["presets"][preset]
|
||||||
|
|
||||||
await cam["ptz"].GotoPreset(
|
await self.cams[camera_name]["ptz"].GotoPreset(
|
||||||
{
|
{
|
||||||
"ProfileToken": move_request.ProfileToken,
|
"ProfileToken": move_request.ProfileToken,
|
||||||
"PresetToken": preset_token,
|
"PresetToken": preset_token,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
cam["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||||
cam = self.cams[camera_name]
|
if self.cams[camera_name]["active"]:
|
||||||
|
|
||||||
if cam["active"]:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{camera_name} is already performing an action, stopping..."
|
f"{camera_name} is already performing an action, stopping..."
|
||||||
)
|
)
|
||||||
await self._stop(camera_name)
|
await self._stop(camera_name)
|
||||||
|
|
||||||
if "zoom" not in cam["features"]:
|
if "zoom" not in self.cams[camera_name]["features"]:
|
||||||
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
||||||
return
|
return
|
||||||
|
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
move_request = cam["move_request"]
|
move_request = self.cams[camera_name]["move_request"]
|
||||||
|
|
||||||
if command == OnvifCommandEnum.zoom_in:
|
if command == OnvifCommandEnum.zoom_in:
|
||||||
move_request.Velocity = {"Zoom": {"x": 0.5}}
|
move_request.Velocity = {"Zoom": {"x": 0.5}}
|
||||||
elif command == OnvifCommandEnum.zoom_out:
|
elif command == OnvifCommandEnum.zoom_out:
|
||||||
move_request.Velocity = {"Zoom": {"x": -0.5}}
|
move_request.Velocity = {"Zoom": {"x": -0.5}}
|
||||||
|
|
||||||
await cam["ptz"].ContinuousMove(move_request)
|
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||||
|
|
||||||
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||||
cam = self.cams[camera_name]
|
if "zoom-a" not in self.cams[camera_name]["features"]:
|
||||||
|
|
||||||
if "zoom-a" not in cam["features"]:
|
|
||||||
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -701,59 +782,56 @@ class OnvifController:
|
|||||||
|
|
||||||
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
||||||
|
|
||||||
if cam["active"]:
|
if self.cams[camera_name]["active"]:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{camera_name} is already performing an action, not moving..."
|
f"{camera_name} is already performing an action, not moving..."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
metrics.motor_stopped.clear()
|
metrics.motor_stopped.clear()
|
||||||
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
||||||
metrics.start_time.value = metrics.frame_time.value
|
metrics.start_time.value = metrics.frame_time.value
|
||||||
metrics.stop_time.value = 0
|
metrics.stop_time.value = 0
|
||||||
|
move_request = self.cams[camera_name]["absolute_move_request"]
|
||||||
|
|
||||||
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
|
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
|
||||||
zoom = numpy.interp(
|
zoom = numpy.interp(
|
||||||
zoom,
|
zoom,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[
|
[
|
||||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
move_request.Speed = {"Zoom": speed}
|
||||||
|
move_request.Position = {"Zoom": zoom}
|
||||||
|
|
||||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||||
|
|
||||||
await cam["ptz"].AbsoluteMove(
|
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
|
||||||
{
|
|
||||||
"ProfileToken": cam["move_request"].ProfileToken,
|
|
||||||
"Position": {"Zoom": zoom},
|
|
||||||
"Speed": {"Zoom": speed},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
cam["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||||
cam = self.cams[camera_name]
|
if self.cams[camera_name]["active"]:
|
||||||
|
|
||||||
if cam["active"]:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{camera_name} is already performing an action, not moving..."
|
f"{camera_name} is already performing an action, not moving..."
|
||||||
)
|
)
|
||||||
await self._stop(camera_name)
|
await self._stop(camera_name)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
"focus" not in cam["features"]
|
"focus" not in self.cams[camera_name]["features"]
|
||||||
or not cam["video_source_token"]
|
or not self.cams[camera_name]["video_source_token"]
|
||||||
or cam["imaging"] is None
|
or self.cams[camera_name]["imaging"] is None
|
||||||
):
|
):
|
||||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||||
return
|
return
|
||||||
|
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
move_request = cam["imaging"].create_type("Move")
|
move_request = self.cams[camera_name]["imaging"].create_type("Move")
|
||||||
move_request.VideoSourceToken = cam["video_source_token"]
|
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
|
||||||
move_request.Focus = {
|
move_request.Focus = {
|
||||||
"Continuous": {
|
"Continuous": {
|
||||||
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
|
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
|
||||||
@@ -761,10 +839,10 @@ class OnvifController:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await cam["imaging"].Move(move_request)
|
await self.cams[camera_name]["imaging"].Move(move_request)
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
|
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
|
||||||
cam["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
async def handle_command_async(
|
async def handle_command_async(
|
||||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||||
@@ -805,7 +883,7 @@ class OnvifController:
|
|||||||
await self._focus(camera_name, command)
|
await self._focus(camera_name, command)
|
||||||
else:
|
else:
|
||||||
await self._move(camera_name, command)
|
await self._move(camera_name, command)
|
||||||
except Exception as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
logger.error(f"Unable to handle onvif command: {e}")
|
logger.error(f"Unable to handle onvif command: {e}")
|
||||||
|
|
||||||
def handle_command(
|
def handle_command(
|
||||||
@@ -828,15 +906,6 @@ class OnvifController:
|
|||||||
f"Error executing command {command} for camera {camera_name}: {e}"
|
f"Error executing command {command} for camera {camera_name}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _camera_info(self, camera_name: str) -> dict[str, Any]:
|
|
||||||
cam = self.cams[camera_name]
|
|
||||||
return {
|
|
||||||
"name": camera_name,
|
|
||||||
"features": cam["features"],
|
|
||||||
"presets": list(cam["presets"]),
|
|
||||||
"profiles": cam["profiles"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
|
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
|
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
|
||||||
@@ -860,21 +929,60 @@ class OnvifController:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
||||||
return self._camera_info(camera_name)
|
return {
|
||||||
|
"name": camera_name,
|
||||||
|
"features": self.cams[camera_name]["features"],
|
||||||
|
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||||
|
"profiles": self.cams[camera_name].get("profiles", []),
|
||||||
|
}
|
||||||
|
|
||||||
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
||||||
success = await self._init_single_camera(camera_name)
|
success = await self._init_single_camera(camera_name)
|
||||||
if not success:
|
if not success:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
failed = self.failed_cams.get(camera_name, {})
|
|
||||||
attempts = failed.get("retry_attempts", 0)
|
|
||||||
last_attempt = failed.get("last_attempt", 0)
|
|
||||||
|
|
||||||
# Reset retry count after timeout
|
# Reset retry count after timeout
|
||||||
|
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
|
||||||
|
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
|
||||||
|
|
||||||
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
|
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
|
||||||
logger.debug(f"Resetting retry count for {camera_name} after timeout")
|
logger.debug(f"Resetting retry count for {camera_name} after timeout")
|
||||||
attempts = 0
|
attempts = 0
|
||||||
|
self.failed_cams[camera_name]["retry_attempts"] = 0
|
||||||
|
|
||||||
|
# Attempt initialization/reconnection
|
||||||
|
if attempts < self.max_retries:
|
||||||
|
logger.info(
|
||||||
|
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if await self._init_onvif(camera_name):
|
||||||
|
if camera_name in self.failed_cams:
|
||||||
|
del self.failed_cams[camera_name]
|
||||||
|
return {
|
||||||
|
"name": camera_name,
|
||||||
|
"features": self.cams[camera_name]["features"],
|
||||||
|
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
||||||
|
self.failed_cams[camera_name] = {
|
||||||
|
"retry_attempts": attempts + 1,
|
||||||
|
"last_attempt": time.time(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error during ONVIF initialization for {camera_name}: {e}"
|
||||||
|
)
|
||||||
|
if camera_name not in self.failed_cams:
|
||||||
|
self.failed_cams[camera_name] = {"retry_attempts": 0}
|
||||||
|
self.failed_cams[camera_name].update(
|
||||||
|
{
|
||||||
|
"retry_attempts": attempts + 1,
|
||||||
|
"last_error": str(e),
|
||||||
|
"last_attempt": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if attempts >= self.max_retries:
|
if attempts >= self.max_retries:
|
||||||
remaining_time = max(
|
remaining_time = max(
|
||||||
@@ -883,24 +991,8 @@ class OnvifController:
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
|
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
|
||||||
)
|
)
|
||||||
return {}
|
|
||||||
|
|
||||||
logger.info(
|
logger.debug(f"Could not initialize ONVIF for {camera_name}")
|
||||||
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if await self._init_onvif(camera_name):
|
|
||||||
self.failed_cams.pop(camera_name, None)
|
|
||||||
return self._camera_info(camera_name)
|
|
||||||
|
|
||||||
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during ONVIF initialization for {camera_name}: {e}")
|
|
||||||
|
|
||||||
self.failed_cams[camera_name] = {
|
|
||||||
"retry_attempts": attempts + 1,
|
|
||||||
"last_attempt": time.time(),
|
|
||||||
}
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def get_service_capabilities(self, camera_name: str) -> None:
|
async def get_service_capabilities(self, camera_name: str) -> None:
|
||||||
@@ -908,13 +1000,16 @@ class OnvifController:
|
|||||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
cam = self.cams[camera_name]
|
if not self.cams[camera_name]["init"]:
|
||||||
|
|
||||||
if not cam["init"]:
|
|
||||||
await self._init_onvif(camera_name)
|
await self._init_onvif(camera_name)
|
||||||
|
|
||||||
|
service_capabilities_request = self.cams[camera_name][
|
||||||
|
"service_capabilities_request"
|
||||||
|
]
|
||||||
try:
|
try:
|
||||||
service_capabilities = await cam["ptz"].GetServiceCapabilities()
|
service_capabilities = await self.cams[camera_name][
|
||||||
|
"ptz"
|
||||||
|
].GetServiceCapabilities(service_capabilities_request)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
||||||
@@ -940,16 +1035,13 @@ class OnvifController:
|
|||||||
if metrics is None or camera_config is None:
|
if metrics is None or camera_config is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
cam = self.cams[camera_name]
|
if not self.cams[camera_name]["init"]:
|
||||||
|
|
||||||
if not cam["init"]:
|
|
||||||
if not await self._init_onvif(camera_name):
|
if not await self._init_onvif(camera_name):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
status_request = self.cams[camera_name]["status_request"]
|
||||||
try:
|
try:
|
||||||
status = await cam["ptz"].GetStatus(
|
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
|
||||||
{"ProfileToken": cam["move_request"].ProfileToken}
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # We're unsupported, that'll be reported in the next check.
|
pass # We're unsupported, that'll be reported in the next check.
|
||||||
|
|
||||||
@@ -980,7 +1072,7 @@ class OnvifController:
|
|||||||
if pan_tilt_status == "IDLE" and (
|
if pan_tilt_status == "IDLE" and (
|
||||||
zoom_status is None or zoom_status == "IDLE"
|
zoom_status is None or zoom_status == "IDLE"
|
||||||
):
|
):
|
||||||
cam["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
if not metrics.motor_stopped.is_set():
|
if not metrics.motor_stopped.is_set():
|
||||||
metrics.motor_stopped.set()
|
metrics.motor_stopped.set()
|
||||||
|
|
||||||
@@ -990,7 +1082,7 @@ class OnvifController:
|
|||||||
|
|
||||||
metrics.stop_time.value = metrics.frame_time.value
|
metrics.stop_time.value = metrics.frame_time.value
|
||||||
else:
|
else:
|
||||||
cam["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
if metrics.motor_stopped.is_set():
|
if metrics.motor_stopped.is_set():
|
||||||
metrics.motor_stopped.clear()
|
metrics.motor_stopped.clear()
|
||||||
|
|
||||||
@@ -1006,8 +1098,8 @@ class OnvifController:
|
|||||||
metrics.zoom_level.value = numpy.interp(
|
metrics.zoom_level.value = numpy.interp(
|
||||||
round(status.Position.Zoom.x, 2),
|
round(status.Position.Zoom.x, 2),
|
||||||
[
|
[
|
||||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||||
],
|
],
|
||||||
[0, 1],
|
[0, 1],
|
||||||
)
|
)
|
||||||
@@ -1046,7 +1138,7 @@ class OnvifController:
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Gracefully shut down the ONVIF controller."""
|
"""Gracefully shut down the ONVIF controller."""
|
||||||
if self.loop.is_closed():
|
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||||
logger.debug("ONVIF controller already closed")
|
logger.debug("ONVIF controller already closed")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1063,6 +1155,13 @@ class OnvifController:
|
|||||||
|
|
||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
|
|
||||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
def stop_and_cleanup():
|
||||||
|
try:
|
||||||
|
self.loop.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during loop cleanup: {e}")
|
||||||
|
|
||||||
|
# Schedule stop and cleanup in the loop thread
|
||||||
|
self.loop.call_soon_threadsafe(stop_and_cleanup)
|
||||||
|
|
||||||
self.loop_thread.join()
|
self.loop_thread.join()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class TestImprovedMotionDetector(unittest.TestCase):
|
|||||||
|
|
||||||
class DummyPTZ:
|
class DummyPTZ:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.autotracker_enabled = _Stub(False)
|
||||||
self.motor_stopped = _Stub(False)
|
self.motor_stopped = _Stub(False)
|
||||||
self.stop_time = _Stub(0)
|
self.stop_time = _Stub(0)
|
||||||
|
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
|||||||
|
|
||||||
def test_publish_direct_waits_for_flush_barrier(self) -> None:
|
def test_publish_direct_waits_for_flush_barrier(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
|
mock_client.loop.return_value = mqtt.MQTT_ERR_SUCCESS
|
||||||
self.client.client = mock_client
|
self.client.client = mock_client
|
||||||
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1)
|
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1)
|
||||||
# inflight tracking checks once, then _wait_for_publish polls
|
# inflight tracking checks once, then _wait_for_publish polls
|
||||||
@@ -455,14 +456,11 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
|||||||
mock_client.publish.return_value = message_info
|
mock_client.publish.return_value = message_info
|
||||||
barrier = MagicMock()
|
barrier = MagicMock()
|
||||||
|
|
||||||
with patch.object(
|
self.client._publish_direct(
|
||||||
self.client, "_loop_client", return_value=mqtt.MQTT_ERR_SUCCESS
|
QueuedPublish("frigate/available", "stopped", True, barrier)
|
||||||
) as mock_loop:
|
)
|
||||||
self.client._publish_direct(
|
|
||||||
QueuedPublish("frigate/available", "stopped", True, barrier)
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_loop.assert_called_once()
|
mock_client.loop.assert_called_once()
|
||||||
barrier.set.assert_called_once()
|
barrier.set.assert_called_once()
|
||||||
|
|
||||||
def test_shutdown_barrier_releases_when_publish_raises(self) -> None:
|
def test_shutdown_barrier_releases_when_publish_raises(self) -> None:
|
||||||
@@ -645,8 +643,9 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
|||||||
loop_calls[0] += 1
|
loop_calls[0] += 1
|
||||||
return mqtt.MQTT_ERR_SUCCESS
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
with patch.object(self.client, "_loop_client", side_effect=loop_side_effect):
|
mock_client.loop.side_effect = loop_side_effect
|
||||||
self.client._wait_for_publish(message_info)
|
|
||||||
|
self.client._wait_for_publish(message_info)
|
||||||
|
|
||||||
self.assertEqual(loop_calls[0], 1)
|
self.assertEqual(loop_calls[0], 1)
|
||||||
self.assertIsNone(self.client.client)
|
self.assertIsNone(self.client.client)
|
||||||
@@ -670,20 +669,16 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
|||||||
|
|
||||||
def test_mqtt_loop_worker_reconnects_on_recoverable_loop_error(self) -> None:
|
def test_mqtt_loop_worker_reconnects_on_recoverable_loop_error(self) -> None:
|
||||||
self.client.client = MagicMock()
|
self.client.client = MagicMock()
|
||||||
|
self.client.client.loop.side_effect = OSError("socket closed")
|
||||||
|
|
||||||
def stop_after_reconnect() -> None:
|
def stop_after_reconnect() -> None:
|
||||||
self.client._stop_event.set()
|
self.client._stop_event.set()
|
||||||
|
|
||||||
with (
|
with patch.object(
|
||||||
patch.object(
|
self.client,
|
||||||
self.client, "_loop_client", side_effect=OSError("socket closed")
|
"_schedule_reconnect",
|
||||||
),
|
side_effect=stop_after_reconnect,
|
||||||
patch.object(
|
) as mock_schedule_reconnect:
|
||||||
self.client,
|
|
||||||
"_schedule_reconnect",
|
|
||||||
side_effect=stop_after_reconnect,
|
|
||||||
) as mock_schedule_reconnect,
|
|
||||||
):
|
|
||||||
self.client._mqtt_loop_worker()
|
self.client._mqtt_loop_worker()
|
||||||
|
|
||||||
mock_schedule_reconnect.assert_called_once()
|
mock_schedule_reconnect.assert_called_once()
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import fcntl
|
|
||||||
import resource
|
|
||||||
import selectors
|
|
||||||
import socket
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
|
||||||
from paho.mqtt.enums import CallbackAPIVersion
|
|
||||||
|
|
||||||
from frigate.comms.mqtt import MqttClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestMqttNetworkLoop(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.transport = object.__new__(MqttClient)
|
|
||||||
self.client = MagicMock()
|
|
||||||
self.client.want_write.return_value = False
|
|
||||||
self.client.loop_read.return_value = mqtt.MQTT_ERR_SUCCESS
|
|
||||||
self.client.loop_write.return_value = mqtt.MQTT_ERR_SUCCESS
|
|
||||||
self.client.loop_misc.return_value = mqtt.MQTT_ERR_SUCCESS
|
|
||||||
self.transport.client = self.client
|
|
||||||
self.sock, self.peer = socket.socketpair()
|
|
||||||
self.addCleanup(self.sock.close)
|
|
||||||
self.addCleanup(self.peer.close)
|
|
||||||
self.client.socket.return_value = self.sock
|
|
||||||
|
|
||||||
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
|
|
||||||
"""Process real MQTT packets on a socket beyond select()'s FD limit."""
|
|
||||||
if selectors.DefaultSelector is selectors.SelectSelector:
|
|
||||||
self.skipTest("This platform has no selector supporting high socket FDs")
|
|
||||||
original_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
soft, hard = original_limit
|
|
||||||
if soft <= 1024:
|
|
||||||
if hard != resource.RLIM_INFINITY and hard <= 1024:
|
|
||||||
self.skipTest("The hard file descriptor limit is too low")
|
|
||||||
new_soft = 2048 if hard == resource.RLIM_INFINITY else min(2048, hard)
|
|
||||||
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
|
|
||||||
self.addCleanup(resource.setrlimit, resource.RLIMIT_NOFILE, original_limit)
|
|
||||||
|
|
||||||
fd = fcntl.fcntl(self.sock.fileno(), fcntl.F_DUPFD, 1024)
|
|
||||||
with socket.socket(fileno=fd) as high_sock:
|
|
||||||
high_sock.setblocking(False)
|
|
||||||
self.peer.settimeout(1)
|
|
||||||
client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="high-fd-test")
|
|
||||||
client._sock = high_sock
|
|
||||||
self.transport.client = client
|
|
||||||
connected, subscribed, received = [], [], []
|
|
||||||
client.on_connect = lambda *args: connected.append(args[3])
|
|
||||||
client.on_subscribe = lambda *args: subscribed.append(args[2])
|
|
||||||
client.on_message = lambda client, userdata, message: received.append(
|
|
||||||
message.payload
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertGreaterEqual(high_sock.fileno(), 1024)
|
|
||||||
self.peer.sendall(b"\x20\x02\x00\x00")
|
|
||||||
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.assertEqual(len(connected), 1)
|
|
||||||
self.assertTrue(client.is_connected())
|
|
||||||
|
|
||||||
result, mid = client.subscribe("diagnostic", qos=1)
|
|
||||||
self.assertEqual(result, mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.peer.recv(1024)
|
|
||||||
self.peer.sendall(b"\x90\x03" + mid.to_bytes(2, "big") + b"\x01")
|
|
||||||
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.assertEqual(subscribed, [mid])
|
|
||||||
|
|
||||||
info = client.publish("diagnostic", b"outgoing", qos=1)
|
|
||||||
self.peer.recv(1024)
|
|
||||||
self.peer.sendall(b"\x40\x02" + info.mid.to_bytes(2, "big"))
|
|
||||||
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.assertTrue(info.is_published())
|
|
||||||
|
|
||||||
payload = b"\x00\x0adiagnosticincoming"
|
|
||||||
self.peer.sendall(b"\x30" + bytes([len(payload)]) + payload)
|
|
||||||
self.assertEqual(self.transport._loop_client(0.1), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.assertEqual(received, [b"incoming"])
|
|
||||||
|
|
||||||
def test_idle_socket_still_runs_keepalive(self) -> None:
|
|
||||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.client.loop_read.assert_not_called()
|
|
||||||
self.client.loop_write.assert_not_called()
|
|
||||||
self.client.loop_misc.assert_called_once()
|
|
||||||
|
|
||||||
def test_writable_socket_flushes_pending_packets(self) -> None:
|
|
||||||
self.client.want_write.return_value = True
|
|
||||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
self.client.loop_write.assert_called_once()
|
|
||||||
self.client.loop_read.assert_not_called()
|
|
||||||
|
|
||||||
def test_tls_buffer_is_read_without_waiting_for_socket_readiness(self) -> None:
|
|
||||||
tls_sock = MagicMock()
|
|
||||||
tls_sock.fileno.return_value = self.sock.fileno()
|
|
||||||
tls_sock.pending.return_value = 1
|
|
||||||
self.client.socket.return_value = tls_sock
|
|
||||||
with patch("frigate.comms.mqtt.selectors.DefaultSelector") as selector:
|
|
||||||
selector.return_value.__enter__.return_value.select.return_value = []
|
|
||||||
self.assertEqual(self.transport._loop_client(1), mqtt.MQTT_ERR_SUCCESS)
|
|
||||||
selector.return_value.__enter__.return_value.select.assert_called_once_with(
|
|
||||||
0.0
|
|
||||||
)
|
|
||||||
self.client.loop_read.assert_called_once()
|
|
||||||
|
|
||||||
def test_read_failure_does_not_write_or_run_keepalive(self) -> None:
|
|
||||||
self.peer.sendall(b"ready")
|
|
||||||
self.client.want_write.return_value = True
|
|
||||||
self.client.loop_read.return_value = mqtt.MQTT_ERR_CONN_LOST
|
|
||||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
|
|
||||||
self.client.loop_write.assert_not_called()
|
|
||||||
self.client.loop_misc.assert_not_called()
|
|
||||||
|
|
||||||
def test_socket_closed_during_read_does_not_write(self) -> None:
|
|
||||||
self.peer.sendall(b"ready")
|
|
||||||
self.client.want_write.return_value = True
|
|
||||||
self.client.socket.side_effect = [self.sock, None]
|
|
||||||
self.transport._loop_client(0)
|
|
||||||
self.client.loop_write.assert_not_called()
|
|
||||||
self.client.loop_misc.assert_not_called()
|
|
||||||
|
|
||||||
def test_write_failure_does_not_run_keepalive(self) -> None:
|
|
||||||
self.client.want_write.return_value = True
|
|
||||||
self.client.loop_write.return_value = mqtt.MQTT_ERR_CONN_LOST
|
|
||||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
|
|
||||||
self.client.loop_misc.assert_not_called()
|
|
||||||
|
|
||||||
def test_missing_client_or_socket_reports_no_connection(self) -> None:
|
|
||||||
self.client.socket.return_value = None
|
|
||||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
|
|
||||||
self.transport.client = None
|
|
||||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
|
|
||||||
@@ -7,8 +7,9 @@ KeyError on the autotracker thread or silently keep the wrong state:
|
|||||||
|
|
||||||
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
|
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
|
||||||
constructed, so runtime-enabled cameras raised KeyError on lookup.
|
constructed, so runtime-enabled cameras raised KeyError on lookup.
|
||||||
- _disable only changed the main process config, so the camera process kept
|
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
|
||||||
running its motion estimator for a camera that could not autotrack.
|
updated it when autotracking was enabled through a config save, so it stayed
|
||||||
|
False and the tracker never built a motion estimator.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
@@ -16,8 +17,7 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
from frigate.camera import PTZMetrics
|
from frigate.camera import PTZMetrics
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
from frigate.ptz.autotrack import PtzAutoTracker
|
||||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
|
||||||
|
|
||||||
CAMERA = "ptz_cam"
|
CAMERA = "ptz_cam"
|
||||||
|
|
||||||
@@ -53,9 +53,8 @@ def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
|
|||||||
onvif over the network. Only the config/metrics state is relevant here."""
|
onvif over the network. Only the config/metrics state is relevant here."""
|
||||||
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
|
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
|
||||||
tracker.config = _config(autotracking_enabled)
|
tracker.config = _config(autotracking_enabled)
|
||||||
tracker.ptz_metrics = {CAMERA: PTZMetrics()}
|
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
|
||||||
tracker.onvif = MagicMock()
|
tracker.onvif = MagicMock()
|
||||||
tracker.dispatcher = MagicMock()
|
|
||||||
tracker.config_subscriber = MagicMock()
|
tracker.config_subscriber = MagicMock()
|
||||||
tracker.autotracker_init = {}
|
tracker.autotracker_init = {}
|
||||||
tracker.calibrating = {}
|
tracker.calibrating = {}
|
||||||
@@ -84,49 +83,47 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
|
|||||||
tracker.onvif.get_camera_status.assert_not_called()
|
tracker.onvif.get_camera_status.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestAutotrackerEnqueueMove(unittest.TestCase):
|
class TestAutotrackerMetricSync(unittest.TestCase):
|
||||||
def _enqueue(self, pan: float, tilt: float, zoom: float) -> MagicMock:
|
def test_metric_follows_config_when_enabled_by_update(self) -> None:
|
||||||
tracker = _make_tracker()
|
# autotracking enabled via a config save: the metric was seeded False when
|
||||||
tracker.move_queues = {CAMERA: MagicMock()}
|
# the camera was added and nothing else updates it
|
||||||
tracker.move_queue_locks = {CAMERA: MagicMock()}
|
|
||||||
tracker.move_queue_locks[CAMERA].locked.return_value = False
|
|
||||||
|
|
||||||
tracker._enqueue_move(CAMERA, 1000.0, pan, tilt, zoom)
|
|
||||||
|
|
||||||
return tracker.onvif.loop.call_soon_threadsafe
|
|
||||||
|
|
||||||
def test_move_is_clipped_to_the_onvif_range(self) -> None:
|
|
||||||
# velocity estimates can push the predicted centroid outside the frame
|
|
||||||
call_soon = self._enqueue(1.7, -2.5, 0.4)
|
|
||||||
|
|
||||||
call_soon.assert_called_once()
|
|
||||||
self.assertEqual(call_soon.call_args.args[1], (1000.0, 1.0, -1.0, 0.4))
|
|
||||||
|
|
||||||
def test_empty_move_is_not_enqueued(self) -> None:
|
|
||||||
self._enqueue(0, 0, 0).assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutotrackerDisable(unittest.TestCase):
|
|
||||||
def test_disable_publishes_to_camera_process(self) -> None:
|
|
||||||
tracker = _make_tracker(autotracking_enabled=True)
|
tracker = _make_tracker(autotracking_enabled=True)
|
||||||
|
metrics = tracker.ptz_metrics[CAMERA]
|
||||||
|
self.assertFalse(metrics.autotracker_enabled.value)
|
||||||
|
|
||||||
tracker._disable(CAMERA, "onvif connection failed")
|
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
|
||||||
|
tracker.check_for_updates()
|
||||||
|
|
||||||
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
self.assertTrue(metrics.autotracker_enabled.value)
|
||||||
self.assertFalse(autotracking.enabled)
|
|
||||||
|
|
||||||
publish = tracker.dispatcher.config_updater.publish_update
|
def test_metric_follows_config_when_disabled_by_update(self) -> None:
|
||||||
publish.assert_called_once()
|
tracker = _make_tracker(autotracking_enabled=False)
|
||||||
topic, payload = publish.call_args.args
|
metrics = tracker.ptz_metrics[CAMERA]
|
||||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.autotracking)
|
metrics.autotracker_enabled.value = True
|
||||||
self.assertEqual(topic.camera, CAMERA)
|
|
||||||
self.assertIs(payload, autotracking)
|
|
||||||
|
|
||||||
|
tracker.config_subscriber.check_for_updates.return_value = {
|
||||||
|
"autotracking": [CAMERA]
|
||||||
|
}
|
||||||
|
tracker.check_for_updates()
|
||||||
|
|
||||||
class TestMaxTargetBox(unittest.TestCase):
|
self.assertFalse(metrics.autotracker_enabled.value)
|
||||||
def test_follows_zoom_factor(self) -> None:
|
|
||||||
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
def test_metric_sync_skips_camera_without_metrics(self) -> None:
|
||||||
self.assertAlmostEqual(calculate_max_target_box(0.25), 0.6**4)
|
# `add` reaches the maintainer and the autotracker on separate threads with
|
||||||
|
# no ordering guarantee, so the metrics may not exist yet
|
||||||
|
tracker = _make_tracker()
|
||||||
|
tracker.ptz_metrics = {}
|
||||||
|
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
|
||||||
|
|
||||||
|
tracker.check_for_updates()
|
||||||
|
|
||||||
|
def test_metric_sync_skips_unknown_camera(self) -> None:
|
||||||
|
tracker = _make_tracker()
|
||||||
|
tracker.config_subscriber.check_for_updates.return_value = {
|
||||||
|
"add": ["not_in_config"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.check_for_updates()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
Regression coverage for a camera that is initialized while autotracking is off and
|
Regression coverage for a camera that is initialized while autotracking is off and
|
||||||
has it enabled later, which is the normal wizard flow: set the camera up first,
|
has it enabled later, which is the normal wizard flow: set the camera up first,
|
||||||
configure autotracking afterwards. get_camera_status skips its re-init branch when
|
configure autotracking afterwards. The autotracking-only request objects used to
|
||||||
init is True, so everything it reads must exist whether or not autotracking was
|
be created only when autotracking was enabled at init time, so the camera was left
|
||||||
enabled at init time.
|
with init=True but no status_request. get_camera_status skips its re-init branch
|
||||||
|
when init is True, so it went straight to the missing key and raised KeyError on
|
||||||
|
the tracking thread.
|
||||||
|
|
||||||
|
The request objects are built from the locally parsed WSDL and cost no network, so
|
||||||
|
they are always created and init=True now implies they exist.
|
||||||
|
|
||||||
Also covers the inverse direction: the ptz movement timestamps must not be written
|
Also covers the inverse direction: the ptz movement timestamps must not be written
|
||||||
for a camera that has autotracking off, because nothing clears them back out.
|
for a camera that has autotracking off, because nothing clears them back out.
|
||||||
@@ -94,6 +99,7 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
|||||||
controller.config = config
|
controller.config = config
|
||||||
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
|
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
|
||||||
controller.failed_cams = {}
|
controller.failed_cams = {}
|
||||||
|
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||||
controller.ptz_metrics = {CAMERA: MagicMock()}
|
controller.ptz_metrics = {CAMERA: MagicMock()}
|
||||||
return controller
|
return controller
|
||||||
|
|
||||||
@@ -104,6 +110,7 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
|||||||
config = _config(autotracking_enabled)
|
config = _config(autotracking_enabled)
|
||||||
controller = OnvifController.__new__(OnvifController)
|
controller = OnvifController.__new__(OnvifController)
|
||||||
controller.config = config
|
controller.config = config
|
||||||
|
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||||
controller.failed_cams = {}
|
controller.failed_cams = {}
|
||||||
|
|
||||||
ptz = MagicMock()
|
ptz = MagicMock()
|
||||||
@@ -121,30 +128,45 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controller.ptz_metrics = {CAMERA: PTZMetrics()}
|
controller.ptz_metrics = {
|
||||||
|
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
||||||
|
}
|
||||||
return controller
|
return controller
|
||||||
|
|
||||||
|
|
||||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_camera_status_independent_of_autotracking_at_init(self) -> None:
|
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||||
# the wizard flow: onvif configured first, autotracking enabled later
|
# the wizard flow: onvif configured first, autotracking enabled later
|
||||||
|
controller = _make_controller(autotracking_enabled=False)
|
||||||
|
|
||||||
|
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||||
|
|
||||||
|
cam = controller.cams[CAMERA]
|
||||||
|
self.assertTrue(cam["init"])
|
||||||
|
self.assertIn("status_request", cam)
|
||||||
|
self.assertIn("service_capabilities_request", cam)
|
||||||
|
|
||||||
|
async def test_status_request_created_when_autotracking_enabled(self) -> None:
|
||||||
|
controller = _make_controller(autotracking_enabled=True)
|
||||||
|
|
||||||
|
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||||
|
|
||||||
|
cam = controller.cams[CAMERA]
|
||||||
|
self.assertIn("status_request", cam)
|
||||||
|
self.assertIn("service_capabilities_request", cam)
|
||||||
|
|
||||||
|
async def test_init_implies_status_request_exists(self) -> None:
|
||||||
|
# the invariant get_camera_status relies on: it skips re-init when init is
|
||||||
|
# True and then reads status_request without guarding
|
||||||
for autotracking_enabled in (True, False):
|
for autotracking_enabled in (True, False):
|
||||||
with self.subTest(autotracking_enabled=autotracking_enabled):
|
with self.subTest(autotracking_enabled=autotracking_enabled):
|
||||||
controller = _make_controller(autotracking_enabled)
|
controller = _make_controller(autotracking_enabled)
|
||||||
controller.status_locks = {CAMERA: asyncio.Lock()}
|
|
||||||
|
|
||||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
await controller._init_onvif(CAMERA)
|
||||||
|
|
||||||
status = MagicMock()
|
cam = controller.cams[CAMERA]
|
||||||
status.MoveStatus.PanTilt = "IDLE"
|
if cam["init"]:
|
||||||
status.MoveStatus.Zoom = "IDLE"
|
self.assertEqual(cam["status_request"].request_type, "GetStatus")
|
||||||
ptz = controller.cams[CAMERA]["ptz"]
|
|
||||||
ptz.GetStatus = AsyncMock(return_value=status)
|
|
||||||
|
|
||||||
await controller.get_camera_status(CAMERA)
|
|
||||||
|
|
||||||
ptz.GetStatus.assert_awaited_once_with({"ProfileToken": "profile_1"})
|
|
||||||
self.assertFalse(controller.cams[CAMERA]["active"])
|
|
||||||
|
|
||||||
async def test_requests_built_without_contacting_camera(self) -> None:
|
async def test_requests_built_without_contacting_camera(self) -> None:
|
||||||
# create_type is a local WSDL lookup; cameras that do not implement
|
# create_type is a local WSDL lookup; cameras that do not implement
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.camera_config.onvif.autotracking.enabled:
|
if self.ptz_metrics.autotracker_enabled.value:
|
||||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||||
self.camera_config, self.ptz_metrics
|
self.camera_config, self.ptz_metrics
|
||||||
)
|
)
|
||||||
@@ -515,7 +515,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
yuv_frame: np.ndarray | None = None
|
yuv_frame: np.ndarray | None = None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.camera_config.onvif.autotracking.enabled
|
self.ptz_metrics.autotracker_enabled.value
|
||||||
or self.detect_config.stationary.classifier
|
or self.detect_config.stationary.classifier
|
||||||
):
|
):
|
||||||
yuv_frame = self.frame_manager.get(
|
yuv_frame = self.frame_manager.get(
|
||||||
@@ -534,7 +534,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
|
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
|
||||||
|
|
||||||
embedding = None
|
embedding = None
|
||||||
if self.camera_config.onvif.autotracking.enabled:
|
if self.ptz_metrics.autotracker_enabled.value:
|
||||||
embedding = get_histogram(
|
embedding = get_histogram(
|
||||||
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
|
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
|
||||||
)
|
)
|
||||||
@@ -559,7 +559,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
|
|
||||||
coord_transformations = None
|
coord_transformations = None
|
||||||
|
|
||||||
if self.camera_config.onvif.autotracking.enabled:
|
if self.ptz_metrics.autotracker_enabled.value:
|
||||||
# we must have been enabled by mqtt, so set up the estimator
|
# we must have been enabled by mqtt, so set up the estimator
|
||||||
if not self.ptz_motion_estimator:
|
if not self.ptz_motion_estimator:
|
||||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.models import Event, ReviewSegment, Timeline
|
from frigate.models import Event, ReviewSegment, Timeline
|
||||||
from frigate.ptz.autotrack import PtzAutoTracker
|
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||||
from frigate.track.tracked_object import TrackedObject
|
from frigate.track.tracked_object import TrackedObject
|
||||||
from frigate.util.image import SharedMemoryFrameManager
|
from frigate.util.image import SharedMemoryFrameManager
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
dispatcher: Dispatcher,
|
dispatcher: Dispatcher,
|
||||||
tracked_objects_queue: MpQueue,
|
tracked_objects_queue: MpQueue,
|
||||||
ptz_autotracker_thread: PtzAutoTracker,
|
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||||
stop_event: MpEvent,
|
stop_event: MpEvent,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(name="detected_frames_processor")
|
super().__init__(name="detected_frames_processor")
|
||||||
@@ -153,7 +153,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||||
self.ptz_autotracker_thread.autotrack_object(camera, obj)
|
self.ptz_autotracker_thread.ptz_autotracker.autotrack_object(camera, obj)
|
||||||
|
|
||||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||||
# populate has_snapshot
|
# populate has_snapshot
|
||||||
@@ -177,7 +177,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
"type": "end",
|
"type": "end",
|
||||||
}
|
}
|
||||||
self.dispatcher.publish("events", json.dumps(message), retain=False)
|
self.dispatcher.publish("events", json.dumps(message), retain=False)
|
||||||
self.ptz_autotracker_thread.end_object(camera, obj)
|
self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj)
|
||||||
|
|
||||||
self.event_sender.publish(
|
self.event_sender.publish(
|
||||||
(
|
(
|
||||||
|
|||||||
+2
-10
@@ -94,7 +94,6 @@ class CameraTracker(FrigateProcess):
|
|||||||
self.config.detect.fps,
|
self.config.detect.fps,
|
||||||
name=self.config.name,
|
name=self.config.name,
|
||||||
ptz_metrics=self.ptz_metrics,
|
ptz_metrics=self.ptz_metrics,
|
||||||
autotracking_enabled=self.config.onvif.autotracking.enabled,
|
|
||||||
)
|
)
|
||||||
object_detector = RemoteObjectDetector(
|
object_detector = RemoteObjectDetector(
|
||||||
self.config.name,
|
self.config.name,
|
||||||
@@ -196,12 +195,10 @@ def process_frames(
|
|||||||
None,
|
None,
|
||||||
{camera_config.name: camera_config},
|
{camera_config.name: camera_config},
|
||||||
[
|
[
|
||||||
CameraConfigUpdateEnum.autotracking,
|
|
||||||
CameraConfigUpdateEnum.detect,
|
CameraConfigUpdateEnum.detect,
|
||||||
CameraConfigUpdateEnum.enabled,
|
CameraConfigUpdateEnum.enabled,
|
||||||
CameraConfigUpdateEnum.motion,
|
CameraConfigUpdateEnum.motion,
|
||||||
CameraConfigUpdateEnum.objects,
|
CameraConfigUpdateEnum.objects,
|
||||||
CameraConfigUpdateEnum.onvif,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,11 +235,6 @@ def process_frames(
|
|||||||
motion_detector.config = camera_config.motion
|
motion_detector.config = camera_config.motion
|
||||||
motion_detector.update_mask()
|
motion_detector.update_mask()
|
||||||
|
|
||||||
if "autotracking" in updated_configs or "onvif" in updated_configs:
|
|
||||||
motion_detector.autotracking_enabled = (
|
|
||||||
camera_config.onvif.autotracking.enabled
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not camera_enabled
|
not camera_enabled
|
||||||
and prev_enabled != camera_enabled
|
and prev_enabled != camera_enabled
|
||||||
@@ -357,8 +349,8 @@ def process_frames(
|
|||||||
|
|
||||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||||
# the ptz timestamps are only maintained while autotracking is on, so gate
|
# the ptz timestamps are only maintained while autotracking is on, so gate
|
||||||
# on the config rather than trusting them to be reset otherwise
|
# on the metric rather than trusting them to be reset otherwise
|
||||||
ptz_moving = camera_config.onvif.autotracking.enabled and (
|
ptz_moving = ptz_metrics.autotracker_enabled.value and (
|
||||||
ptz_moving_at_frame_time(
|
ptz_moving_at_frame_time(
|
||||||
frame_time,
|
frame_time,
|
||||||
ptz_metrics.start_time.value,
|
ptz_metrics.start_time.value,
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { FrigateApp } from "../fixtures/frigate-test";
|
|
||||||
|
|
||||||
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
|
|
||||||
// of every tab for measurement and hides the ones that do not fit behind a
|
|
||||||
// kebab. The selected tab always stays in the strip.
|
|
||||||
|
|
||||||
export function systemTab(frigateApp: FrigateApp, name: string) {
|
|
||||||
return frigateApp.page
|
|
||||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
|
||||||
.first();
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { systemTab } from "../helpers/system-tabs";
|
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Auth — admin access @high", () => {
|
test.describe("Auth — admin access @high", () => {
|
||||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -29,7 +28,7 @@ test.describe("Auth — admin access @high", () => {
|
|||||||
|
|
||||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,22 +287,3 @@ test.describe("Live mobile layout @critical @mobile", () => {
|
|||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Live camera groups @medium", () => {
|
|
||||||
test("a group with an invalid icon renders a fallback icon", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
camera_groups: {
|
|
||||||
outdoor: { cameras: ["front_door"], icon: "generic" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const group = frigateApp.page
|
|
||||||
.locator('[aria-label="Camera Groups"]:not([inert] *)')
|
|
||||||
.first();
|
|
||||||
await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Add-camera wizard - Reolink stream selection with the brand template.
|
||||||
|
*
|
||||||
|
* The wizard asks the camera for its resolution, then probes http-flv first
|
||||||
|
* above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
|
||||||
|
* cameras that should be on http-flv. An http-flv stream the wizard selects
|
||||||
|
* is registered with go2rtc through the ffmpeg module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const FLV_PATH = "channel0_main.bcs";
|
||||||
|
const RTSP_PATH = "Preview_01_main";
|
||||||
|
const RTSP_WARNING = "Reolink RTSP is not recommended";
|
||||||
|
const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
|
||||||
|
|
||||||
|
const FFPROBE_OK = [
|
||||||
|
{
|
||||||
|
return_code: 0,
|
||||||
|
stderr: [],
|
||||||
|
stdout: {
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
codec_type: "video",
|
||||||
|
codec_name: "hevc",
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
avg_frame_rate: "15/1",
|
||||||
|
},
|
||||||
|
{ codec_type: "audio", codec_name: "aac" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FFPROBE_FAILED = [
|
||||||
|
{ return_code: 1, stderr: ["probe failed"], stdout: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock the camera's answers and drive the wizard to Step 3. Returns the
|
||||||
|
* dialog and the stream paths the wizard probed, in order.
|
||||||
|
*/
|
||||||
|
async function gotoStep3(
|
||||||
|
page: Page,
|
||||||
|
{ protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
|
||||||
|
) {
|
||||||
|
const probed: string[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/reolink/detect**", (route) =>
|
||||||
|
route.fulfill({ json: { success: protocol !== null, protocol } }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/ffprobe**", (route) => {
|
||||||
|
const paths = new URL(route.request().url()).searchParams.get("paths");
|
||||||
|
const isFlv = !!paths?.includes(FLV_PATH);
|
||||||
|
probed.push(isFlv ? FLV_PATH : RTSP_PATH);
|
||||||
|
return route.fulfill({
|
||||||
|
json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/ffprobe/snapshot**", (route) =>
|
||||||
|
route.fulfill({ status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
|
||||||
|
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
||||||
|
await dialog.getByPlaceholder("Optional").first().fill("admin");
|
||||||
|
await dialog.getByPlaceholder("Optional").last().fill("pw");
|
||||||
|
await dialog.getByText("Manual selection").click();
|
||||||
|
await dialog.getByRole("combobox").click();
|
||||||
|
await page.getByRole("option", { name: "Reolink" }).click();
|
||||||
|
await dialog.getByRole("button", { name: /^Continue$/i }).click();
|
||||||
|
|
||||||
|
// Step 2 tests the connection on its own, then offers Continue
|
||||||
|
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
||||||
|
await expect(next).toBeEnabled({ timeout: 10_000 });
|
||||||
|
await next.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: /Add Another Stream/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
return { dialog, probed };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
|
||||||
|
test.beforeEach(async ({ frigateApp }) => {
|
||||||
|
// not in the default mock; unmocked it 500s and trips the error collector
|
||||||
|
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
||||||
|
route.fulfill({ json: {} }),
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/settings?page=cameraManagement");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
|
||||||
|
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||||
|
protocol: "rtsp",
|
||||||
|
flvProbes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual([FLV_PATH]);
|
||||||
|
await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
const registered: string[] = [];
|
||||||
|
await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
|
||||||
|
const src = new URL(route.request().url()).searchParams.get("src");
|
||||||
|
if (src) registered.push(src);
|
||||||
|
return route.fulfill({ json: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
|
||||||
|
await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("above 5MP falls back to RTSP without a warning", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||||
|
protocol: "rtsp",
|
||||||
|
flvProbes: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
|
||||||
|
await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
|
||||||
|
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||||
|
protocol: null,
|
||||||
|
flvProbes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual([RTSP_PATH]);
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||||
|
await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { systemTab } from "../helpers/system-tabs";
|
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
const NOW = Math.floor(Date.now() / 1000);
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
@@ -56,7 +55,7 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
});
|
});
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
|
|
||||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -802,7 +801,7 @@ test.describe("System — Health notices sources @medium", () => {
|
|||||||
stats: QUIET_STATS,
|
stats: QUIET_STATS,
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/system#health");
|
await frigateApp.goto("/system#health");
|
||||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|||||||
@@ -6,66 +6,42 @@
|
|||||||
* RestartDialog cancel flow.
|
* RestartDialog cancel flow.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import {
|
import {
|
||||||
expectBodyInteractive,
|
expectBodyInteractive,
|
||||||
waitForBodyInteractive,
|
waitForBodyInteractive,
|
||||||
} from "../helpers/overlay-interaction";
|
} from "../helpers/overlay-interaction";
|
||||||
import { systemTab } from "../helpers/system-tabs";
|
|
||||||
|
|
||||||
async function selectTab(frigateApp: FrigateApp, name: string) {
|
|
||||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
|
||||||
|
|
||||||
if (frigateApp.isMobile && (await kebab.isVisible())) {
|
|
||||||
await kebab.click();
|
|
||||||
await frigateApp.page
|
|
||||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
|
||||||
.last()
|
|
||||||
.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await systemTab(frigateApp, name).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectTabActive(frigateApp: FrigateApp, name: string) {
|
|
||||||
await expect(systemTab(frigateApp, name)).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{
|
|
||||||
timeout: 5_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("System — tabs @medium", () => {
|
test.describe("System — tabs @medium", () => {
|
||||||
test("general tab is active by default via #general hash", async ({
|
test("general tab is active by default via #general hash", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
if (!frigateApp.isMobile) {
|
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
||||||
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||||
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Storage tab activates and deactivates General", async ({
|
test("Storage tab activates and deactivates General", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await selectTab(frigateApp, "storage");
|
await frigateApp.page.getByLabel("Select storage").click();
|
||||||
await expectTabActive(frigateApp, "storage");
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"off",
|
"off",
|
||||||
);
|
);
|
||||||
@@ -73,20 +49,24 @@ test.describe("System — tabs @medium", () => {
|
|||||||
|
|
||||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await selectTab(frigateApp, "cameras");
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
await expectTabActive(frigateApp, "cameras");
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("general tab shows version and last-refreshed", async ({
|
test("general tab shows version and last-refreshed", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -107,13 +87,17 @@ test.describe("System — tabs @medium", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await selectTab(frigateApp, "storage");
|
await frigateApp.page.getByLabel("Select storage").click();
|
||||||
await expectTabActive(frigateApp, "storage");
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
// On desktop, tab buttons render text labels so the word "storage"
|
// On desktop, tab buttons render text labels so the word "storage"
|
||||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||||
// icon-only, so we verify the general-tab content disappears instead
|
// icon-only, so we verify the general-tab content disappears instead
|
||||||
@@ -128,23 +112,25 @@ test.describe("System — tabs @medium", () => {
|
|||||||
} else {
|
} else {
|
||||||
// Mobile: tab activation (data-state "on") already asserted above.
|
// Mobile: tab activation (data-state "on") already asserted above.
|
||||||
// Additionally confirm general tab is no longer the active tab.
|
// Additionally confirm general tab is no longer the active tab.
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(
|
||||||
"data-state",
|
frigateApp.page.getByLabel("Select general"),
|
||||||
"off",
|
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await selectTab(frigateApp, "cameras");
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
await expectTabActive(frigateApp, "cameras");
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
// Cameras tab lists every camera from config/stats. The default
|
// Cameras tab lists every camera from config/stats. The default
|
||||||
// mock has front_door, backyard, garage.
|
// mock has front_door, backyard, garage.
|
||||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||||
@@ -165,13 +151,17 @@ test.describe("System — tabs @medium", () => {
|
|||||||
config: { semantic_search: { enabled: true } },
|
config: { semantic_search: { enabled: true } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await selectTab(frigateApp, "enrichments");
|
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
||||||
await expectTabActive(frigateApp, "enrichments");
|
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
||||||
|
await enrichTab.click();
|
||||||
|
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,27 +223,31 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
|
|
||||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await selectTab(frigateApp, "storage");
|
await frigateApp.page.getByLabel("Select storage").click();
|
||||||
await expectTabActive(frigateApp, "storage");
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -261,9 +255,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||||
|
|
||||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
const tabs = frigateApp.page
|
||||||
await expect(kebab).toBeVisible();
|
.locator("[data-radix-scroll-area-viewport]")
|
||||||
const tabs = kebab.locator("..");
|
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
||||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||||
|
|
||||||
const logoBox = await logo.boundingBox();
|
const logoBox = await logo.boundingBox();
|
||||||
@@ -273,9 +267,20 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||||
|
|
||||||
// the tabs that do not fit stay reachable through the kebab
|
// the clipped tabs stay reachable by scrolling
|
||||||
await selectTab(frigateApp, "cameras");
|
const overflow = await tabs.evaluate((el) => ({
|
||||||
await expectTabActive(frigateApp, "cameras");
|
scroll: el.scrollWidth,
|
||||||
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
|
client: el.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(overflow.scroll).toBeGreaterThan(overflow.client);
|
||||||
|
await tabs.evaluate((el) => {
|
||||||
|
el.scrollLeft = el.scrollWidth;
|
||||||
|
});
|
||||||
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
},
|
},
|
||||||
"title": "System",
|
"title": "System",
|
||||||
"metrics": "System metrics",
|
"metrics": "System metrics",
|
||||||
"showAllTabs": "Show all tabs",
|
|
||||||
"showLessTabs": "Show less",
|
|
||||||
"health": {
|
"health": {
|
||||||
"title": "Health",
|
"title": "Health",
|
||||||
"notices": {
|
"notices": {
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ import { isDesktop, isMobile } from "react-device-detect";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
import OverflowStrip from "../mobile/OverflowStrip";
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { HiDotsHorizontal } from "react-icons/hi";
|
||||||
|
import { IoClose } from "react-icons/io5";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
@@ -146,7 +156,80 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
|
|
||||||
const [addGroup, setAddGroup] = useState(false);
|
const [addGroup, setAddGroup] = useState(false);
|
||||||
|
|
||||||
const groupButtons = () => {
|
// mobile overflow reveal - the group strip sits left of the logo and is
|
||||||
|
// clipped (not scrollable) when there are too many groups, so render only
|
||||||
|
// the buttons that fully fit and surface a kebab next to the last visible
|
||||||
|
// one that expands a panel revealing all of them
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
// null => all buttons fit, render them all with no kebab; a number => only
|
||||||
|
// that many fit alongside the kebab
|
||||||
|
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
const measure = measureRef.current;
|
||||||
|
|
||||||
|
if (!wrapper || !measure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = 8; // gap-2 between buttons in the strip
|
||||||
|
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||||
|
|
||||||
|
const compute = () => {
|
||||||
|
const buttons = Array.from(measure.children) as HTMLElement[];
|
||||||
|
|
||||||
|
if (buttons.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the trailing child of the measurement row is a kebab clone
|
||||||
|
const kebab = buttons[buttons.length - 1];
|
||||||
|
const groupButtons = buttons.slice(0, -1);
|
||||||
|
const available = wrapper.clientWidth;
|
||||||
|
const fullWidth =
|
||||||
|
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||||
|
Math.max(groupButtons.length - 1, 0) * gap;
|
||||||
|
|
||||||
|
if (fullWidth <= available) {
|
||||||
|
setVisibleCount(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||||
|
let used = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const el of groupButtons) {
|
||||||
|
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
|
||||||
|
|
||||||
|
if (used + next <= budget) {
|
||||||
|
used += next;
|
||||||
|
count += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleCount(Math.max(count, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
compute();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(compute);
|
||||||
|
observer.observe(wrapper);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [groups, isAdmin]);
|
||||||
|
|
||||||
|
const groupButtons = (afterSelect?: () => void) => {
|
||||||
const buttons = [
|
const buttons = [
|
||||||
<Button
|
<Button
|
||||||
key="default-group"
|
key="default-group"
|
||||||
@@ -162,6 +245,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
if (group) {
|
if (group) {
|
||||||
setGroup("default", true);
|
setGroup("default", true);
|
||||||
}
|
}
|
||||||
|
afterSelect?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MdHome className="size-5" />
|
<MdHome className="size-5" />
|
||||||
@@ -179,16 +263,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroup(name, group != "default");
|
setGroup(name, group != "default");
|
||||||
|
afterSelect?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconRenderer
|
{config && config.icon && isValidIconName(config.icon) && (
|
||||||
icon={
|
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||||
isValidIconName(config.icon)
|
)}
|
||||||
? LuIcons[config.icon]
|
|
||||||
: LuIcons.LuFolder
|
|
||||||
}
|
|
||||||
className="size-5"
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
@@ -202,6 +282,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddGroup(true);
|
setAddGroup(true);
|
||||||
|
afterSelect?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuPencil className="size-5 text-primary-variant" />
|
<LuPencil className="size-5 text-primary-variant" />
|
||||||
@@ -269,14 +350,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
onMouseEnter={() => showTooltip(name)}
|
onMouseEnter={() => showTooltip(name)}
|
||||||
onMouseLeave={() => showTooltip(undefined)}
|
onMouseLeave={() => showTooltip(undefined)}
|
||||||
>
|
>
|
||||||
<IconRenderer
|
{config && config.icon && isValidIconName(config.icon) && (
|
||||||
icon={
|
<IconRenderer
|
||||||
isValidIconName(config.icon)
|
icon={LuIcons[config.icon]}
|
||||||
? LuIcons[config.icon]
|
className="size-4"
|
||||||
: LuIcons.LuFolder
|
/>
|
||||||
}
|
)}
|
||||||
className="size-4"
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
@@ -311,13 +390,74 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<OverflowStrip
|
<div
|
||||||
className={className}
|
ref={wrapperRef}
|
||||||
items={groupButtons()}
|
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||||
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
>
|
||||||
showAllLabel={t("group.showAll")}
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
showLessLabel={t("group.showLess")}
|
{visibleCount == null
|
||||||
/>
|
? groupButtons()
|
||||||
|
: groupButtons().slice(0, visibleCount)}
|
||||||
|
</div>
|
||||||
|
{visibleCount != null && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 px-2 text-secondary-foreground"
|
||||||
|
aria-label={t("group.showAll")}
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<HiDotsHorizontal className="size-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* invisible row used only to measure natural button widths so we
|
||||||
|
can render exactly the buttons that fully fit */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||||
|
aria-hidden
|
||||||
|
inert
|
||||||
|
>
|
||||||
|
<div ref={measureRef} className="flex w-max items-center gap-2">
|
||||||
|
{groupButtons()}
|
||||||
|
<Button variant="ghost" size="sm" className="px-2">
|
||||||
|
<HiDotsHorizontal className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-20"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
key="group-overlay"
|
||||||
|
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||||
|
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||||
|
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||||
|
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{groupButtons(() => setExpanded(false))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||||
|
aria-label={t("group.showLess")}
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
<IoClose className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import { HiDotsHorizontal } from "react-icons/hi";
|
|
||||||
import { IoClose } from "react-icons/io5";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type OverflowStripProps = {
|
|
||||||
className?: string;
|
|
||||||
items: ReactNode[];
|
|
||||||
activeIndex?: number;
|
|
||||||
gapClassName?: string;
|
|
||||||
showAllLabel: string;
|
|
||||||
showLessLabel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Renders only the items that fully fit and surfaces a kebab next to the last
|
|
||||||
// visible one. The kebab expands a panel over the nearest positioned ancestor
|
|
||||||
// that reveals every item.
|
|
||||||
export default function OverflowStrip({
|
|
||||||
className,
|
|
||||||
items,
|
|
||||||
activeIndex = 0,
|
|
||||||
gapClassName = "gap-2",
|
|
||||||
showAllLabel,
|
|
||||||
showLessLabel,
|
|
||||||
}: OverflowStripProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
// null => all items fit, render them all with no kebab; a number => only
|
|
||||||
// that many fit alongside the kebab
|
|
||||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const wrapper = wrapperRef.current;
|
|
||||||
const measure = measureRef.current;
|
|
||||||
|
|
||||||
if (!wrapper || !measure) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
|
||||||
|
|
||||||
const compute = () => {
|
|
||||||
const children = Array.from(measure.children) as HTMLElement[];
|
|
||||||
|
|
||||||
if (children.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// the trailing child of the measurement row is a kebab clone
|
|
||||||
const kebab = children[children.length - 1];
|
|
||||||
const start = children[0].offsetLeft;
|
|
||||||
const ends = children
|
|
||||||
.slice(0, -1)
|
|
||||||
.map((el) => el.offsetLeft + el.offsetWidth - start);
|
|
||||||
const available = wrapper.clientWidth;
|
|
||||||
|
|
||||||
if (ends[ends.length - 1] <= available) {
|
|
||||||
setVisibleCount(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const budget = available - kebab.offsetWidth - wrapperGap;
|
|
||||||
const count = ends.filter((end) => end <= budget).length;
|
|
||||||
|
|
||||||
setVisibleCount(Math.max(count, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
compute();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(compute);
|
|
||||||
observer.observe(wrapper);
|
|
||||||
observer.observe(measure);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [items.length, gapClassName]);
|
|
||||||
|
|
||||||
// a selected item past the cut takes the last visible slot
|
|
||||||
const visibleItems =
|
|
||||||
visibleCount == null
|
|
||||||
? items
|
|
||||||
: activeIndex >= visibleCount
|
|
||||||
? [...items.slice(0, visibleCount - 1), items[activeIndex]]
|
|
||||||
: items.slice(0, visibleCount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapperRef}
|
|
||||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
|
|
||||||
gapClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{visibleItems}
|
|
||||||
</div>
|
|
||||||
{visibleCount != null && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0 px-2 text-secondary-foreground"
|
|
||||||
aria-label={showAllLabel}
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
>
|
|
||||||
<HiDotsHorizontal className="size-5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* invisible row used only to measure natural item widths so we can
|
|
||||||
render exactly the items that fully fit */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
|
||||||
aria-hidden
|
|
||||||
inert
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={measureRef}
|
|
||||||
className={cn("flex w-max items-center", gapClassName)}
|
|
||||||
>
|
|
||||||
{items}
|
|
||||||
<Button variant="ghost" size="sm" className="px-2">
|
|
||||||
<HiDotsHorizontal className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-20"
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<AnimatePresence>
|
|
||||||
{expanded && (
|
|
||||||
<motion.div
|
|
||||||
key="overflow-overlay"
|
|
||||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
|
||||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
|
||||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
|
||||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
|
||||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
{/* a tap on any item bubbles up and collapses the panel */}
|
|
||||||
<div
|
|
||||||
className={cn("flex flex-wrap items-center", gapClassName)}
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
{items}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
|
||||||
aria-label={showLessLabel}
|
|
||||||
>
|
|
||||||
<IoClose className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -222,6 +222,7 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
wizardData.username,
|
wizardData.username,
|
||||||
wizardData.password,
|
wizardData.password,
|
||||||
);
|
);
|
||||||
|
update.reolinkProtocol = protocol;
|
||||||
if (protocol === "http-flv") {
|
if (protocol === "http-flv") {
|
||||||
update.brandTemplate = "reolink";
|
update.brandTemplate = "reolink";
|
||||||
}
|
}
|
||||||
@@ -294,73 +295,72 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
[probeUri],
|
[probeUri],
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateDynamicStreamUrl = useCallback(
|
const generateDynamicStreamUrls = useCallback(
|
||||||
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
if (!brand || !data.host) return null;
|
const host = data.host;
|
||||||
|
if (!brand || !host) return [];
|
||||||
|
|
||||||
let protocol = undefined;
|
let protocol = undefined;
|
||||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||||
try {
|
try {
|
||||||
protocol = await detectReolinkCamera(
|
protocol = await detectReolinkCamera(
|
||||||
data.host,
|
host,
|
||||||
data.username,
|
data.username,
|
||||||
data.password,
|
data.password,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
onUpdate({ reolinkProtocol: protocol });
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocolKey = protocol || "rtsp";
|
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
|
||||||
|
// fallback when the http-flv stream does not probe.
|
||||||
|
const protocolKeys =
|
||||||
|
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
|
||||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||||
|
|
||||||
if (Object.keys(templates).includes(protocolKey)) {
|
return protocolKeys
|
||||||
const template =
|
.filter((key) => key in templates)
|
||||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
.map((key) =>
|
||||||
return template
|
templates[key]
|
||||||
.replace("{username}", data.username || "")
|
.replace("{username}", data.username || "")
|
||||||
.replace("{password}", data.password || "")
|
.replace("{password}", data.password || "")
|
||||||
.replace("{host}", data.host);
|
.replace("{host}", host),
|
||||||
}
|
);
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
[],
|
[onUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateStreamUrl = useCallback(
|
const generateStreamUrls = useCallback(
|
||||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||||
if (data.brandTemplate === "other") {
|
if (data.brandTemplate === "other") {
|
||||||
return data.customUrl || "";
|
return data.customUrl ? [data.customUrl] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
if (!brand || !data.host) return "";
|
if (!brand || !data.host) return [];
|
||||||
|
|
||||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
return generateDynamicStreamUrls(data);
|
||||||
|
|
||||||
if (dynamicUrl) {
|
|
||||||
return dynamicUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return brand.template
|
return [
|
||||||
.replace("{username}", data.username || "")
|
brand.template
|
||||||
.replace("{password}", data.password || "")
|
.replace("{username}", data.username || "")
|
||||||
.replace("{host}", data.host);
|
.replace("{password}", data.password || "")
|
||||||
|
.replace("{host}", data.host),
|
||||||
|
];
|
||||||
},
|
},
|
||||||
[generateDynamicStreamUrl],
|
[generateDynamicStreamUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testConnection = useCallback(
|
const testConnection = useCallback(
|
||||||
async (showToast = true) => {
|
async (showToast = true) => {
|
||||||
const streamUrl = await generateStreamUrl(wizardData);
|
const streamUrls = await generateStreamUrls(wizardData);
|
||||||
|
|
||||||
if (!streamUrl) {
|
if (streamUrls.length === 0) {
|
||||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -370,8 +370,18 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
let streamUrl = streamUrls[0];
|
||||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
let result: TestResult | undefined;
|
||||||
|
|
||||||
|
for (const url of streamUrls) {
|
||||||
|
streamUrl = url;
|
||||||
|
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||||
|
result = await probeUri(url, true, setTestStatus);
|
||||||
|
|
||||||
|
if (result.success && result.resolution) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
setTestResult(result);
|
setTestResult(result);
|
||||||
@@ -383,6 +393,9 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
url: streamUrl,
|
url: streamUrl,
|
||||||
roles: ["detect"] as StreamRole[],
|
roles: ["detect"] as StreamRole[],
|
||||||
testResult: result,
|
testResult: result,
|
||||||
|
useFfmpeg:
|
||||||
|
wizardData.brandTemplate === "reolink" &&
|
||||||
|
streamUrl.startsWith("http://"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -434,7 +447,7 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
setTestStatus("");
|
setTestStatus("");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
|
[wizardData, generateStreamUrls, t, onUpdate, probeUri],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContinue = useCallback(() => {
|
const handleContinue = useCallback(() => {
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ function StreamIssues({
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
roles: stream.roles,
|
roles: stream.roles,
|
||||||
brand: wizardData.brandTemplate,
|
brand: wizardData.brandTemplate,
|
||||||
|
reolinkProtocol: wizardData.reolinkProtocol,
|
||||||
useFfmpeg: stream.useFfmpeg,
|
useFfmpeg: stream.useFfmpeg,
|
||||||
restream: stream.restream,
|
restream: stream.restream,
|
||||||
testResult: stream.testResult,
|
testResult: stream.testResult,
|
||||||
|
|||||||
+31
-64
@@ -1,10 +1,8 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
|
||||||
import OverflowStrip from "@/components/mobile/OverflowStrip";
|
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||||
@@ -38,14 +36,6 @@ const allMetrics = [
|
|||||||
] as const;
|
] as const;
|
||||||
type SystemMetric = (typeof allMetrics)[number];
|
type SystemMetric = (typeof allMetrics)[number];
|
||||||
|
|
||||||
const metricIcons: Record<SystemMetric, ReactNode> = {
|
|
||||||
health: <LuHeartPulse className="size-4" />,
|
|
||||||
general: <LuActivity className="size-4" />,
|
|
||||||
enrichments: <LuSearchCode className="size-4" />,
|
|
||||||
storage: <LuHardDrive className="size-4" />,
|
|
||||||
cameras: <FaVideo className="size-4" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
function System() {
|
function System() {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@@ -108,66 +98,43 @@ function System() {
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
)}
|
)}
|
||||||
{isMobile ? (
|
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
||||||
<div className="w-[calc(50%-1rem)]">
|
<div className="flex flex-row">
|
||||||
<OverflowStrip
|
<ToggleGroup
|
||||||
items={metrics.map((item) => (
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
<Toggle
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={pageToggle}
|
||||||
|
onValueChange={(value: SystemMetric) => {
|
||||||
|
if (value) {
|
||||||
|
setPageToggle(value);
|
||||||
|
}
|
||||||
|
}} // don't allow the severity to be unselected
|
||||||
|
>
|
||||||
|
{Object.values(metrics).map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
key={item}
|
key={item}
|
||||||
className={cn(
|
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
"shrink-0 rounded-md px-3 py-4",
|
value={item}
|
||||||
pageToggle != item && "*:text-muted-foreground",
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
pressed={pageToggle == item}
|
|
||||||
onPressedChange={() => setPageToggle(item)}
|
|
||||||
aria-label={t("selectItem", {
|
aria-label={t("selectItem", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
item: t(item + ".title"),
|
item: t(item + ".title"),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{metricIcons[item]}
|
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||||
</Toggle>
|
{item == "general" && <LuActivity className="size-4" />}
|
||||||
))}
|
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||||
activeIndex={metrics.indexOf(pageToggle)}
|
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||||
gapClassName="gap-0.5"
|
{item == "cameras" && <FaVideo className="size-4" />}
|
||||||
showAllLabel={t("showAllTabs")}
|
{isDesktop && (
|
||||||
showLessLabel={t("showLessTabs")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="whitespace-nowrap">
|
|
||||||
<div className="flex flex-row">
|
|
||||||
<ToggleGroup
|
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
value={pageToggle}
|
|
||||||
onValueChange={(value: SystemMetric) => {
|
|
||||||
if (value) {
|
|
||||||
setPageToggle(value);
|
|
||||||
}
|
|
||||||
}} // don't allow the severity to be unselected
|
|
||||||
>
|
|
||||||
{Object.values(metrics).map((item) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={item}
|
|
||||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
|
||||||
value={item}
|
|
||||||
aria-label={t("selectItem", {
|
|
||||||
ns: "common",
|
|
||||||
item: t(item + ".title"),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{metricIcons[item]}
|
|
||||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||||
</ToggleGroupItem>
|
)}
|
||||||
))}
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
))}
|
||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
</ToggleGroup>
|
||||||
</div>
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
</ScrollArea>
|
</div>
|
||||||
)}
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="flex h-full items-center">
|
<div className="flex h-full items-center">
|
||||||
{pageToggle == "health" && (
|
{pageToggle == "health" && (
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export type WizardFormData = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
brandTemplate?: CameraBrand;
|
brandTemplate?: CameraBrand;
|
||||||
|
reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
|
||||||
customUrl?: string;
|
customUrl?: string;
|
||||||
streams?: StreamConfig[];
|
streams?: StreamConfig[];
|
||||||
probeMode?: boolean; // true for probe, false for manual
|
probeMode?: boolean; // true for probe, false for manual
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type StreamIssueInput = {
|
|||||||
url: string;
|
url: string;
|
||||||
roles: StreamRole[];
|
roles: StreamRole[];
|
||||||
brand?: CameraBrand;
|
brand?: CameraBrand;
|
||||||
|
reolinkProtocol?: "http-flv" | "rtsp" | null;
|
||||||
useFfmpeg?: boolean;
|
useFfmpeg?: boolean;
|
||||||
restream?: boolean;
|
restream?: boolean;
|
||||||
testResult?: TestResult;
|
testResult?: TestResult;
|
||||||
@@ -96,7 +97,7 @@ export function getStreamIssues(
|
|||||||
|
|
||||||
if (input.brand === "reolink") {
|
if (input.brand === "reolink") {
|
||||||
const streamUrl = input.url.toLowerCase();
|
const streamUrl = input.url.toLowerCase();
|
||||||
if (streamUrl.startsWith("rtsp://")) {
|
if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") {
|
||||||
result.push({
|
result.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
rule: "reolink-rtsp",
|
rule: "reolink-rtsp",
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="relative flex h-11 items-center justify-between">
|
<div className="relative flex h-11 items-center justify-between">
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
<div className="w-[calc(50%-1rem)]">
|
<div className="w-[45%]">
|
||||||
<CameraGroupSelector />
|
<CameraGroupSelector />
|
||||||
</div>
|
</div>
|
||||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user