Compare commits

..
2 Commits
Author SHA1 Message Date
Josh Hawkins 3180d63df1 add reolink tests 2026-10-05 14:56:31 -05:00
Josh Hawkins fa1c41974f try http-flv first for reolink cameras above 5MP in the camera wizard
The wizard picked RTSP for every Reolink above 5MP and then warned that RTSP isn't recommended, telling users to enable HTTP and restart, which just produced the same result. It now tries the http-flv stream first for those cameras and falls back to RTSP when the stream doesn't probe, since only the newer models serve H.265 over enhanced FLV. The RTSP warning is skipped when the camera's resolution made RTSP a valid choice.

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