mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-26 18:18:22 +03:00
ONVIF refactor (#22629)
* add profile support and decouple relative move from autotracking * add drag to zoom * docs * add profile selection to UI * dynamically update onvif config * ui tweak * docs * docs tweak
This commit is contained in:
parent
f5937d8370
commit
3f6d5bcf22
@ -52,6 +52,10 @@ cameras:
|
||||
password: admin
|
||||
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
|
||||
tls_insecure: False
|
||||
# Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below)
|
||||
# If not set, the first profile with valid PTZ configuration is selected automatically.
|
||||
# Use this when your camera has multiple ONVIF profiles and you need to select a specific one.
|
||||
profile: None
|
||||
# Optional: PTZ camera object autotracking. Keeps a moving object in
|
||||
# the center of the frame by automatically moving the PTZ camera.
|
||||
autotracking:
|
||||
|
||||
@ -91,6 +91,8 @@ If your ONVIF camera does not require authentication credentials, you may still
|
||||
|
||||
:::
|
||||
|
||||
If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera.
|
||||
|
||||
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
|
||||
|
||||
## ONVIF PTZ camera recommendations
|
||||
|
||||
@ -951,7 +951,7 @@ cameras:
|
||||
onvif:
|
||||
# Required: host of the camera being connected to.
|
||||
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
|
||||
# NOTE: ONVIF user, and password can be specified with environment variables or docker secrets
|
||||
# NOTE: ONVIF host, user, and password can be specified with environment variables or docker secrets
|
||||
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
|
||||
host: 0.0.0.0
|
||||
# Optional: ONVIF port for device (default: shown below).
|
||||
@ -966,6 +966,10 @@ cameras:
|
||||
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
|
||||
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
|
||||
ignore_time_mismatch: False
|
||||
# Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below)
|
||||
# If not set, the first profile with valid PTZ configuration is selected automatically.
|
||||
# Use this when your camera has multiple ONVIF profiles and you need to select a specific one.
|
||||
profile: None
|
||||
# Optional: PTZ camera object autotracking. Keeps a moving object in
|
||||
# the center of the frame by automatically moving the PTZ camera.
|
||||
autotracking:
|
||||
|
||||
@ -117,6 +117,11 @@ class OnvifConfig(FrigateBaseModel):
|
||||
title="Disable TLS verify",
|
||||
description="Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).",
|
||||
)
|
||||
profile: Optional[str] = Field(
|
||||
default=None,
|
||||
title="ONVIF profile",
|
||||
description="Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.",
|
||||
)
|
||||
autotracking: PtzAutotrackConfig = Field(
|
||||
default_factory=PtzAutotrackConfig,
|
||||
title="Autotracking",
|
||||
|
||||
@ -23,6 +23,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
object_genai = "object_genai"
|
||||
onvif = "onvif"
|
||||
record = "record"
|
||||
remove = "remove" # for removing a camera
|
||||
review = "review"
|
||||
@ -130,6 +131,8 @@ class CameraConfigUpdateSubscriber:
|
||||
config.lpr = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.snapshots:
|
||||
config.snapshots = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.onvif:
|
||||
config.onvif = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.zones:
|
||||
config.zones = updated_config
|
||||
|
||||
|
||||
@ -15,6 +15,10 @@ from zeep.exceptions import Fault, TransportError
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.util.builtin import find_by_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -65,7 +69,14 @@ class OnvifController:
|
||||
self.camera_configs[cam_name] = cam
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[CameraConfigUpdateEnum.onvif],
|
||||
)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
||||
asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop)
|
||||
|
||||
def _run_event_loop(self) -> None:
|
||||
"""Run the event loop in a separate thread."""
|
||||
@ -80,6 +91,52 @@ class OnvifController:
|
||||
for cam_name in self.camera_configs:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _poll_config_updates(self) -> None:
|
||||
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
for update_type, cameras in updates.items():
|
||||
if update_type == CameraConfigUpdateEnum.onvif.name:
|
||||
for cam_name in cameras:
|
||||
await self._reinit_camera(cam_name)
|
||||
except Exception:
|
||||
logger.error("Error checking for ONVIF config updates")
|
||||
|
||||
async def _close_camera(self, cam_name: str) -> None:
|
||||
"""Close the ONVIF client session for a camera."""
|
||||
cam_state = self.cams.get(cam_name)
|
||||
if cam_state and "onvif" in cam_state:
|
||||
try:
|
||||
await cam_state["onvif"].close()
|
||||
except Exception:
|
||||
logger.debug(f"Error closing ONVIF session for {cam_name}")
|
||||
|
||||
async def _reinit_camera(self, cam_name: str) -> None:
|
||||
"""Re-initialize a camera after config change."""
|
||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||
|
||||
# close existing session before re-init
|
||||
await self._close_camera(cam_name)
|
||||
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if not cam or not cam.onvif.host:
|
||||
# ONVIF removed from config, clean up
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
return
|
||||
|
||||
# update stored config and reset state
|
||||
self.camera_configs[cam_name] = cam
|
||||
if cam_name not in self.status_locks:
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
self.cams.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||
"""Initialize a single camera by name.
|
||||
|
||||
@ -118,6 +175,7 @@ class OnvifController:
|
||||
"active": False,
|
||||
"features": [],
|
||||
"presets": {},
|
||||
"profiles": [],
|
||||
}
|
||||
return True
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
@ -161,22 +219,60 @@ class OnvifController:
|
||||
)
|
||||
return False
|
||||
|
||||
# build list of valid PTZ profiles
|
||||
valid_profiles = [
|
||||
p
|
||||
for p in profiles
|
||||
if p.VideoEncoderConfiguration
|
||||
and p.PTZConfiguration
|
||||
and (
|
||||
p.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace is not None
|
||||
or p.PTZConfiguration.DefaultContinuousZoomVelocitySpace is not None
|
||||
)
|
||||
]
|
||||
|
||||
# store available profiles for API response and log for debugging
|
||||
self.cams[camera_name]["profiles"] = [
|
||||
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
||||
for p in valid_profiles
|
||||
]
|
||||
for p in valid_profiles:
|
||||
logger.debug(
|
||||
"Onvif profile for %s: name='%s', token='%s'",
|
||||
camera_name,
|
||||
getattr(p, "Name", None),
|
||||
p.token,
|
||||
)
|
||||
|
||||
configured_profile = self.config.cameras[camera_name].onvif.profile
|
||||
profile = None
|
||||
for _, onvif_profile in enumerate(profiles):
|
||||
if (
|
||||
onvif_profile.VideoEncoderConfiguration
|
||||
and onvif_profile.PTZConfiguration
|
||||
and (
|
||||
onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace
|
||||
is not None
|
||||
or onvif_profile.PTZConfiguration.DefaultContinuousZoomVelocitySpace
|
||||
is not None
|
||||
|
||||
if configured_profile is not None:
|
||||
# match by exact token first, then by name
|
||||
for p in valid_profiles:
|
||||
if p.token == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
for p in valid_profiles:
|
||||
if getattr(p, "Name", None) == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
available = [
|
||||
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
||||
for p in valid_profiles
|
||||
]
|
||||
logger.error(
|
||||
"Onvif profile '%s' not found for camera %s. Available profiles: %s",
|
||||
configured_profile,
|
||||
camera_name,
|
||||
available,
|
||||
)
|
||||
):
|
||||
# use the first profile that has a valid ptz configuration
|
||||
profile = onvif_profile
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
break
|
||||
return False
|
||||
else:
|
||||
# use the first profile that has a valid ptz configuration
|
||||
profile = valid_profiles[0] if valid_profiles else None
|
||||
|
||||
if profile is None:
|
||||
logger.error(
|
||||
@ -184,6 +280,8 @@ class OnvifController:
|
||||
)
|
||||
return False
|
||||
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
|
||||
# get the PTZ config for the profile
|
||||
try:
|
||||
configs = profile.PTZConfiguration
|
||||
@ -218,48 +316,92 @@ class OnvifController:
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["move_request"] = move_request
|
||||
|
||||
# extra setup for autotracking cameras
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
):
|
||||
# get PTZ configuration options for feature detection and relative movement
|
||||
ptz_config = None
|
||||
fov_space_id = None
|
||||
|
||||
try:
|
||||
request = ptz.create_type("GetConfigurationOptions")
|
||||
request.ConfigurationToken = profile.PTZConfiguration.token
|
||||
ptz_config = await ptz.GetConfigurationOptions(request)
|
||||
logger.debug(f"Onvif config for {camera_name}: {ptz_config}")
|
||||
logger.debug(
|
||||
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
||||
)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
||||
)
|
||||
|
||||
# detect FOV translation space for relative movement
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
fov_space_id = next(
|
||||
(
|
||||
i
|
||||
for i, space in enumerate(
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace
|
||||
)
|
||||
if "TranslationSpaceFov" in space["URI"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
fov_space_id = None
|
||||
|
||||
autotracking_config = self.config.cameras[camera_name].onvif.autotracking
|
||||
autotracking_enabled = (
|
||||
autotracking_config.enabled_in_config and autotracking_config.enabled
|
||||
)
|
||||
|
||||
# autotracking-only: status request and service capabilities
|
||||
if autotracking_enabled:
|
||||
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
|
||||
)
|
||||
|
||||
fov_space_id = next(
|
||||
(
|
||||
i
|
||||
for i, space in enumerate(
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace
|
||||
)
|
||||
if "TranslationSpaceFov" in space["URI"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# status request for autotracking and filling ptz-parameters
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
# setup relative move request when FOV relative movement is supported
|
||||
if (
|
||||
fov_space_id is not None
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
# one-off GetStatus to seed Translation field
|
||||
status = None
|
||||
try:
|
||||
status = await ptz.GetStatus(status_request)
|
||||
logger.debug(f"Onvif status config for {camera_name}: {status}")
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera: {camera_name}: {e}")
|
||||
status = None
|
||||
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
||||
|
||||
# autotracking relative panning/tilting needs a relative zoom value set to 0
|
||||
# if camera supports relative movement
|
||||
rel_move_request = ptz.create_type("RelativeMove")
|
||||
rel_move_request.ProfileToken = profile.token
|
||||
logger.debug(f"{camera_name}: Relative move request: {rel_move_request}")
|
||||
|
||||
fov_uri = ptz_config["Spaces"]["RelativePanTiltTranslationSpace"][
|
||||
fov_space_id
|
||||
]["URI"]
|
||||
|
||||
if rel_move_request.Translation is None:
|
||||
if status is not None:
|
||||
# seed from current position
|
||||
rel_move_request.Translation = status.Position
|
||||
rel_move_request.Translation.PanTilt.space = fov_uri
|
||||
else:
|
||||
# fallback: construct Translation explicitly
|
||||
rel_move_request.Translation = {
|
||||
"PanTilt": {"x": 0, "y": 0, "space": fov_uri}
|
||||
}
|
||||
|
||||
# configure zoom on relative move request
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
!= ZoomingModeEnum.disabled
|
||||
autotracking_enabled
|
||||
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||
):
|
||||
zoom_space_id = next(
|
||||
(
|
||||
@ -271,60 +413,43 @@ class OnvifController:
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# setup relative moving request for autotracking
|
||||
move_request = ptz.create_type("RelativeMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
logger.debug(f"{camera_name}: Relative move request: {move_request}")
|
||||
if move_request.Translation is None and fov_space_id is not None:
|
||||
move_request.Translation = status.Position
|
||||
move_request.Translation.PanTilt.space = ptz_config["Spaces"][
|
||||
"RelativePanTiltTranslationSpace"
|
||||
][fov_space_id]["URI"]
|
||||
|
||||
# try setting relative zoom translation space
|
||||
try:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
!= ZoomingModeEnum.disabled
|
||||
):
|
||||
try:
|
||||
if zoom_space_id is not None:
|
||||
move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||
rel_move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||
"RelativeZoomTranslationSpace"
|
||||
][zoom_space_id]["URI"]
|
||||
else:
|
||||
if (
|
||||
move_request["Translation"] is not None
|
||||
and "Zoom" in move_request["Translation"]
|
||||
):
|
||||
del move_request["Translation"]["Zoom"]
|
||||
if (
|
||||
move_request["Speed"] is not None
|
||||
and "Zoom" in move_request["Speed"]
|
||||
):
|
||||
del move_request["Speed"]["Zoom"]
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after deleting zoom: {move_request}"
|
||||
except Exception as e:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
else:
|
||||
# remove zoom fields from relative move request
|
||||
if (
|
||||
rel_move_request["Translation"] is not None
|
||||
and "Zoom" in rel_move_request["Translation"]
|
||||
):
|
||||
del rel_move_request["Translation"]["Zoom"]
|
||||
if (
|
||||
rel_move_request["Speed"] is not None
|
||||
and "Zoom" in rel_move_request["Speed"]
|
||||
):
|
||||
del rel_move_request["Speed"]["Zoom"]
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after deleting zoom: {rel_move_request}"
|
||||
)
|
||||
|
||||
if move_request.Speed is None:
|
||||
move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
if rel_move_request.Speed is None:
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after setup: {move_request}"
|
||||
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
||||
)
|
||||
self.cams[camera_name]["relative_move_request"] = move_request
|
||||
self.cams[camera_name]["relative_move_request"] = rel_move_request
|
||||
|
||||
# setup absolute moving request for autotracking zooming
|
||||
move_request = ptz.create_type("AbsoluteMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["absolute_move_request"] = 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
|
||||
try:
|
||||
@ -358,48 +483,48 @@ class OnvifController:
|
||||
|
||||
if configs.DefaultRelativeZoomTranslationSpace:
|
||||
supported_features.append("zoom-r")
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
):
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name]["relative_zoom_range"] = (
|
||||
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||
)
|
||||
except Exception as e:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
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:
|
||||
supported_features.append("zoom-a")
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
):
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name]["absolute_zoom_range"] = (
|
||||
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||
)
|
||||
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||
except Exception as e:
|
||||
if self.config.cameras[camera_name].onvif.autotracking.zooming:
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
# 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
|
||||
@ -416,10 +541,9 @@ class OnvifController:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
||||
|
||||
# detect FOV relative movement support
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
and fov_space_id is not None
|
||||
fov_space_id is not None
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
supported_features.append("pt-r-fov")
|
||||
@ -548,11 +672,8 @@ class OnvifController:
|
||||
move_request.Translation.PanTilt.x = pan
|
||||
move_request.Translation.PanTilt.y = tilt
|
||||
|
||||
if (
|
||||
"zoom-r" in self.cams[camera_name]["features"]
|
||||
and self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
# include zoom if requested and camera supports relative zoom
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
@ -560,7 +681,7 @@ class OnvifController:
|
||||
},
|
||||
"Zoom": {"x": speed},
|
||||
}
|
||||
move_request.Translation.Zoom.x = zoom
|
||||
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||
|
||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||
|
||||
@ -568,12 +689,8 @@ class OnvifController:
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
move_request.Translation.PanTilt.y = 0
|
||||
|
||||
if (
|
||||
"zoom-r" in self.cams[camera_name]["features"]
|
||||
and self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
move_request.Translation.Zoom.x = 0
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
del move_request["Translation"]["Zoom"]
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
@ -717,8 +834,18 @@ class OnvifController:
|
||||
elif command == OnvifCommandEnum.preset:
|
||||
await self._move_to_preset(camera_name, param)
|
||||
elif command == OnvifCommandEnum.move_relative:
|
||||
_, pan, tilt = param.split("_")
|
||||
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
|
||||
parts = param.split("_")
|
||||
if len(parts) == 3:
|
||||
_, pan, tilt = parts
|
||||
zoom = 0.0
|
||||
elif len(parts) == 4:
|
||||
_, pan, tilt, zoom = parts
|
||||
else:
|
||||
logger.error(f"Invalid move_relative params: {param}")
|
||||
return
|
||||
await self._move_relative(
|
||||
camera_name, float(pan), float(tilt), float(zoom), 1
|
||||
)
|
||||
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
|
||||
await self._zoom(camera_name, command)
|
||||
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
|
||||
@ -773,6 +900,7 @@ class OnvifController:
|
||||
"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:
|
||||
@ -970,6 +1098,7 @@ class OnvifController:
|
||||
return
|
||||
|
||||
logger.info("Exiting ONVIF controller...")
|
||||
self.config_subscriber.stop()
|
||||
|
||||
def stop_and_cleanup():
|
||||
try:
|
||||
|
||||
@ -787,6 +787,10 @@
|
||||
"label": "Disable TLS verify",
|
||||
"description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only)."
|
||||
},
|
||||
"profile": {
|
||||
"label": "ONVIF profile",
|
||||
"description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically."
|
||||
},
|
||||
"autotracking": {
|
||||
"label": "Autotracking",
|
||||
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
||||
|
||||
@ -1536,6 +1536,10 @@
|
||||
"label": "Disable TLS verify",
|
||||
"description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only)."
|
||||
},
|
||||
"profile": {
|
||||
"label": "ONVIF profile",
|
||||
"description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically."
|
||||
},
|
||||
"autotracking": {
|
||||
"label": "Autotracking",
|
||||
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"clickMove": {
|
||||
"label": "Click in the frame to center the camera",
|
||||
"enable": "Enable click to move",
|
||||
"enableWithZoom": "Enable click to move / drag to zoom",
|
||||
"disable": "Disable click to move"
|
||||
},
|
||||
"left": {
|
||||
|
||||
@ -1573,5 +1573,9 @@
|
||||
"hardwareNone": "No hardware acceleration",
|
||||
"hardwareAuto": "Automatic hardware acceleration"
|
||||
}
|
||||
},
|
||||
"onvif": {
|
||||
"profileAuto": "Auto",
|
||||
"profileLoading": "Loading profiles..."
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,20 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const onvif: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
||||
restartRequired: [
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"tls_insecure",
|
||||
"ignore_time_mismatch",
|
||||
"autotracking.calibrate_on_startup",
|
||||
],
|
||||
fieldOrder: [
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"profile",
|
||||
"tls_insecure",
|
||||
"ignore_time_mismatch",
|
||||
"autotracking",
|
||||
@ -27,10 +19,14 @@ const onvif: SectionConfigOverrides = {
|
||||
],
|
||||
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
|
||||
overrideFields: [],
|
||||
restartRequired: ["autotracking.calibrate_on_startup"],
|
||||
uiSchema: {
|
||||
host: {
|
||||
"ui:options": { size: "sm" },
|
||||
},
|
||||
profile: {
|
||||
"ui:widget": "onvifProfile",
|
||||
},
|
||||
autotracking: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
|
||||
@ -29,6 +29,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
@ -79,6 +80,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { CameraPtzInfo } from "@/types/ptz";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AUTO_VALUE = "__auto__";
|
||||
|
||||
export function OnvifProfileWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options } = props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
const cameraName = formContext?.cameraName;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
|
||||
|
||||
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
|
||||
isCameraLevel && cameraName && hasOnvifHost
|
||||
? `${cameraName}/ptz/info`
|
||||
: null,
|
||||
{
|
||||
// ONVIF may not be initialized yet when the settings page loads,
|
||||
// so retry until profiles become available
|
||||
refreshInterval: (data) =>
|
||||
data?.profiles && data.profiles.length > 0 ? 0 : 5000,
|
||||
},
|
||||
);
|
||||
|
||||
const profiles = ptzInfo?.profiles ?? [];
|
||||
const fieldClassName = getSizedFieldClassName(options, "md");
|
||||
const hasProfiles = profiles.length > 0;
|
||||
const waiting = isCameraLevel && !!cameraName && hasOnvifHost && !hasProfiles;
|
||||
|
||||
const selected = value ?? AUTO_VALUE;
|
||||
|
||||
if (waiting) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", fieldClassName)}>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("onvif.profileLoading")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={String(selected)}
|
||||
onValueChange={(val) => {
|
||||
onChange(val === AUTO_VALUE ? null : val);
|
||||
}}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger id={id} className={cn("text-left", fieldClassName)}>
|
||||
<SelectValue placeholder={schema.title || "Select..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_VALUE}>{t("onvif.profileAuto")}</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.token} value={p.token}>
|
||||
{p.name !== p.token ? `${p.name} (${p.token})` : p.token}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!hasProfiles && value && value !== AUTO_VALUE && (
|
||||
<SelectItem value={String(value)}>{String(value)}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@ -284,7 +284,9 @@ export default function PtzControlPanel({
|
||||
<p>
|
||||
{clickOverlay
|
||||
? t("ptz.move.clickMove.disable")
|
||||
: t("ptz.move.clickMove.enable")}
|
||||
: ptz?.features?.includes("zoom-r")
|
||||
? t("ptz.move.clickMove.enableWithZoom")
|
||||
: t("ptz.move.clickMove.enable")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -7,8 +7,14 @@ type PtzFeature =
|
||||
| "pt-r-fov"
|
||||
| "focus";
|
||||
|
||||
export type OnvifProfile = {
|
||||
name: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type CameraPtzInfo = {
|
||||
name: string;
|
||||
features: PtzFeature[];
|
||||
presets: string[];
|
||||
profiles: OnvifProfile[];
|
||||
};
|
||||
|
||||
@ -122,6 +122,11 @@ import {
|
||||
SnapshotResult,
|
||||
} from "@/utils/snapshotUtil";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Stage, Layer, Rect } from "react-konva";
|
||||
import type { KonvaEventObject } from "konva/lib/Node";
|
||||
|
||||
/** Pixel threshold to distinguish drag from click. */
|
||||
const DRAG_MIN_PX = 15;
|
||||
|
||||
type LiveCameraViewProps = {
|
||||
config?: FrigateConfig;
|
||||
@ -213,45 +218,112 @@ export default function LiveCameraView({
|
||||
};
|
||||
}, [audioTranscriptionState, sendTranscription]);
|
||||
|
||||
// click overlay for ptzs
|
||||
// click-to-move / drag-to-zoom overlay for PTZ cameras
|
||||
|
||||
const [clickOverlay, setClickOverlay] = useState(false);
|
||||
const clickOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const { send: sendPtz } = usePtzCommand(camera.name);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (!clickOverlay) {
|
||||
return;
|
||||
}
|
||||
// drag rectangle state in stage-local coordinates
|
||||
const [ptzRect, setPtzRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [isPtzDrawing, setIsPtzDrawing] = useState(false);
|
||||
// raw origin to determine drag direction (not min/max corrected)
|
||||
const ptzOriginRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
let clientX;
|
||||
let clientY;
|
||||
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||
clientX = e.nativeEvent.touches[0].clientX;
|
||||
clientY = e.nativeEvent.touches[0].clientY;
|
||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||
clientX = e.nativeEvent.clientX;
|
||||
clientY = e.nativeEvent.clientY;
|
||||
}
|
||||
const [overlaySize] = useResizeObserver(clickOverlayRef);
|
||||
|
||||
if (clickOverlayRef.current && clientX && clientY) {
|
||||
const rect = clickOverlayRef.current.getBoundingClientRect();
|
||||
|
||||
const normalizedX = (clientX - rect.left) / rect.width;
|
||||
const normalizedY = (clientY - rect.top) / rect.height;
|
||||
|
||||
const pan = (normalizedX - 0.5) * 2;
|
||||
const tilt = (0.5 - normalizedY) * 2;
|
||||
|
||||
sendPtz(`move_relative_${pan}_${tilt}`);
|
||||
const onPtzStageDown = useCallback(
|
||||
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
||||
const pos = e.target.getStage()?.getPointerPosition();
|
||||
if (pos) {
|
||||
setIsPtzDrawing(true);
|
||||
ptzOriginRef.current = { x: pos.x, y: pos.y };
|
||||
setPtzRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
|
||||
}
|
||||
},
|
||||
[clickOverlayRef, clickOverlay, sendPtz],
|
||||
[],
|
||||
);
|
||||
|
||||
const onPtzStageMove = useCallback(
|
||||
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
||||
if (!isPtzDrawing || !ptzRect) return;
|
||||
const pos = e.target.getStage()?.getPointerPosition();
|
||||
if (pos) {
|
||||
setPtzRect({
|
||||
...ptzRect,
|
||||
width: pos.x - ptzRect.x,
|
||||
height: pos.y - ptzRect.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPtzDrawing, ptzRect],
|
||||
);
|
||||
|
||||
const onPtzStageUp = useCallback(() => {
|
||||
setIsPtzDrawing(false);
|
||||
|
||||
if (!ptzRect || !ptzOriginRef.current || overlaySize.width === 0) {
|
||||
setPtzRect(null);
|
||||
ptzOriginRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const endX = ptzRect.x + ptzRect.width;
|
||||
const endY = ptzRect.y + ptzRect.height;
|
||||
const distX = Math.abs(ptzRect.width);
|
||||
const distY = Math.abs(ptzRect.height);
|
||||
|
||||
if (distX < DRAG_MIN_PX && distY < DRAG_MIN_PX) {
|
||||
// click — pan/tilt to point without zoom
|
||||
const normX = endX / overlaySize.width;
|
||||
const normY = endY / overlaySize.height;
|
||||
const pan = (normX - 0.5) * 2;
|
||||
const tilt = (0.5 - normY) * 2;
|
||||
sendPtz(`move_relative_${pan}_${tilt}`);
|
||||
} else {
|
||||
// drag — pan/tilt to box center, zoom based on box size
|
||||
const origin = ptzOriginRef.current;
|
||||
|
||||
const n0x = Math.min(origin.x, endX) / overlaySize.width;
|
||||
const n0y = Math.min(origin.y, endY) / overlaySize.height;
|
||||
const n1x = Math.max(origin.x, endX) / overlaySize.width;
|
||||
const n1y = Math.max(origin.y, endY) / overlaySize.height;
|
||||
|
||||
let boxW = n1x - n0x;
|
||||
let boxH = n1y - n0y;
|
||||
|
||||
// correct box to match camera aspect ratio so zoom is uniform
|
||||
const frameAR = overlaySize.width / overlaySize.height;
|
||||
const boxAR = boxW / boxH;
|
||||
if (boxAR > frameAR) {
|
||||
boxH = boxW / frameAR;
|
||||
} else {
|
||||
boxW = boxH * frameAR;
|
||||
}
|
||||
|
||||
const centerX = (n0x + n1x) / 2;
|
||||
const centerY = (n0y + n1y) / 2;
|
||||
const pan = (centerX - 0.5) * 2;
|
||||
const tilt = (0.5 - centerY) * 2;
|
||||
|
||||
// zoom magnitude from box size (small box = more zoom)
|
||||
let zoom = Math.max(0.01, Math.min(1, Math.max(boxW, boxH)));
|
||||
// drag direction: top-left → bottom-right = zoom in, reverse = zoom out
|
||||
const zoomIn = endX > origin.x && endY > origin.y;
|
||||
if (!zoomIn) zoom = -zoom;
|
||||
|
||||
sendPtz(`move_relative_${pan}_${tilt}_${zoom}`);
|
||||
}
|
||||
|
||||
setPtzRect(null);
|
||||
ptzOriginRef.current = null;
|
||||
}, [ptzRect, overlaySize, sendPtz]);
|
||||
|
||||
// pip state
|
||||
|
||||
useEffect(() => {
|
||||
@ -440,7 +512,8 @@ export default function LiveCameraView({
|
||||
<TransformWrapper
|
||||
minScale={1.0}
|
||||
wheel={{ smoothStep: 0.005 }}
|
||||
disabled={debug}
|
||||
disabled={debug || clickOverlay}
|
||||
panning={{ disabled: clickOverlay }}
|
||||
>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div
|
||||
@ -634,13 +707,41 @@ export default function LiveCameraView({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center ${growClassName}`}
|
||||
className={`relative flex flex-col items-center justify-center ${growClassName}`}
|
||||
ref={clickOverlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
style={{
|
||||
aspectRatio: constrainedAspectRatio,
|
||||
}}
|
||||
>
|
||||
{clickOverlay && overlaySize.width > 0 && (
|
||||
<div className="absolute inset-0 z-40 cursor-crosshair">
|
||||
<Stage
|
||||
width={overlaySize.width}
|
||||
height={overlaySize.height}
|
||||
onMouseDown={onPtzStageDown}
|
||||
onMouseMove={onPtzStageMove}
|
||||
onMouseUp={onPtzStageUp}
|
||||
onTouchStart={onPtzStageDown}
|
||||
onTouchMove={onPtzStageMove}
|
||||
onTouchEnd={onPtzStageUp}
|
||||
>
|
||||
<Layer>
|
||||
{ptzRect && (
|
||||
<Rect
|
||||
x={ptzRect.x}
|
||||
y={ptzRect.y}
|
||||
width={ptzRect.width}
|
||||
height={ptzRect.height}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
dash={[6, 4]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
)}
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user