mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-29 19:40:19 +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
|
password: admin
|
||||||
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
|
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
|
||||||
tls_insecure: False
|
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
|
# Optional: PTZ camera object autotracking. Keeps a moving object in
|
||||||
# the center of the frame by automatically moving the PTZ camera.
|
# the center of the frame by automatically moving the PTZ camera.
|
||||||
autotracking:
|
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.
|
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
|
## ONVIF PTZ camera recommendations
|
||||||
|
|||||||
@ -951,7 +951,7 @@ cameras:
|
|||||||
onvif:
|
onvif:
|
||||||
# Required: host of the camera being connected to.
|
# 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: 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}'
|
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
# Optional: ONVIF port for device (default: shown below).
|
# 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.
|
# 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.
|
# 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
|
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
|
# Optional: PTZ camera object autotracking. Keeps a moving object in
|
||||||
# the center of the frame by automatically moving the PTZ camera.
|
# the center of the frame by automatically moving the PTZ camera.
|
||||||
autotracking:
|
autotracking:
|
||||||
|
|||||||
@ -117,6 +117,11 @@ class OnvifConfig(FrigateBaseModel):
|
|||||||
title="Disable TLS verify",
|
title="Disable TLS verify",
|
||||||
description="Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).",
|
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(
|
autotracking: PtzAutotrackConfig = Field(
|
||||||
default_factory=PtzAutotrackConfig,
|
default_factory=PtzAutotrackConfig,
|
||||||
title="Autotracking",
|
title="Autotracking",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
notifications = "notifications"
|
notifications = "notifications"
|
||||||
objects = "objects"
|
objects = "objects"
|
||||||
object_genai = "object_genai"
|
object_genai = "object_genai"
|
||||||
|
onvif = "onvif"
|
||||||
record = "record"
|
record = "record"
|
||||||
remove = "remove" # for removing a camera
|
remove = "remove" # for removing a camera
|
||||||
review = "review"
|
review = "review"
|
||||||
@ -130,6 +131,8 @@ class CameraConfigUpdateSubscriber:
|
|||||||
config.lpr = updated_config
|
config.lpr = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.snapshots:
|
elif update_type == CameraConfigUpdateEnum.snapshots:
|
||||||
config.snapshots = updated_config
|
config.snapshots = updated_config
|
||||||
|
elif update_type == CameraConfigUpdateEnum.onvif:
|
||||||
|
config.onvif = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.zones:
|
elif update_type == CameraConfigUpdateEnum.zones:
|
||||||
config.zones = updated_config
|
config.zones = updated_config
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,10 @@ 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
|
||||||
|
from frigate.config.camera.updater import (
|
||||||
|
CameraConfigUpdateEnum,
|
||||||
|
CameraConfigUpdateSubscriber,
|
||||||
|
)
|
||||||
from frigate.util.builtin import find_by_key
|
from frigate.util.builtin import find_by_key
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -65,7 +69,14 @@ class OnvifController:
|
|||||||
self.camera_configs[cam_name] = cam
|
self.camera_configs[cam_name] = cam
|
||||||
self.status_locks[cam_name] = asyncio.Lock()
|
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._init_cameras(), self.loop)
|
||||||
|
asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop)
|
||||||
|
|
||||||
def _run_event_loop(self) -> None:
|
def _run_event_loop(self) -> None:
|
||||||
"""Run the event loop in a separate thread."""
|
"""Run the event loop in a separate thread."""
|
||||||
@ -80,6 +91,52 @@ class OnvifController:
|
|||||||
for cam_name in self.camera_configs:
|
for cam_name in self.camera_configs:
|
||||||
await self._init_single_camera(cam_name)
|
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:
|
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||||
"""Initialize a single camera by name.
|
"""Initialize a single camera by name.
|
||||||
|
|
||||||
@ -118,6 +175,7 @@ class OnvifController:
|
|||||||
"active": False,
|
"active": False,
|
||||||
"features": [],
|
"features": [],
|
||||||
"presets": {},
|
"presets": {},
|
||||||
|
"profiles": [],
|
||||||
}
|
}
|
||||||
return True
|
return True
|
||||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||||
@ -161,22 +219,60 @@ class OnvifController:
|
|||||||
)
|
)
|
||||||
return False
|
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
|
profile = None
|
||||||
for _, onvif_profile in enumerate(profiles):
|
|
||||||
if (
|
if configured_profile is not None:
|
||||||
onvif_profile.VideoEncoderConfiguration
|
# match by exact token first, then by name
|
||||||
and onvif_profile.PTZConfiguration
|
for p in valid_profiles:
|
||||||
and (
|
if p.token == configured_profile:
|
||||||
onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace
|
profile = p
|
||||||
is not None
|
break
|
||||||
or onvif_profile.PTZConfiguration.DefaultContinuousZoomVelocitySpace
|
if profile is None:
|
||||||
is not 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,
|
||||||
)
|
)
|
||||||
):
|
return False
|
||||||
# use the first profile that has a valid ptz configuration
|
else:
|
||||||
profile = onvif_profile
|
# use the first profile that has a valid ptz configuration
|
||||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
profile = valid_profiles[0] if valid_profiles else None
|
||||||
break
|
|
||||||
|
|
||||||
if profile is None:
|
if profile is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -184,6 +280,8 @@ class OnvifController:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||||
|
|
||||||
# get the PTZ config for the profile
|
# get the PTZ config for the profile
|
||||||
try:
|
try:
|
||||||
configs = profile.PTZConfiguration
|
configs = profile.PTZConfiguration
|
||||||
@ -218,48 +316,92 @@ class OnvifController:
|
|||||||
move_request.ProfileToken = profile.token
|
move_request.ProfileToken = profile.token
|
||||||
self.cams[camera_name]["move_request"] = move_request
|
self.cams[camera_name]["move_request"] = move_request
|
||||||
|
|
||||||
# extra setup for autotracking cameras
|
# get PTZ configuration options for feature detection and relative movement
|
||||||
if (
|
ptz_config = None
|
||||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
fov_space_id = None
|
||||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
|
||||||
):
|
try:
|
||||||
request = ptz.create_type("GetConfigurationOptions")
|
request = ptz.create_type("GetConfigurationOptions")
|
||||||
request.ConfigurationToken = profile.PTZConfiguration.token
|
request.ConfigurationToken = profile.PTZConfiguration.token
|
||||||
ptz_config = await ptz.GetConfigurationOptions(request)
|
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")
|
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||||
self.cams[camera_name]["service_capabilities_request"] = (
|
self.cams[camera_name]["service_capabilities_request"] = (
|
||||||
service_capabilities_request
|
service_capabilities_request
|
||||||
)
|
)
|
||||||
|
|
||||||
fov_space_id = next(
|
# setup relative move request when FOV relative movement is supported
|
||||||
(
|
if (
|
||||||
i
|
fov_space_id is not None
|
||||||
for i, space in enumerate(
|
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||||
ptz_config.Spaces.RelativePanTiltTranslationSpace
|
):
|
||||||
)
|
# one-off GetStatus to seed Translation field
|
||||||
if "TranslationSpaceFov" in space["URI"]
|
status = None
|
||||||
),
|
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
status = await ptz.GetStatus(status_request)
|
one_off_status_request = ptz.create_type("GetStatus")
|
||||||
logger.debug(f"Onvif status config for {camera_name}: {status}")
|
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:
|
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}")
|
||||||
status = None
|
|
||||||
|
|
||||||
# autotracking relative panning/tilting needs a relative zoom value set to 0
|
rel_move_request = ptz.create_type("RelativeMove")
|
||||||
# if camera supports relative movement
|
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 (
|
if (
|
||||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
autotracking_enabled
|
||||||
!= ZoomingModeEnum.disabled
|
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||||
):
|
):
|
||||||
zoom_space_id = next(
|
zoom_space_id = next(
|
||||||
(
|
(
|
||||||
@ -271,60 +413,43 @@ class OnvifController:
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
# 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
|
|
||||||
):
|
|
||||||
if zoom_space_id is not None:
|
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"
|
"RelativeZoomTranslationSpace"
|
||||||
][zoom_space_id]["URI"]
|
][zoom_space_id]["URI"]
|
||||||
else:
|
except Exception as e:
|
||||||
if (
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
move_request["Translation"] is not None
|
logger.warning(
|
||||||
and "Zoom" in move_request["Translation"]
|
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||||
):
|
|
||||||
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:
|
else:
|
||||||
self.config.cameras[
|
# remove zoom fields from relative move request
|
||||||
camera_name
|
if (
|
||||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
rel_move_request["Translation"] is not None
|
||||||
logger.warning(
|
and "Zoom" in rel_move_request["Translation"]
|
||||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
):
|
||||||
|
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:
|
if rel_move_request.Speed is None:
|
||||||
move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||||
logger.debug(
|
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
|
# setup absolute move request
|
||||||
move_request = ptz.create_type("AbsoluteMove")
|
abs_move_request = ptz.create_type("AbsoluteMove")
|
||||||
move_request.ProfileToken = profile.token
|
abs_move_request.ProfileToken = profile.token
|
||||||
self.cams[camera_name]["absolute_move_request"] = move_request
|
self.cams[camera_name]["absolute_move_request"] = abs_move_request
|
||||||
|
|
||||||
# setup existing presets
|
# setup existing presets
|
||||||
try:
|
try:
|
||||||
@ -358,48 +483,48 @@ class OnvifController:
|
|||||||
|
|
||||||
if configs.DefaultRelativeZoomTranslationSpace:
|
if configs.DefaultRelativeZoomTranslationSpace:
|
||||||
supported_features.append("zoom-r")
|
supported_features.append("zoom-r")
|
||||||
if (
|
if ptz_config is not None:
|
||||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
|
||||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
# get camera's zoom limits from onvif config
|
|
||||||
self.cams[camera_name]["relative_zoom_range"] = (
|
self.cams[camera_name]["relative_zoom_range"] = (
|
||||||
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if (
|
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
== ZoomingModeEnum.relative
|
|
||||||
):
|
|
||||||
self.config.cameras[
|
|
||||||
camera_name
|
|
||||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
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 (
|
if ptz_config is not None:
|
||||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
|
||||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
# get camera's zoom limits from onvif config
|
|
||||||
self.cams[camera_name]["absolute_zoom_range"] = (
|
self.cams[camera_name]["absolute_zoom_range"] = (
|
||||||
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||||
)
|
)
|
||||||
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.config.cameras[camera_name].onvif.autotracking.zooming:
|
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||||
self.config.cameras[
|
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||||
camera_name
|
|
||||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
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 (
|
if (
|
||||||
self.cams[camera_name]["video_source_token"] is not None
|
self.cams[camera_name]["video_source_token"] is not None
|
||||||
and imaging is not None
|
and imaging is not None
|
||||||
@ -416,10 +541,9 @@ class OnvifController:
|
|||||||
except (Fault, ONVIFError, TransportError, 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
|
||||||
if (
|
if (
|
||||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
fov_space_id is not None
|
||||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
|
||||||
and fov_space_id is not None
|
|
||||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||||
):
|
):
|
||||||
supported_features.append("pt-r-fov")
|
supported_features.append("pt-r-fov")
|
||||||
@ -548,11 +672,8 @@ class OnvifController:
|
|||||||
move_request.Translation.PanTilt.x = pan
|
move_request.Translation.PanTilt.x = pan
|
||||||
move_request.Translation.PanTilt.y = tilt
|
move_request.Translation.PanTilt.y = tilt
|
||||||
|
|
||||||
if (
|
# include zoom if requested and camera supports relative zoom
|
||||||
"zoom-r" in self.cams[camera_name]["features"]
|
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||||
and self.config.cameras[camera_name].onvif.autotracking.zooming
|
|
||||||
== ZoomingModeEnum.relative
|
|
||||||
):
|
|
||||||
move_request.Speed = {
|
move_request.Speed = {
|
||||||
"PanTilt": {
|
"PanTilt": {
|
||||||
"x": speed,
|
"x": speed,
|
||||||
@ -560,7 +681,7 @@ class OnvifController:
|
|||||||
},
|
},
|
||||||
"Zoom": {"x": speed},
|
"Zoom": {"x": speed},
|
||||||
}
|
}
|
||||||
move_request.Translation.Zoom.x = zoom
|
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||||
|
|
||||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
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.x = 0
|
||||||
move_request.Translation.PanTilt.y = 0
|
move_request.Translation.PanTilt.y = 0
|
||||||
|
|
||||||
if (
|
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||||
"zoom-r" in self.cams[camera_name]["features"]
|
del move_request["Translation"]["Zoom"]
|
||||||
and self.config.cameras[camera_name].onvif.autotracking.zooming
|
|
||||||
== ZoomingModeEnum.relative
|
|
||||||
):
|
|
||||||
move_request.Translation.Zoom.x = 0
|
|
||||||
|
|
||||||
self.cams[camera_name]["active"] = False
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
@ -717,8 +834,18 @@ class OnvifController:
|
|||||||
elif command == OnvifCommandEnum.preset:
|
elif command == OnvifCommandEnum.preset:
|
||||||
await self._move_to_preset(camera_name, param)
|
await self._move_to_preset(camera_name, param)
|
||||||
elif command == OnvifCommandEnum.move_relative:
|
elif command == OnvifCommandEnum.move_relative:
|
||||||
_, pan, tilt = param.split("_")
|
parts = param.split("_")
|
||||||
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
|
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):
|
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
|
||||||
await self._zoom(camera_name, command)
|
await self._zoom(camera_name, command)
|
||||||
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
|
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
|
||||||
@ -773,6 +900,7 @@ class OnvifController:
|
|||||||
"name": camera_name,
|
"name": camera_name,
|
||||||
"features": self.cams[camera_name]["features"],
|
"features": self.cams[camera_name]["features"],
|
||||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
"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:
|
||||||
@ -970,6 +1098,7 @@ class OnvifController:
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Exiting ONVIF controller...")
|
logger.info("Exiting ONVIF controller...")
|
||||||
|
self.config_subscriber.stop()
|
||||||
|
|
||||||
def stop_and_cleanup():
|
def stop_and_cleanup():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -787,6 +787,10 @@
|
|||||||
"label": "Disable TLS verify",
|
"label": "Disable TLS verify",
|
||||||
"description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only)."
|
"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": {
|
"autotracking": {
|
||||||
"label": "Autotracking",
|
"label": "Autotracking",
|
||||||
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
||||||
|
|||||||
@ -1536,6 +1536,10 @@
|
|||||||
"label": "Disable TLS verify",
|
"label": "Disable TLS verify",
|
||||||
"description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only)."
|
"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": {
|
"autotracking": {
|
||||||
"label": "Autotracking",
|
"label": "Autotracking",
|
||||||
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"clickMove": {
|
"clickMove": {
|
||||||
"label": "Click in the frame to center the camera",
|
"label": "Click in the frame to center the camera",
|
||||||
"enable": "Enable click to move",
|
"enable": "Enable click to move",
|
||||||
|
"enableWithZoom": "Enable click to move / drag to zoom",
|
||||||
"disable": "Disable click to move"
|
"disable": "Disable click to move"
|
||||||
},
|
},
|
||||||
"left": {
|
"left": {
|
||||||
|
|||||||
@ -1573,5 +1573,9 @@
|
|||||||
"hardwareNone": "No hardware acceleration",
|
"hardwareNone": "No hardware acceleration",
|
||||||
"hardwareAuto": "Automatic hardware acceleration"
|
"hardwareAuto": "Automatic hardware acceleration"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"onvif": {
|
||||||
|
"profileAuto": "Auto",
|
||||||
|
"profileLoading": "Loading profiles..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const onvif: SectionConfigOverrides = {
|
const onvif: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
||||||
restartRequired: [
|
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"user",
|
|
||||||
"password",
|
|
||||||
"tls_insecure",
|
|
||||||
"ignore_time_mismatch",
|
|
||||||
"autotracking.calibrate_on_startup",
|
|
||||||
],
|
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"host",
|
"host",
|
||||||
"port",
|
"port",
|
||||||
"user",
|
"user",
|
||||||
"password",
|
"password",
|
||||||
|
"profile",
|
||||||
"tls_insecure",
|
"tls_insecure",
|
||||||
"ignore_time_mismatch",
|
"ignore_time_mismatch",
|
||||||
"autotracking",
|
"autotracking",
|
||||||
@ -27,10 +19,14 @@ const onvif: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
|
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
|
||||||
overrideFields: [],
|
overrideFields: [],
|
||||||
|
restartRequired: ["autotracking.calibrate_on_startup"],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
host: {
|
host: {
|
||||||
"ui:options": { size: "sm" },
|
"ui:options": { size: "sm" },
|
||||||
},
|
},
|
||||||
|
profile: {
|
||||||
|
"ui:widget": "onvifProfile",
|
||||||
|
},
|
||||||
autotracking: {
|
autotracking: {
|
||||||
required_zones: {
|
required_zones: {
|
||||||
"ui:widget": "zoneNames",
|
"ui:widget": "zoneNames",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
|||||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||||
|
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||||
|
|
||||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||||
@ -79,6 +80,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
timezoneSelect: TimezoneSelectWidget,
|
timezoneSelect: TimezoneSelectWidget,
|
||||||
optionalField: OptionalFieldWidget,
|
optionalField: OptionalFieldWidget,
|
||||||
semanticSearchModel: SemanticSearchModelWidget,
|
semanticSearchModel: SemanticSearchModelWidget,
|
||||||
|
onvifProfile: OnvifProfileWidget,
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
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>
|
<p>
|
||||||
{clickOverlay
|
{clickOverlay
|
||||||
? t("ptz.move.clickMove.disable")
|
? 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>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -7,8 +7,14 @@ type PtzFeature =
|
|||||||
| "pt-r-fov"
|
| "pt-r-fov"
|
||||||
| "focus";
|
| "focus";
|
||||||
|
|
||||||
|
export type OnvifProfile = {
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CameraPtzInfo = {
|
export type CameraPtzInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
features: PtzFeature[];
|
features: PtzFeature[];
|
||||||
presets: string[];
|
presets: string[];
|
||||||
|
profiles: OnvifProfile[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -122,6 +122,11 @@ import {
|
|||||||
SnapshotResult,
|
SnapshotResult,
|
||||||
} from "@/utils/snapshotUtil";
|
} from "@/utils/snapshotUtil";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
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 = {
|
type LiveCameraViewProps = {
|
||||||
config?: FrigateConfig;
|
config?: FrigateConfig;
|
||||||
@ -213,45 +218,112 @@ export default function LiveCameraView({
|
|||||||
};
|
};
|
||||||
}, [audioTranscriptionState, sendTranscription]);
|
}, [audioTranscriptionState, sendTranscription]);
|
||||||
|
|
||||||
// click overlay for ptzs
|
// click-to-move / drag-to-zoom overlay for PTZ cameras
|
||||||
|
|
||||||
const [clickOverlay, setClickOverlay] = useState(false);
|
const [clickOverlay, setClickOverlay] = useState(false);
|
||||||
const clickOverlayRef = useRef<HTMLDivElement>(null);
|
const clickOverlayRef = useRef<HTMLDivElement>(null);
|
||||||
const { send: sendPtz } = usePtzCommand(camera.name);
|
const { send: sendPtz } = usePtzCommand(camera.name);
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
// drag rectangle state in stage-local coordinates
|
||||||
(
|
const [ptzRect, setPtzRect] = useState<{
|
||||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
x: number;
|
||||||
) => {
|
y: number;
|
||||||
if (!clickOverlay) {
|
width: number;
|
||||||
return;
|
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;
|
const [overlaySize] = useResizeObserver(clickOverlayRef);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clickOverlayRef.current && clientX && clientY) {
|
const onPtzStageDown = useCallback(
|
||||||
const rect = clickOverlayRef.current.getBoundingClientRect();
|
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
||||||
|
const pos = e.target.getStage()?.getPointerPosition();
|
||||||
const normalizedX = (clientX - rect.left) / rect.width;
|
if (pos) {
|
||||||
const normalizedY = (clientY - rect.top) / rect.height;
|
setIsPtzDrawing(true);
|
||||||
|
ptzOriginRef.current = { x: pos.x, y: pos.y };
|
||||||
const pan = (normalizedX - 0.5) * 2;
|
setPtzRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
|
||||||
const tilt = (0.5 - normalizedY) * 2;
|
|
||||||
|
|
||||||
sendPtz(`move_relative_${pan}_${tilt}`);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[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
|
// pip state
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -440,7 +512,8 @@ export default function LiveCameraView({
|
|||||||
<TransformWrapper
|
<TransformWrapper
|
||||||
minScale={1.0}
|
minScale={1.0}
|
||||||
wheel={{ smoothStep: 0.005 }}
|
wheel={{ smoothStep: 0.005 }}
|
||||||
disabled={debug}
|
disabled={debug || clickOverlay}
|
||||||
|
panning={{ disabled: clickOverlay }}
|
||||||
>
|
>
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<div
|
<div
|
||||||
@ -634,13 +707,41 @@ export default function LiveCameraView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center justify-center ${growClassName}`}
|
className={`relative flex flex-col items-center justify-center ${growClassName}`}
|
||||||
ref={clickOverlayRef}
|
ref={clickOverlayRef}
|
||||||
onClick={handleOverlayClick}
|
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: constrainedAspectRatio,
|
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
|
<LivePlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user