mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-07 15:22:48 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b0dff949 | ||
|
|
6eebd4234c | ||
|
|
945de037a5 | ||
|
|
6b1e084fdc | ||
|
|
9ab52c2be6 | ||
|
|
dab0d85830 |
@@ -53,6 +53,14 @@ go2rtc:
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### Can I add a privacy mask to hide part of my camera's view?
|
||||
|
||||
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
|
||||
|
||||
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
|
||||
+3
-7
@@ -77,7 +77,7 @@ from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.output.output import OutputProcess
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.record.cleanup import RecordingCleanup
|
||||
from frigate.record.export import migrate_exports
|
||||
@@ -166,11 +166,7 @@ class FrigateApp:
|
||||
# create camera_metrics
|
||||
for camera_name in self.config.cameras.keys():
|
||||
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[camera_name] = PTZMetrics(
|
||||
autotracker_enabled=self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.enabled
|
||||
)
|
||||
self.ptz_metrics[camera_name] = PTZMetrics()
|
||||
|
||||
def init_queues(self) -> None:
|
||||
# Queue for cameras to push tracked objects to
|
||||
@@ -443,7 +439,7 @@ class FrigateApp:
|
||||
)
|
||||
|
||||
def start_ptz_autotracker(self) -> None:
|
||||
self.ptz_autotracker_thread = PtzAutoTrackerThread(
|
||||
self.ptz_autotracker_thread = PtzAutoTracker(
|
||||
self.config,
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
|
||||
@@ -43,8 +43,6 @@ class CameraMetrics:
|
||||
|
||||
|
||||
class PTZMetrics:
|
||||
autotracker_enabled: Synchronized
|
||||
|
||||
start_time: Synchronized
|
||||
stop_time: Synchronized
|
||||
frame_time: Synchronized
|
||||
@@ -52,13 +50,10 @@ class PTZMetrics:
|
||||
max_zoom: Synchronized
|
||||
min_zoom: Synchronized
|
||||
|
||||
tracking_active: Event
|
||||
motor_stopped: Event
|
||||
reset: Event
|
||||
|
||||
def __init__(self, *, autotracker_enabled: bool):
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.start_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]
|
||||
@@ -66,7 +61,6 @@ class PTZMetrics:
|
||||
self.max_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.reset = mp.Event()
|
||||
|
||||
|
||||
@@ -120,9 +120,7 @@ class CameraMaintainer(threading.Thread):
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics(
|
||||
autotracker_enabled=config.onvif.autotracking.enabled
|
||||
)
|
||||
self.ptz_metrics[name] = PTZMetrics()
|
||||
self.region_grids[name] = get_camera_regions_grid(
|
||||
name,
|
||||
config.detect,
|
||||
|
||||
+9
-13
@@ -16,7 +16,7 @@ from frigate.config import (
|
||||
ZoomingModeEnum,
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
from frigate.track.tracked_object import TrackedObject
|
||||
from frigate.util.image import (
|
||||
SharedMemoryFrameManager,
|
||||
@@ -35,7 +35,7 @@ class CameraState:
|
||||
name: str,
|
||||
config: FrigateConfig,
|
||||
frame_manager: SharedMemoryFrameManager,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
ptz_autotracker_thread: PtzAutoTracker,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
@@ -115,17 +115,13 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
|
||||
self.name
|
||||
)
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
|
||||
and self.ptz_autotracker_thread.tracked_object[self.name]
|
||||
is not None
|
||||
and obj["id"]
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
|
||||
self.name
|
||||
].obj_data["id"] # type: ignore[attr-defined]
|
||||
].obj_data["id"]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
@@ -138,9 +134,9 @@ class CameraState:
|
||||
and self.camera_config.detect.width is not None
|
||||
and self.camera_config.detect.height is not None
|
||||
):
|
||||
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
|
||||
self.name
|
||||
]["max_target_box"] # type: ignore[index]
|
||||
max_target_box = calculate_max_target_box(
|
||||
self.camera_config.onvif.autotracking.zoom_factor
|
||||
)
|
||||
side_length = max_target_box * (
|
||||
max(
|
||||
self.camera_config.detect.width,
|
||||
|
||||
@@ -759,15 +759,13 @@ class Dispatcher:
|
||||
"Autotracking must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
if not ptz_autotracker_settings.enabled:
|
||||
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
|
||||
ptz_autotracker_settings.enabled = True
|
||||
elif payload == "OFF":
|
||||
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
if ptz_autotracker_settings.enabled:
|
||||
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
|
||||
ptz_autotracker_settings.enabled = False
|
||||
|
||||
|
||||
+35
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import selectors
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -358,7 +359,7 @@ class MqttClient(Communicator):
|
||||
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
|
||||
while not message_info.is_published() and time.monotonic() < deadline:
|
||||
if (
|
||||
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
!= mqtt.MQTT_ERR_SUCCESS
|
||||
):
|
||||
break
|
||||
@@ -368,6 +369,37 @@ class MqttClient(Communicator):
|
||||
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:
|
||||
# The worker owns all socket I/O so reconnect, subscribe, and publish
|
||||
# ordering stays serialized in one place.
|
||||
@@ -384,7 +416,7 @@ class MqttClient(Communicator):
|
||||
|
||||
assert self.client is not None
|
||||
try:
|
||||
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT)
|
||||
result = self._loop_client(timeout=MQTT_LOOP_TIMEOUT)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
logger.warning("MQTT loop error: %s", err)
|
||||
self._schedule_reconnect()
|
||||
@@ -617,7 +649,7 @@ class MqttClient(Communicator):
|
||||
return
|
||||
|
||||
try:
|
||||
result = self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
result = self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
logger.warning("MQTT publish wait failed: %s", err)
|
||||
self._schedule_reconnect()
|
||||
|
||||
@@ -19,6 +19,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
config: RuntimeMotionConfig,
|
||||
fps: int,
|
||||
ptz_metrics: PTZMetrics | None = None,
|
||||
autotracking_enabled: bool = False,
|
||||
name: str = "improved",
|
||||
blur_radius: int = 1,
|
||||
interpolation: int = cv2.INTER_NEAREST,
|
||||
@@ -45,6 +46,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.contrast_values[:, 1:2] = 255
|
||||
self.contrast_values_index = 0
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.autotracking_enabled = autotracking_enabled
|
||||
self.last_stop_time: float | None = None
|
||||
|
||||
def is_calibrating(self) -> bool:
|
||||
@@ -59,8 +61,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
# if ptz motor is moving from autotracking, quickly return
|
||||
# a single box that is 80% of the frame
|
||||
if self.ptz_metrics is not None and (
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
and not self.ptz_metrics.motor_stopped.is_set()
|
||||
self.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
|
||||
):
|
||||
return [
|
||||
(
|
||||
@@ -162,7 +163,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
# if so, reassign the average to the current frame so we begin with a new baseline
|
||||
if self.ptz_metrics is not None and (
|
||||
# ensure we only do this for cameras with autotracking enabled
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
self.autotracking_enabled
|
||||
and self.ptz_metrics.motor_stopped.is_set()
|
||||
and (
|
||||
self.last_stop_time is None
|
||||
|
||||
+176
-321
@@ -23,6 +23,7 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import (
|
||||
AUTOTRACKING_MAX_AREA_RATIO,
|
||||
@@ -42,6 +43,11 @@ from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
|
||||
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):
|
||||
# Determine if the PTZ was in motion at the set frame time
|
||||
# for non ptz/autotracking cameras, this will always return False
|
||||
@@ -180,7 +186,7 @@ class PtzMotionEstimator:
|
||||
return self.coord_transformations
|
||||
|
||||
|
||||
class PtzAutoTrackerThread(threading.Thread):
|
||||
class PtzAutoTracker(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
@@ -190,56 +196,14 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
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.onvif = onvif
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.dispatcher = dispatcher
|
||||
self.stop_event = stop_event
|
||||
self.tracked_object: dict[str, object] = {}
|
||||
self.tracked_object: dict[str, TrackedObject | None] = {}
|
||||
self.tracked_object_history: dict[str, object] = {}
|
||||
self.tracked_object_metrics: dict[str, object] = {}
|
||||
self.object_types: dict[str, object] = {}
|
||||
self.required_zones: dict[str, object] = {}
|
||||
self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
|
||||
self.move_queues: dict[str, object] = {}
|
||||
self.move_queue_locks: dict[str, object] = {}
|
||||
self.move_threads: dict[str, object] = {}
|
||||
@@ -249,7 +213,6 @@ class PtzAutoTracker:
|
||||
self.intercept: dict[str, object] = {}
|
||||
self.move_coefficients: dict[str, object] = {}
|
||||
self.zoom_time: dict[str, float] = {}
|
||||
self.zoom_factor: dict[str, object] = {}
|
||||
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
@@ -277,44 +240,37 @@ class PtzAutoTracker:
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""Apply camera config updates and mirror autotracking state to ptz metrics.
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.wait(1):
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
The camera processes read autotracker_enabled rather than the config, so it
|
||||
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:
|
||||
for camera, camera_config in list(self.config.cameras.items()):
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
metrics.autotracker_enabled.value = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
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):
|
||||
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_history[camera] = deque(
|
||||
maxlen=round(camera_config.detect.fps * 1.5)
|
||||
)
|
||||
self.tracked_object_metrics[camera] = {
|
||||
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
self._reset_tracked_object_metrics(camera)
|
||||
|
||||
self.calibrating[camera] = False
|
||||
self.move_metrics[camera] = []
|
||||
@@ -326,43 +282,22 @@ class PtzAutoTracker:
|
||||
|
||||
# handle onvif constructor failing due to no connection
|
||||
if camera not in self.onvif.cams:
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: onvif connection failed"
|
||||
)
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
self._disable(camera, "onvif connection failed")
|
||||
return
|
||||
|
||||
if not self.onvif.cams[camera]["init"]:
|
||||
if not await self.onvif._init_onvif(camera):
|
||||
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
|
||||
self._disable(camera, "Unable to initialize onvif")
|
||||
return
|
||||
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
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
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if not (
|
||||
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
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
@@ -374,59 +309,41 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
if len(camera_config.onvif.autotracking.movement_weights) == 6:
|
||||
camera_config.onvif.autotracking.movement_weights = [
|
||||
float(val)
|
||||
for val in camera_config.onvif.autotracking.movement_weights
|
||||
]
|
||||
self.ptz_metrics[
|
||||
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."
|
||||
)
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
|
||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||
self.autotracker_init[camera] = True
|
||||
|
||||
def _write_config(self, camera):
|
||||
config_file = find_config_file()
|
||||
def _disable(self, camera: str, reason: str) -> None:
|
||||
logger.warning(f"Disabling autotracking for {camera}: {reason}")
|
||||
autotracking_config = self.config.cameras[camera].onvif.autotracking
|
||||
autotracking_config.enabled = False
|
||||
|
||||
logger.debug(
|
||||
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
|
||||
# the camera process holds its own copy of the config
|
||||
self.dispatcher.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||
autotracking_config,
|
||||
)
|
||||
|
||||
update_yaml_file_bulk(
|
||||
config_file,
|
||||
{
|
||||
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
||||
camera
|
||||
].onvif.autotracking.movement_weights
|
||||
},
|
||||
)
|
||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
||||
self.tracked_object_metrics[camera] = {}
|
||||
|
||||
async def _wait_until_stopped(
|
||||
self, camera: str, metrics: PTZMetrics | None = None
|
||||
) -> None:
|
||||
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):
|
||||
# move the camera from the preset in steps and measure the time it takes to move that amount
|
||||
@@ -459,8 +376,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -470,8 +386,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -488,8 +403,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -503,8 +417,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_stop_time = time.time()
|
||||
|
||||
@@ -518,8 +431,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
full_relative_stop_time = time.time()
|
||||
|
||||
@@ -531,8 +443,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
self.zoom_time[camera] = (
|
||||
full_relative_stop_time - full_relative_start_time
|
||||
@@ -558,9 +469,7 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].reset.set()
|
||||
self.ptz_metrics[camera].motor_stopped.clear()
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
for step in range(num_steps):
|
||||
pan = step_sizes[step]
|
||||
@@ -569,9 +478,7 @@ class PtzAutoTracker:
|
||||
start_time = time.time()
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
stop_time = time.time()
|
||||
|
||||
self.move_metrics[camera].append(
|
||||
@@ -590,9 +497,7 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].reset.set()
|
||||
self.ptz_metrics[camera].motor_stopped.clear()
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
logger.info(
|
||||
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
|
||||
@@ -668,7 +573,14 @@ class PtzAutoTracker:
|
||||
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
|
||||
)
|
||||
|
||||
self._write_config(camera)
|
||||
update_yaml_file_bulk(
|
||||
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):
|
||||
combined_movement = abs(pan) + abs(tilt)
|
||||
@@ -682,6 +594,18 @@ class PtzAutoTracker:
|
||||
[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 remove_outliers(data):
|
||||
areas = [item["area"] for item in data]
|
||||
@@ -703,19 +627,20 @@ class PtzAutoTracker:
|
||||
return filtered_data
|
||||
|
||||
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_height = camera_config.frame_shape[0]
|
||||
|
||||
# Extract areas and calculate weighted average
|
||||
# grab the largest dimension of the bounding box and create a square from that
|
||||
# Filter out the initial frame and use a recent time window
|
||||
# Use a recent time window
|
||||
current_time = obj.obj_data["frame_time"]
|
||||
time_window = 1.5 # seconds
|
||||
history = [
|
||||
entry
|
||||
for entry in self.tracked_object_history[camera]
|
||||
if not entry.get("is_initial_frame", False)
|
||||
and current_time - entry["frame_time"] <= time_window
|
||||
if current_time - entry["frame_time"] <= time_window
|
||||
]
|
||||
if not history: # Fallback to latest if no recent entries
|
||||
history = [self.tracked_object_history[camera][-1]]
|
||||
@@ -748,33 +673,29 @@ class PtzAutoTracker:
|
||||
)
|
||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
||||
|
||||
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq(
|
||||
X.reshape(-1, 1), y, rcond=None
|
||||
)[0]
|
||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
||||
0
|
||||
]
|
||||
else:
|
||||
self.tracked_object_metrics[camera]["area_coefficients"] = np.array([0])
|
||||
tom["area_coefficients"] = np.array([0])
|
||||
|
||||
weights = np.arange(1, len(filtered_areas) + 1)
|
||||
weighted_area = np.average(
|
||||
[item["area"] for item in filtered_areas], weights=weights
|
||||
)
|
||||
|
||||
self.tracked_object_metrics[camera]["target_box"] = (
|
||||
tom["target_box"] = (
|
||||
weighted_area / (camera_width * camera_height)
|
||||
) ** self.zoom_factor[camera]
|
||||
) ** zoom_factor
|
||||
|
||||
if "original_target_box" not in self.tracked_object_metrics[camera]:
|
||||
self.tracked_object_metrics[camera]["original_target_box"] = (
|
||||
self.tracked_object_metrics[camera]["target_box"]
|
||||
)
|
||||
if "original_target_box" not in tom:
|
||||
tom["original_target_box"] = tom["target_box"]
|
||||
|
||||
(
|
||||
self.tracked_object_metrics[camera]["valid_velocity"],
|
||||
self.tracked_object_metrics[camera]["velocity"],
|
||||
tom["valid_velocity"],
|
||||
tom["velocity"],
|
||||
) = self._get_valid_velocity(camera, obj)
|
||||
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold(
|
||||
camera, obj
|
||||
)
|
||||
tom["distance"] = self._get_distance_threshold(camera, obj)
|
||||
|
||||
centroid_distance = np.linalg.norm(
|
||||
[
|
||||
@@ -785,9 +706,7 @@ class PtzAutoTracker:
|
||||
|
||||
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
|
||||
|
||||
self.tracked_object_metrics[camera]["below_distance_threshold"] = (
|
||||
centroid_distance < self.tracked_object_metrics[camera]["distance"]
|
||||
)
|
||||
tom["below_distance_threshold"] = centroid_distance < tom["distance"]
|
||||
|
||||
async def _process_move_queue(self, camera):
|
||||
move_queue = self.move_queues[camera]
|
||||
@@ -833,16 +752,12 @@ class PtzAutoTracker:
|
||||
if pan != 0 or tilt != 0:
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
logger.debug(
|
||||
@@ -878,41 +793,20 @@ class PtzAutoTracker:
|
||||
await move_queue.get()
|
||||
|
||||
def _enqueue_move(self, camera, frame_time, 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
|
||||
pan, tilt, zoom = (np.clip(value, -1, 1) for value in (pan, tilt, zoom))
|
||||
|
||||
if (
|
||||
frame_time > self.ptz_metrics[camera].start_time.value
|
||||
(pan != 0 or tilt != 0 or zoom != 0)
|
||||
and frame_time > self.ptz_metrics[camera].start_time.value
|
||||
and frame_time > self.ptz_metrics[camera].stop_time.value
|
||||
and not self.move_queue_locks[camera].locked()
|
||||
):
|
||||
# we can split up any large moves caused by velocity estimated movements if necessary
|
||||
# get an excess amount and assign it instead of 0 below
|
||||
while pan != 0 or tilt != 0 or zoom != 0:
|
||||
pan, _ = split_value(pan)
|
||||
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
|
||||
logger.debug(
|
||||
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
|
||||
)
|
||||
self.onvif.loop.call_soon_threadsafe(
|
||||
self.move_queues[camera].put_nowait, (frame_time, pan, tilt, zoom)
|
||||
)
|
||||
|
||||
def _touching_frame_edges(self, camera, box):
|
||||
camera_config = self.config.cameras[camera]
|
||||
@@ -1046,16 +940,17 @@ class PtzAutoTracker:
|
||||
|
||||
return distance_threshold
|
||||
|
||||
def _should_zoom_in(
|
||||
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
|
||||
):
|
||||
def _should_zoom_in(self, camera: str, box, predicted_time):
|
||||
# returns True if we should zoom in, False if we should zoom out, None to do nothing
|
||||
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_height = camera_config.frame_shape[0]
|
||||
camera_fps = camera_config.detect.fps
|
||||
|
||||
average_velocity = self.tracked_object_metrics[camera]["velocity"]
|
||||
average_velocity = tom["velocity"]
|
||||
|
||||
bb_left, bb_top, bb_right, bb_bottom = box
|
||||
|
||||
@@ -1073,13 +968,11 @@ class PtzAutoTracker:
|
||||
touching_frame_edges = self._touching_frame_edges(camera, box)
|
||||
|
||||
# make sure object is centered in the frame
|
||||
below_distance_threshold = self.tracked_object_metrics[camera][
|
||||
"below_distance_threshold"
|
||||
]
|
||||
below_distance_threshold = tom["below_distance_threshold"]
|
||||
|
||||
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
|
||||
self.zoom_factor[camera] + 0.1
|
||||
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1)
|
||||
zoom_factor + 0.1
|
||||
) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
|
||||
|
||||
# ensure object is not moving quickly
|
||||
below_velocity_threshold = np.all(
|
||||
@@ -1087,30 +980,16 @@ class PtzAutoTracker:
|
||||
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
|
||||
) or np.all(average_velocity == 0)
|
||||
|
||||
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
|
||||
)
|
||||
calculated_target_box = self._predict_target_box(camera, predicted_time)
|
||||
|
||||
below_area_threshold = (
|
||||
calculated_target_box
|
||||
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||
)
|
||||
below_area_threshold = calculated_target_box < max_target_box
|
||||
|
||||
# introduce some hysteresis to prevent a yo-yo zooming effect
|
||||
zoom_out_hysteresis = (
|
||||
calculated_target_box
|
||||
> self.tracked_object_metrics[camera]["max_target_box"]
|
||||
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||
calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||
)
|
||||
zoom_in_hysteresis = (
|
||||
calculated_target_box
|
||||
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||
calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||
)
|
||||
|
||||
at_max_zoom = (
|
||||
@@ -1122,31 +1001,29 @@ class PtzAutoTracker:
|
||||
== self.ptz_metrics[camera].min_zoom.value
|
||||
)
|
||||
|
||||
# debug zooming
|
||||
if debug_zooming:
|
||||
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}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
|
||||
)
|
||||
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']}"
|
||||
)
|
||||
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)}"
|
||||
)
|
||||
logger.debug(
|
||||
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 max zoom: {at_max_zoom}")
|
||||
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
|
||||
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']}"
|
||||
)
|
||||
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']}"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {tom['target_box']}, calculated: {calculated_target_box}, max: {max_target_box}"
|
||||
)
|
||||
logger.debug(
|
||||
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(
|
||||
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 max zoom: {at_max_zoom}")
|
||||
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
|
||||
logger.debug(
|
||||
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: 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']}"
|
||||
)
|
||||
|
||||
# Zoom in conditions (and)
|
||||
if (
|
||||
@@ -1237,7 +1114,7 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
zoom = self._get_zoom_amount(
|
||||
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
|
||||
camera, obj, predicted_box, predicted_movement_time
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -1298,9 +1175,11 @@ class PtzAutoTracker:
|
||||
obj: TrackedObject,
|
||||
predicted_box,
|
||||
predicted_movement_time,
|
||||
debug_zoom=True,
|
||||
):
|
||||
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
|
||||
camera_width = camera_config.frame_shape[1]
|
||||
@@ -1317,16 +1196,12 @@ class PtzAutoTracker:
|
||||
# absolute zooming separately from pan/tilt
|
||||
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
|
||||
# don't zoom on initial move
|
||||
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||
if "target_box" not in tom:
|
||||
zoom = current_zoom_level
|
||||
else:
|
||||
if (
|
||||
result := self._should_zoom_in(
|
||||
camera,
|
||||
obj,
|
||||
obj.obj_data["box"],
|
||||
predicted_movement_time,
|
||||
debug_zoom,
|
||||
camera, obj.obj_data["box"], predicted_movement_time
|
||||
)
|
||||
) is not None:
|
||||
# divide zoom in 10 increments and always zoom out more than in
|
||||
@@ -1342,46 +1217,35 @@ class PtzAutoTracker:
|
||||
# relative zooming concurrently with pan/tilt
|
||||
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
|
||||
# this is our initial zoom in on a new object
|
||||
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||
zoom = target_box ** self.zoom_factor[camera]
|
||||
if zoom > self.tracked_object_metrics[camera]["max_target_box"]:
|
||||
if "target_box" not in tom:
|
||||
zoom = target_box**zoom_factor
|
||||
if zoom > max_target_box:
|
||||
zoom = -(1 - zoom)
|
||||
logger.debug(
|
||||
f"{camera}: target box: {target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}, calc zoom: {zoom}"
|
||||
f"{camera}: target box: {target_box}, max: {max_target_box}, calc zoom: {zoom}"
|
||||
)
|
||||
else:
|
||||
if (
|
||||
result := self._should_zoom_in(
|
||||
camera,
|
||||
obj,
|
||||
predicted_box
|
||||
if camera_config.onvif.autotracking.movement_weights
|
||||
else obj.obj_data["box"],
|
||||
predicted_movement_time,
|
||||
debug_zoom,
|
||||
)
|
||||
) is not None:
|
||||
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(
|
||||
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
|
||||
ratio = (
|
||||
self.tracked_object_metrics[camera]["max_target_box"]
|
||||
/ calculated_target_box
|
||||
calculated_target_box = self._predict_target_box(
|
||||
camera, predicted_movement_time
|
||||
)
|
||||
if predicted_movement_time:
|
||||
logger.debug(
|
||||
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {tom['target_box']}, calculated box: {calculated_target_box}"
|
||||
)
|
||||
# zoom value
|
||||
ratio = max_target_box / calculated_target_box
|
||||
zoom = (ratio - 1) / (ratio + 1)
|
||||
logger.debug(
|
||||
f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}"
|
||||
f"{camera}: limit: {max_target_box}, ratio: {ratio} zoom calculation: {zoom}"
|
||||
)
|
||||
if not result:
|
||||
# zoom out with special condition if zooming out because of velocity, edges, etc.
|
||||
@@ -1394,9 +1258,6 @@ class PtzAutoTracker:
|
||||
|
||||
return zoom
|
||||
|
||||
def is_autotracking(self, camera: str):
|
||||
return self.tracked_object[camera] is not None
|
||||
|
||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||
if camera not in self.config.cameras:
|
||||
return
|
||||
@@ -1420,8 +1281,9 @@ class PtzAutoTracker:
|
||||
# new object
|
||||
self.tracked_object[camera] is None
|
||||
and obj.camera_config.name == camera
|
||||
and obj.obj_data["label"] in self.object_types[camera]
|
||||
and set(obj.entered_zones) & set(self.required_zones[camera])
|
||||
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
|
||||
and set(obj.entered_zones)
|
||||
& set(camera_config.onvif.autotracking.required_zones)
|
||||
and not obj.previous["false_positive"]
|
||||
and not obj.false_positive
|
||||
and not self.tracked_object_history[camera]
|
||||
@@ -1430,7 +1292,6 @@ class PtzAutoTracker:
|
||||
logger.debug(
|
||||
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(
|
||||
f"{camera}/ptz_autotracker/active", "ON", retain=False
|
||||
)
|
||||
@@ -1479,7 +1340,7 @@ class PtzAutoTracker:
|
||||
# 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
|
||||
and obj.camera_config.name == camera
|
||||
and obj.obj_data["label"] in self.object_types[camera]
|
||||
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
|
||||
and not obj.previous["false_positive"]
|
||||
and not obj.false_positive
|
||||
and self.tracked_object_history[camera]
|
||||
@@ -1515,10 +1376,7 @@ class PtzAutoTracker:
|
||||
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
|
||||
)
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_metrics[camera] = {
|
||||
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
self._reset_tracked_object_metrics(camera)
|
||||
|
||||
async def camera_maintenance(self, camera):
|
||||
# bail and don't check anything if we're not set up yet, calibrating, or
|
||||
@@ -1555,8 +1413,7 @@ class PtzAutoTracker:
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_history[camera].clear()
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
logger.debug(
|
||||
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
|
||||
)
|
||||
@@ -1566,10 +1423,8 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
# update stored zoom level from preset
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(
|
||||
f"{camera}/ptz_autotracker/active", "OFF", retain=False
|
||||
)
|
||||
|
||||
+205
-304
@@ -10,8 +10,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy
|
||||
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from onvif import ONVIFCamera, ONVIFService
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
@@ -41,6 +40,14 @@ class OnvifCommandEnum(str, Enum):
|
||||
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:
|
||||
ptz_metrics: dict[str, PTZMetrics]
|
||||
|
||||
@@ -61,14 +68,6 @@ class OnvifController:
|
||||
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
||||
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,
|
||||
self.config.cameras,
|
||||
@@ -92,8 +91,9 @@ class OnvifController:
|
||||
|
||||
async def _init_cameras(self) -> None:
|
||||
"""Initialize all configured cameras."""
|
||||
for cam_name in self.camera_configs:
|
||||
await self._init_single_camera(cam_name)
|
||||
for cam_name, cam in list(self.config.cameras.items()):
|
||||
if cam.enabled and cam.onvif.host:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _poll_config_updates(self) -> None:
|
||||
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
||||
@@ -129,13 +129,9 @@ class OnvifController:
|
||||
|
||||
async def _remove_camera(self, cam_name: str) -> None:
|
||||
"""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")
|
||||
await self._close_camera(cam_name)
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
self.status_locks.pop(cam_name, None)
|
||||
|
||||
@@ -143,25 +139,14 @@ class OnvifController:
|
||||
"""Re-initialize a camera after config change."""
|
||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||
|
||||
# close existing session before re-init
|
||||
# close existing session and reset state before re-init
|
||||
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.failed_cams.pop(cam_name, None)
|
||||
|
||||
await self._init_single_camera(cam_name)
|
||||
cam = self.config.cameras.get(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:
|
||||
"""Initialize a single camera by name.
|
||||
@@ -172,11 +157,12 @@ class OnvifController:
|
||||
Returns:
|
||||
bool: True if initialization succeeded, False otherwise
|
||||
"""
|
||||
if cam_name not in self.camera_configs:
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if cam is None:
|
||||
logger.error(f"No configuration found for camera {cam_name}")
|
||||
return False
|
||||
|
||||
cam = self.camera_configs[cam_name]
|
||||
self.status_locks.setdefault(cam_name, asyncio.Lock())
|
||||
try:
|
||||
self.cams[cam_name] = {
|
||||
"onvif": ONVIFCamera(
|
||||
@@ -195,12 +181,11 @@ class OnvifController:
|
||||
"profiles": [],
|
||||
}
|
||||
return True
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
|
||||
# track initial failures
|
||||
self.failed_cams[cam_name] = {
|
||||
"retry_attempts": 0,
|
||||
"last_error": str(e),
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
return False
|
||||
@@ -211,7 +196,8 @@ class OnvifController:
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
cam = self.cams[camera_name]
|
||||
onvif: ONVIFCamera = cam["onvif"]
|
||||
try:
|
||||
await onvif.update_xaddrs()
|
||||
except Exception as e:
|
||||
@@ -226,7 +212,7 @@ class OnvifController:
|
||||
# this will fire an exception if camera is not a ptz
|
||||
capabilities = onvif.get_definition("ptz")
|
||||
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -235,7 +221,7 @@ class OnvifController:
|
||||
try:
|
||||
profiles = await media.GetProfiles()
|
||||
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -254,7 +240,7 @@ class OnvifController:
|
||||
]
|
||||
|
||||
# store available profiles for API response and log for debugging
|
||||
self.cams[camera_name]["profiles"] = [
|
||||
cam["profiles"] = [
|
||||
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
||||
for p in valid_profiles
|
||||
]
|
||||
@@ -267,19 +253,18 @@ class OnvifController:
|
||||
)
|
||||
|
||||
configured_profile = camera_config.onvif.profile
|
||||
profile = None
|
||||
|
||||
if configured_profile is not None:
|
||||
# match by exact token first, then by name
|
||||
for p in valid_profiles:
|
||||
if p.token == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
for p in valid_profiles:
|
||||
if getattr(p, "Name", None) == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
profile = next(
|
||||
(
|
||||
p
|
||||
for key in ("token", "Name")
|
||||
for p in valid_profiles
|
||||
if getattr(p, key, None) == configured_profile
|
||||
),
|
||||
None,
|
||||
)
|
||||
if profile is None:
|
||||
available = [
|
||||
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
||||
@@ -304,39 +289,30 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
|
||||
# get the PTZ config for the profile
|
||||
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
|
||||
configs = profile.PTZConfiguration
|
||||
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
|
||||
|
||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||
self.cams[camera_name]["ptz"] = ptz
|
||||
cam["ptz"] = ptz
|
||||
|
||||
try:
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||
imaging = None
|
||||
self.cams[camera_name]["imaging"] = imaging
|
||||
cam["imaging"] = imaging
|
||||
try:
|
||||
video_sources = await media.GetVideoSources()
|
||||
if video_sources and len(video_sources) > 0:
|
||||
self.cams[camera_name]["video_source_token"] = video_sources[0].token
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
cam["video_source_token"] = video_sources[0].token
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
|
||||
self.cams[camera_name]["video_source_token"] = None
|
||||
cam["video_source_token"] = None
|
||||
|
||||
# setup continuous moving request
|
||||
move_request = ptz.create_type("ContinuousMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["move_request"] = move_request
|
||||
cam["move_request"] = move_request
|
||||
|
||||
# get PTZ configuration options for feature detection and relative movement
|
||||
ptz_config = None
|
||||
@@ -349,7 +325,7 @@ class OnvifController:
|
||||
logger.debug(
|
||||
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
||||
)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
||||
)
|
||||
@@ -375,18 +351,6 @@ class OnvifController:
|
||||
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
|
||||
if (
|
||||
fov_space_id is not None
|
||||
@@ -395,9 +359,7 @@ class OnvifController:
|
||||
# one-off GetStatus to seed Translation field
|
||||
status = None
|
||||
try:
|
||||
one_off_status_request = ptz.create_type("GetStatus")
|
||||
one_off_status_request.ProfileToken = profile.token
|
||||
status = await ptz.GetStatus(one_off_status_request)
|
||||
status = await ptz.GetStatus({"ProfileToken": profile.token})
|
||||
logger.debug(f"Onvif status for {camera_name}: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
||||
@@ -424,7 +386,7 @@ class OnvifController:
|
||||
# configure zoom on relative move request
|
||||
if (
|
||||
autotracking_enabled
|
||||
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||
and autotracking_config.zooming == ZoomingModeEnum.relative
|
||||
):
|
||||
zoom_space_id = next(
|
||||
(
|
||||
@@ -463,21 +425,16 @@ class OnvifController:
|
||||
)
|
||||
|
||||
if rel_move_request.Speed is None:
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after setup: {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
|
||||
cam["relative_move_request"] = rel_move_request
|
||||
|
||||
# setup existing presets
|
||||
try:
|
||||
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
||||
presets = []
|
||||
|
||||
@@ -490,7 +447,7 @@ class OnvifController:
|
||||
preset_name = preset_name.encode("latin-1").decode("utf-8")
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
pass
|
||||
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
|
||||
cam["presets"][preset_name.lower()] = preset["token"]
|
||||
|
||||
# get list of supported features
|
||||
supported_features = []
|
||||
@@ -504,64 +461,40 @@ class OnvifController:
|
||||
if configs.DefaultRelativePanTiltTranslationSpace:
|
||||
supported_features.append("pt-r")
|
||||
|
||||
spaces = getattr(ptz_config, "Spaces", None)
|
||||
|
||||
if configs.DefaultRelativeZoomTranslationSpace:
|
||||
supported_features.append("zoom-r")
|
||||
if ptz_config is not None:
|
||||
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 getattr(spaces, "RelativeZoomTranslationSpace", None):
|
||||
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
|
||||
|
||||
if configs.DefaultAbsoluteZoomPositionSpace:
|
||||
supported_features.append("zoom-a")
|
||||
if ptz_config is not None:
|
||||
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}"
|
||||
)
|
||||
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
|
||||
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
|
||||
|
||||
# disable autotracking zoom if required ranges are unavailable
|
||||
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||
if "relative_zoom_range" not in self.cams[camera_name]:
|
||||
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 zoom needs the range for its mode, and get_camera_status
|
||||
# reads the absolute range in both modes
|
||||
zooming = autotracking_config.zooming
|
||||
if zooming != ZoomingModeEnum.disabled and (
|
||||
"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}: {zooming.value} zoom range unavailable"
|
||||
)
|
||||
|
||||
if cam["video_source_token"] is not None and imaging is not None:
|
||||
try:
|
||||
imaging_capabilities = await imaging.GetImagingSettings(
|
||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||
{"VideoSourceToken": cam["video_source_token"]}
|
||||
)
|
||||
if (
|
||||
hasattr(imaging_capabilities, "Focus")
|
||||
and imaging_capabilities.Focus
|
||||
):
|
||||
supported_features.append("focus")
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
||||
|
||||
# detect FOV relative movement support
|
||||
@@ -570,17 +503,18 @@ class OnvifController:
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
supported_features.append("pt-r-fov")
|
||||
self.cams[camera_name]["relative_fov_range"] = (
|
||||
cam["relative_fov_range"] = (
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
||||
)
|
||||
|
||||
self.cams[camera_name]["features"] = supported_features
|
||||
self.cams[camera_name]["init"] = True
|
||||
cam["features"] = supported_features
|
||||
cam["init"] = True
|
||||
return True
|
||||
|
||||
async def _stop(self, camera_name: str) -> None:
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
await self.cams[camera_name]["ptz"].Stop(
|
||||
cam = self.cams[camera_name]
|
||||
move_request = cam["move_request"]
|
||||
await cam["ptz"].Stop(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PanTilt": True,
|
||||
@@ -588,88 +522,75 @@ class OnvifController:
|
||||
}
|
||||
)
|
||||
if (
|
||||
"focus" in self.cams[camera_name]["features"]
|
||||
and self.cams[camera_name]["video_source_token"]
|
||||
and self.cams[camera_name]["imaging"] is not None
|
||||
"focus" in cam["features"]
|
||||
and cam["video_source_token"]
|
||||
and cam["imaging"] is not None
|
||||
):
|
||||
try:
|
||||
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||
stop_request.VideoSourceToken = self.cams[camera_name][
|
||||
"video_source_token"
|
||||
]
|
||||
await self.cams[camera_name]["imaging"].Stop(stop_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
stop_request = cam["imaging"].create_type("Stop")
|
||||
stop_request.VideoSourceToken = cam["video_source_token"]
|
||||
await cam["imaging"].Stop(stop_request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "pt" not in self.cams[camera_name]["features"]:
|
||||
if "pt" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
cam["active"] = True
|
||||
move_request = cam["move_request"]
|
||||
|
||||
if command == OnvifCommandEnum.move_left:
|
||||
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,
|
||||
}
|
||||
}
|
||||
x, y = PAN_TILT_VELOCITY[command]
|
||||
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
|
||||
|
||||
try:
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
await cam["ptz"].ContinuousMove(move_request)
|
||||
except Exception as 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:
|
||||
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if "pt-r-fov" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||
return
|
||||
|
||||
metrics = self.ptz_metrics.get(camera_name)
|
||||
camera_config = self.config.cameras.get(camera_name)
|
||||
|
||||
if metrics is None:
|
||||
if metrics is None or camera_config is None:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
|
||||
# only track start_time for autotracking
|
||||
if metrics.autotracker_enabled.value:
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
metrics.motor_stopped.clear()
|
||||
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
||||
metrics.start_time.value = metrics.frame_time.value
|
||||
metrics.stop_time.value = 0
|
||||
|
||||
move_request = self.cams[camera_name]["relative_move_request"]
|
||||
move_request = cam["relative_move_request"]
|
||||
|
||||
# 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
|
||||
@@ -677,55 +598,49 @@ class OnvifController:
|
||||
pan,
|
||||
[-1, 1],
|
||||
[
|
||||
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
|
||||
cam["relative_fov_range"]["XRange"]["Min"],
|
||||
cam["relative_fov_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
tilt = numpy.interp(
|
||||
tilt,
|
||||
[-1, 1],
|
||||
[
|
||||
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
|
||||
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
|
||||
cam["relative_fov_range"]["YRange"]["Min"],
|
||||
cam["relative_fov_range"]["YRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
}
|
||||
move_speed = {"PanTilt": {"x": speed, "y": speed}}
|
||||
|
||||
move_request.Translation.PanTilt.x = pan
|
||||
move_request.Translation.PanTilt.y = tilt
|
||||
|
||||
# include zoom if requested and camera supports relative zoom
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
"Zoom": {"x": speed},
|
||||
}
|
||||
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
|
||||
|
||||
if include_zoom:
|
||||
move_speed["Zoom"] = {"x": speed}
|
||||
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||
|
||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||
move_request.Speed = move_speed
|
||||
|
||||
await cam["ptz"].RelativeMove(move_request)
|
||||
|
||||
# reset after the move request
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
move_request.Translation.PanTilt.y = 0
|
||||
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
if include_zoom:
|
||||
del move_request["Translation"]["Zoom"]
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
preset = preset.lower()
|
||||
|
||||
if preset not in self.cams[camera_name]["presets"]:
|
||||
if preset not in cam["presets"]:
|
||||
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
||||
return
|
||||
|
||||
@@ -734,44 +649,48 @@ class OnvifController:
|
||||
if metrics is None:
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
metrics.start_time.value = 0
|
||||
metrics.stop_time.value = 0
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
preset_token = self.cams[camera_name]["presets"][preset]
|
||||
move_request = cam["move_request"]
|
||||
preset_token = cam["presets"][preset]
|
||||
|
||||
await self.cams[camera_name]["ptz"].GotoPreset(
|
||||
await cam["ptz"].GotoPreset(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "zoom" not in self.cams[camera_name]["features"]:
|
||||
if "zoom" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
cam["active"] = True
|
||||
move_request = cam["move_request"]
|
||||
|
||||
if command == OnvifCommandEnum.zoom_in:
|
||||
move_request.Velocity = {"Zoom": {"x": 0.5}}
|
||||
elif command == OnvifCommandEnum.zoom_out:
|
||||
move_request.Velocity = {"Zoom": {"x": -0.5}}
|
||||
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
await cam["ptz"].ContinuousMove(move_request)
|
||||
|
||||
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||
if "zoom-a" not in self.cams[camera_name]["features"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if "zoom-a" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
||||
return
|
||||
|
||||
@@ -782,56 +701,59 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
metrics.motor_stopped.clear()
|
||||
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
||||
metrics.start_time.value = metrics.frame_time.value
|
||||
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.
|
||||
zoom = numpy.interp(
|
||||
zoom,
|
||||
[0, 1],
|
||||
[
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_request.Speed = {"Zoom": speed}
|
||||
move_request.Position = {"Zoom": zoom}
|
||||
|
||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||
|
||||
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
|
||||
await cam["ptz"].AbsoluteMove(
|
||||
{
|
||||
"ProfileToken": cam["move_request"].ProfileToken,
|
||||
"Position": {"Zoom": zoom},
|
||||
"Speed": {"Zoom": speed},
|
||||
}
|
||||
)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if (
|
||||
"focus" not in self.cams[camera_name]["features"]
|
||||
or not self.cams[camera_name]["video_source_token"]
|
||||
or self.cams[camera_name]["imaging"] is None
|
||||
"focus" not in cam["features"]
|
||||
or not cam["video_source_token"]
|
||||
or cam["imaging"] is None
|
||||
):
|
||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["imaging"].create_type("Move")
|
||||
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
|
||||
cam["active"] = True
|
||||
move_request = cam["imaging"].create_type("Move")
|
||||
move_request.VideoSourceToken = cam["video_source_token"]
|
||||
move_request.Focus = {
|
||||
"Continuous": {
|
||||
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
|
||||
@@ -839,10 +761,10 @@ class OnvifController:
|
||||
}
|
||||
|
||||
try:
|
||||
await self.cams[camera_name]["imaging"].Move(move_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
await cam["imaging"].Move(move_request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def handle_command_async(
|
||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||
@@ -883,7 +805,7 @@ class OnvifController:
|
||||
await self._focus(camera_name, command)
|
||||
else:
|
||||
await self._move(camera_name, command)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to handle onvif command: {e}")
|
||||
|
||||
def handle_command(
|
||||
@@ -906,6 +828,15 @@ class OnvifController:
|
||||
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]:
|
||||
"""
|
||||
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
|
||||
@@ -929,60 +860,21 @@ class OnvifController:
|
||||
return {}
|
||||
|
||||
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
||||
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", []),
|
||||
}
|
||||
return self._camera_info(camera_name)
|
||||
|
||||
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
||||
success = await self._init_single_camera(camera_name)
|
||||
if not success:
|
||||
return {}
|
||||
|
||||
# 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)
|
||||
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
|
||||
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
|
||||
logger.debug(f"Resetting retry count for {camera_name} after timeout")
|
||||
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:
|
||||
remaining_time = max(
|
||||
@@ -991,8 +883,24 @@ class OnvifController:
|
||||
logger.error(
|
||||
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
|
||||
)
|
||||
return {}
|
||||
|
||||
logger.debug(f"Could not initialize ONVIF for {camera_name}")
|
||||
logger.info(
|
||||
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 {}
|
||||
|
||||
async def get_service_capabilities(self, camera_name: str) -> None:
|
||||
@@ -1000,16 +908,13 @@ class OnvifController:
|
||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||
return {}
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if not cam["init"]:
|
||||
await self._init_onvif(camera_name)
|
||||
|
||||
service_capabilities_request = self.cams[camera_name][
|
||||
"service_capabilities_request"
|
||||
]
|
||||
try:
|
||||
service_capabilities = await self.cams[camera_name][
|
||||
"ptz"
|
||||
].GetServiceCapabilities(service_capabilities_request)
|
||||
service_capabilities = await cam["ptz"].GetServiceCapabilities()
|
||||
|
||||
logger.debug(
|
||||
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
||||
@@ -1035,13 +940,16 @@ class OnvifController:
|
||||
if metrics is None or camera_config is None:
|
||||
return
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if not cam["init"]:
|
||||
if not await self._init_onvif(camera_name):
|
||||
return
|
||||
|
||||
status_request = self.cams[camera_name]["status_request"]
|
||||
try:
|
||||
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
|
||||
status = await cam["ptz"].GetStatus(
|
||||
{"ProfileToken": cam["move_request"].ProfileToken}
|
||||
)
|
||||
except Exception:
|
||||
pass # We're unsupported, that'll be reported in the next check.
|
||||
|
||||
@@ -1072,7 +980,7 @@ class OnvifController:
|
||||
if pan_tilt_status == "IDLE" and (
|
||||
zoom_status is None or zoom_status == "IDLE"
|
||||
):
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
if not metrics.motor_stopped.is_set():
|
||||
metrics.motor_stopped.set()
|
||||
|
||||
@@ -1082,7 +990,7 @@ class OnvifController:
|
||||
|
||||
metrics.stop_time.value = metrics.frame_time.value
|
||||
else:
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
if metrics.motor_stopped.is_set():
|
||||
metrics.motor_stopped.clear()
|
||||
|
||||
@@ -1098,8 +1006,8 @@ class OnvifController:
|
||||
metrics.zoom_level.value = numpy.interp(
|
||||
round(status.Position.Zoom.x, 2),
|
||||
[
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
[0, 1],
|
||||
)
|
||||
@@ -1138,7 +1046,7 @@ class OnvifController:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gracefully shut down the ONVIF controller."""
|
||||
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||
if self.loop.is_closed():
|
||||
logger.debug("ONVIF controller already closed")
|
||||
return
|
||||
|
||||
@@ -1155,13 +1063,6 @@ class OnvifController:
|
||||
|
||||
self.config_subscriber.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.call_soon_threadsafe(self.loop.stop)
|
||||
|
||||
self.loop_thread.join()
|
||||
|
||||
@@ -29,7 +29,6 @@ class TestImprovedMotionDetector(unittest.TestCase):
|
||||
|
||||
class DummyPTZ:
|
||||
def __init__(self):
|
||||
self.autotracker_enabled = _Stub(False)
|
||||
self.motor_stopped = _Stub(False)
|
||||
self.stop_time = _Stub(0)
|
||||
|
||||
|
||||
@@ -448,7 +448,6 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
|
||||
def test_publish_direct_waits_for_flush_barrier(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.loop.return_value = mqtt.MQTT_ERR_SUCCESS
|
||||
self.client.client = mock_client
|
||||
message_info = MagicMock(rc=mqtt.MQTT_ERR_SUCCESS, mid=1)
|
||||
# inflight tracking checks once, then _wait_for_publish polls
|
||||
@@ -456,11 +455,14 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
mock_client.publish.return_value = message_info
|
||||
barrier = MagicMock()
|
||||
|
||||
self.client._publish_direct(
|
||||
QueuedPublish("frigate/available", "stopped", True, barrier)
|
||||
)
|
||||
with patch.object(
|
||||
self.client, "_loop_client", return_value=mqtt.MQTT_ERR_SUCCESS
|
||||
) as mock_loop:
|
||||
self.client._publish_direct(
|
||||
QueuedPublish("frigate/available", "stopped", True, barrier)
|
||||
)
|
||||
|
||||
mock_client.loop.assert_called_once()
|
||||
mock_loop.assert_called_once()
|
||||
barrier.set.assert_called_once()
|
||||
|
||||
def test_shutdown_barrier_releases_when_publish_raises(self) -> None:
|
||||
@@ -643,9 +645,8 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
loop_calls[0] += 1
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
mock_client.loop.side_effect = loop_side_effect
|
||||
|
||||
self.client._wait_for_publish(message_info)
|
||||
with patch.object(self.client, "_loop_client", side_effect=loop_side_effect):
|
||||
self.client._wait_for_publish(message_info)
|
||||
|
||||
self.assertEqual(loop_calls[0], 1)
|
||||
self.assertIsNone(self.client.client)
|
||||
@@ -669,16 +670,20 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
|
||||
def test_mqtt_loop_worker_reconnects_on_recoverable_loop_error(self) -> None:
|
||||
self.client.client = MagicMock()
|
||||
self.client.client.loop.side_effect = OSError("socket closed")
|
||||
|
||||
def stop_after_reconnect() -> None:
|
||||
self.client._stop_event.set()
|
||||
|
||||
with patch.object(
|
||||
self.client,
|
||||
"_schedule_reconnect",
|
||||
side_effect=stop_after_reconnect,
|
||||
) as mock_schedule_reconnect:
|
||||
with (
|
||||
patch.object(
|
||||
self.client, "_loop_client", side_effect=OSError("socket closed")
|
||||
),
|
||||
patch.object(
|
||||
self.client,
|
||||
"_schedule_reconnect",
|
||||
side_effect=stop_after_reconnect,
|
||||
) as mock_schedule_reconnect,
|
||||
):
|
||||
self.client._mqtt_loop_worker()
|
||||
|
||||
mock_schedule_reconnect.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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,9 +7,8 @@ KeyError on the autotracker thread or silently keep the wrong state:
|
||||
|
||||
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
|
||||
constructed, so runtime-enabled cameras raised KeyError on lookup.
|
||||
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
|
||||
updated it when autotracking was enabled through a config save, so it stayed
|
||||
False and the tracker never built a motion estimator.
|
||||
- _disable only changed the main process config, so the camera process kept
|
||||
running its motion estimator for a camera that could not autotrack.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
@@ -17,7 +16,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
|
||||
@@ -53,8 +53,9 @@ def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
|
||||
onvif over the network. Only the config/metrics state is relevant here."""
|
||||
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
|
||||
tracker.config = _config(autotracking_enabled)
|
||||
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
|
||||
tracker.ptz_metrics = {CAMERA: PTZMetrics()}
|
||||
tracker.onvif = MagicMock()
|
||||
tracker.dispatcher = MagicMock()
|
||||
tracker.config_subscriber = MagicMock()
|
||||
tracker.autotracker_init = {}
|
||||
tracker.calibrating = {}
|
||||
@@ -83,47 +84,49 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
|
||||
tracker.onvif.get_camera_status.assert_not_called()
|
||||
|
||||
|
||||
class TestAutotrackerMetricSync(unittest.TestCase):
|
||||
def test_metric_follows_config_when_enabled_by_update(self) -> None:
|
||||
# autotracking enabled via a config save: the metric was seeded False when
|
||||
# the camera was added and nothing else updates it
|
||||
class TestAutotrackerEnqueueMove(unittest.TestCase):
|
||||
def _enqueue(self, pan: float, tilt: float, zoom: float) -> MagicMock:
|
||||
tracker = _make_tracker()
|
||||
tracker.move_queues = {CAMERA: MagicMock()}
|
||||
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)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
|
||||
tracker.check_for_updates()
|
||||
tracker._disable(CAMERA, "onvif connection failed")
|
||||
|
||||
self.assertTrue(metrics.autotracker_enabled.value)
|
||||
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
||||
self.assertFalse(autotracking.enabled)
|
||||
|
||||
def test_metric_follows_config_when_disabled_by_update(self) -> None:
|
||||
tracker = _make_tracker(autotracking_enabled=False)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
metrics.autotracker_enabled.value = True
|
||||
publish = tracker.dispatcher.config_updater.publish_update
|
||||
publish.assert_called_once()
|
||||
topic, payload = publish.call_args.args
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.autotracking)
|
||||
self.assertEqual(topic.camera, CAMERA)
|
||||
self.assertIs(payload, autotracking)
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"autotracking": [CAMERA]
|
||||
}
|
||||
tracker.check_for_updates()
|
||||
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
def test_metric_sync_skips_camera_without_metrics(self) -> None:
|
||||
# `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()
|
||||
class TestMaxTargetBox(unittest.TestCase):
|
||||
def test_follows_zoom_factor(self) -> None:
|
||||
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
||||
self.assertAlmostEqual(calculate_max_target_box(0.25), 0.6**4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
|
||||
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,
|
||||
configure autotracking afterwards. The autotracking-only request objects used to
|
||||
be created only when autotracking was enabled at init time, so the camera was left
|
||||
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.
|
||||
configure autotracking afterwards. get_camera_status skips its re-init branch when
|
||||
init is True, so everything it reads must exist whether or not autotracking was
|
||||
enabled at init time.
|
||||
|
||||
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.
|
||||
@@ -99,7 +94,6 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
controller.config = config
|
||||
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
|
||||
controller.failed_cams = {}
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.ptz_metrics = {CAMERA: MagicMock()}
|
||||
return controller
|
||||
|
||||
@@ -110,7 +104,6 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
config = _config(autotracking_enabled)
|
||||
controller = OnvifController.__new__(OnvifController)
|
||||
controller.config = config
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.failed_cams = {}
|
||||
|
||||
ptz = MagicMock()
|
||||
@@ -128,45 +121,30 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
},
|
||||
}
|
||||
}
|
||||
controller.ptz_metrics = {
|
||||
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
||||
}
|
||||
controller.ptz_metrics = {CAMERA: PTZMetrics()}
|
||||
return controller
|
||||
|
||||
|
||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||
async def test_camera_status_independent_of_autotracking_at_init(self) -> None:
|
||||
# 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):
|
||||
with self.subTest(autotracking_enabled=autotracking_enabled):
|
||||
controller = _make_controller(autotracking_enabled)
|
||||
controller.status_locks = {CAMERA: asyncio.Lock()}
|
||||
|
||||
await controller._init_onvif(CAMERA)
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
if cam["init"]:
|
||||
self.assertEqual(cam["status_request"].request_type, "GetStatus")
|
||||
status = MagicMock()
|
||||
status.MoveStatus.PanTilt = "IDLE"
|
||||
status.MoveStatus.Zoom = "IDLE"
|
||||
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:
|
||||
# create_type is a local WSDL lookup; cameras that do not implement
|
||||
|
||||
@@ -223,7 +223,7 @@ class NorfairTracker(ObjectTracker):
|
||||
),
|
||||
}
|
||||
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
self.camera_config, self.ptz_metrics
|
||||
)
|
||||
@@ -515,7 +515,7 @@ class NorfairTracker(ObjectTracker):
|
||||
yuv_frame: np.ndarray | None = None
|
||||
|
||||
if (
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
or self.detect_config.stationary.classifier
|
||||
):
|
||||
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]]])
|
||||
|
||||
embedding = None
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
embedding = get_histogram(
|
||||
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
|
||||
)
|
||||
@@ -559,7 +559,7 @@ class NorfairTracker(ObjectTracker):
|
||||
|
||||
coord_transformations = None
|
||||
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
# we must have been enabled by mqtt, so set up the estimator
|
||||
if not self.ptz_motion_estimator:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
|
||||
@@ -41,7 +41,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Event, ReviewSegment, Timeline
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.track.tracked_object import TrackedObject
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
|
||||
@@ -60,7 +60,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
config: FrigateConfig,
|
||||
dispatcher: Dispatcher,
|
||||
tracked_objects_queue: MpQueue,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
ptz_autotracker_thread: PtzAutoTracker,
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
super().__init__(name="detected_frames_processor")
|
||||
@@ -153,7 +153,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
self.ptz_autotracker_thread.ptz_autotracker.autotrack_object(camera, obj)
|
||||
self.ptz_autotracker_thread.autotrack_object(camera, obj)
|
||||
|
||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
# populate has_snapshot
|
||||
@@ -177,7 +177,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
"type": "end",
|
||||
}
|
||||
self.dispatcher.publish("events", json.dumps(message), retain=False)
|
||||
self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj)
|
||||
self.ptz_autotracker_thread.end_object(camera, obj)
|
||||
|
||||
self.event_sender.publish(
|
||||
(
|
||||
|
||||
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
|
||||
selected_events = _select_balanced_events(events, target_count=100)
|
||||
logger.debug(f"Selected {len(selected_events)} events")
|
||||
|
||||
# Step 3: Extract thumbnails from events
|
||||
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
|
||||
# Step 3: Extract thumbnails from events, falling back to the remaining
|
||||
# events when the selected ones have no image on disk
|
||||
selected_ids = {e.id for e in selected_events}
|
||||
remaining_events = [e for e in events if e.id not in selected_ids]
|
||||
random.shuffle(remaining_events)
|
||||
thumbnails = _extract_event_thumbnails(
|
||||
selected_events + remaining_events, temp_dir, target_count=100
|
||||
)
|
||||
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
|
||||
|
||||
# Step 4: Select 24 most visually distinct thumbnails
|
||||
@@ -833,10 +839,16 @@ def _select_balanced_events(
|
||||
else:
|
||||
selected.extend(remaining)
|
||||
|
||||
# groups are ordered oldest first, so truncating unshuffled keeps only the
|
||||
# oldest events, which are the least likely to still have images on disk
|
||||
random.shuffle(selected)
|
||||
|
||||
return selected[:target_count]
|
||||
|
||||
|
||||
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
||||
def _extract_event_thumbnails(
|
||||
events: list[Event], output_dir: str, target_count: int = 100
|
||||
) -> list[str]:
|
||||
"""
|
||||
Extract a training image for each event.
|
||||
|
||||
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
||||
using a step ladder sized from the box/region area ratio.
|
||||
|
||||
Args:
|
||||
events: List of Event objects
|
||||
events: List of Event objects, in order of preference
|
||||
output_dir: Directory to save crops
|
||||
target_count: Number of images to extract before stopping
|
||||
|
||||
Returns:
|
||||
List of paths to successfully extracted images
|
||||
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
||||
image_paths = []
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
if len(image_paths) >= target_count:
|
||||
break
|
||||
|
||||
try:
|
||||
img = _load_event_classification_crop(event)
|
||||
if img is None:
|
||||
|
||||
+10
-2
@@ -94,6 +94,7 @@ class CameraTracker(FrigateProcess):
|
||||
self.config.detect.fps,
|
||||
name=self.config.name,
|
||||
ptz_metrics=self.ptz_metrics,
|
||||
autotracking_enabled=self.config.onvif.autotracking.enabled,
|
||||
)
|
||||
object_detector = RemoteObjectDetector(
|
||||
self.config.name,
|
||||
@@ -195,10 +196,12 @@ def process_frames(
|
||||
None,
|
||||
{camera_config.name: camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.autotracking,
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.onvif,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -235,6 +238,11 @@ def process_frames(
|
||||
motion_detector.config = camera_config.motion
|
||||
motion_detector.update_mask()
|
||||
|
||||
if "autotracking" in updated_configs or "onvif" in updated_configs:
|
||||
motion_detector.autotracking_enabled = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
|
||||
if (
|
||||
not camera_enabled
|
||||
and prev_enabled != camera_enabled
|
||||
@@ -349,8 +357,8 @@ def process_frames(
|
||||
|
||||
# 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
|
||||
# on the metric rather than trusting them to be reset otherwise
|
||||
ptz_moving = ptz_metrics.autotracker_enabled.value and (
|
||||
# on the config rather than trusting them to be reset otherwise
|
||||
ptz_moving = camera_config.onvif.autotracking.enabled and (
|
||||
ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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,12 +7,13 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
|
||||
|
||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
@@ -13,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
|
||||
// the fixture detector runs at 75.5 ms, above the live warning threshold
|
||||
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
|
||||
|
||||
// the fixture has no go2rtc streams, which gives every camera a live view hint
|
||||
const RESTREAMED = {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
front_door: ["rtsp://x"],
|
||||
backyard: ["rtsp://x"],
|
||||
garage: ["rtsp://x"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ERROR_NOTICE = {
|
||||
id: "model_download_failed:yolo/model.onnx",
|
||||
kind: "model_download_failed",
|
||||
@@ -50,12 +62,13 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -83,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
for (const action of ["acknowledge", "mute"] as const) {
|
||||
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
@@ -125,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -328,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -764,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
@@ -801,7 +820,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -828,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("a camera without a go2rtc stream gets a live view hint", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
await expect(row).toContainText("lower frame rate and no audio");
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a global config problem is not repeated per camera", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -892,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -1129,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
@@ -6,42 +6,66 @@
|
||||
* RestartDialog cancel flow.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} 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("general tab is active by default via #general hash", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||
if (!frigateApp.isMobile) {
|
||||
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
||||
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Storage tab activates and deactivates General", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
);
|
||||
@@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => {
|
||||
|
||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
});
|
||||
|
||||
test("general tab shows version and last-refreshed", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
// On desktop, tab buttons render text labels so the word "storage"
|
||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||
// icon-only, so we verify the general-tab content disappears instead
|
||||
@@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => {
|
||||
} else {
|
||||
// Mobile: tab activation (data-state "on") already asserted above.
|
||||
// Additionally confirm general tab is no longer the active tab.
|
||||
await expect(
|
||||
frigateApp.page.getByLabel("Select general"),
|
||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"off",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
// Cameras tab lists every camera from config/stats. The default
|
||||
// mock has front_door, backyard, garage.
|
||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||
@@ -151,17 +165,13 @@ test.describe("System — tabs @medium", () => {
|
||||
config: { semantic_search: { enabled: true } },
|
||||
});
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
||||
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
||||
await enrichTab.click();
|
||||
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await selectTab(frigateApp, "enrichments");
|
||||
await expectTabActive(frigateApp, "enrichments");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
|
||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Select storage").click();
|
||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await selectTab(frigateApp, "storage");
|
||||
await expectTabActive(frigateApp, "storage");
|
||||
});
|
||||
|
||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
@@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||
|
||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||
const tabs = frigateApp.page
|
||||
.locator("[data-radix-scroll-area-viewport]")
|
||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
||||
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||
await expect(kebab).toBeVisible();
|
||||
const tabs = kebab.locator("..");
|
||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||
|
||||
const logoBox = await logo.boundingBox();
|
||||
@@ -267,20 +273,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||
|
||||
// the clipped tabs stay reachable by scrolling
|
||||
const overflow = await tabs.evaluate((el) => ({
|
||||
scroll: el.scrollWidth,
|
||||
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 },
|
||||
);
|
||||
// the tabs that do not fit stay reachable through the kebab
|
||||
await selectTab(frigateApp, "cameras");
|
||||
await expectTabActive(frigateApp, "cameras");
|
||||
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1997,6 +1997,9 @@
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
|
||||
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
|
||||
},
|
||||
"live": {
|
||||
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"showAllTabs": "Show all tabs",
|
||||
"showLessTabs": "Show less",
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"notices": {
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
messages: [
|
||||
{
|
||||
key: "no-go2rtc-stream",
|
||||
health: true,
|
||||
messageKey: "configMessages.live.noGo2rtcStream",
|
||||
severity: "info",
|
||||
docLink: "/configuration/live",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
return !Object.values(ctx.fullCameraConfig.live.streams).some(
|
||||
(name) => isRestreamedStream(ctx.fullConfig, name),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldOrder: ["streams", "transcode", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
|
||||
@@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import {
|
||||
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 { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import OverflowStrip from "../mobile/OverflowStrip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
@@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
|
||||
const [addGroup, setAddGroup] = useState(false);
|
||||
|
||||
// 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 groupButtons = () => {
|
||||
const buttons = [
|
||||
<Button
|
||||
key="default-group"
|
||||
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
if (group) {
|
||||
setGroup("default", true);
|
||||
}
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<MdHome className="size-5" />
|
||||
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGroup(name, group != "default");
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||
)}
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-5"
|
||||
/>
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroup(true);
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-5 text-primary-variant" />
|
||||
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
onMouseEnter={() => showTooltip(name)}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer
|
||||
icon={LuIcons[config.icon]}
|
||||
className="size-4"
|
||||
/>
|
||||
)}
|
||||
<IconRenderer
|
||||
icon={
|
||||
isValidIconName(config.icon)
|
||||
? LuIcons[config.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="size-4"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
@@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{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>
|
||||
<OverflowStrip
|
||||
className={className}
|
||||
items={groupButtons()}
|
||||
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
||||
showAllLabel={t("group.showAll")}
|
||||
showLessLabel={t("group.showLess")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+63
-30
@@ -1,8 +1,10 @@
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
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 GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||
@@ -36,6 +38,14 @@ const allMetrics = [
|
||||
] as const;
|
||||
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() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@@ -98,43 +108,66 @@ function System() {
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
||||
<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
|
||||
{isMobile ? (
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<OverflowStrip
|
||||
items={metrics.map((item) => (
|
||||
<Toggle
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-4",
|
||||
pageToggle != item && "*:text-muted-foreground",
|
||||
)}
|
||||
size="sm"
|
||||
pressed={pageToggle == item}
|
||||
onPressedChange={() => setPageToggle(item)}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
{metricIcons[item]}
|
||||
</Toggle>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
activeIndex={metrics.indexOf(pageToggle)}
|
||||
gapClassName="gap-0.5"
|
||||
showAllLabel={t("showAllTabs")}
|
||||
showLessLabel={t("showLessTabs")}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<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>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{pageToggle == "health" && (
|
||||
|
||||
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
|
||||
{isMobile && (
|
||||
<div className="relative flex h-11 items-center justify-between">
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
<div className="w-[45%]">
|
||||
<div className="w-[calc(50%-1rem)]">
|
||||
<CameraGroupSelector />
|
||||
</div>
|
||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||
|
||||
Reference in New Issue
Block a user