Files
frigate/frigate/ptz/onvif.py
T

1106 lines
44 KiB
Python
Raw Normal View History

"""Configure and control camera via onvif."""
import asyncio
import logging
2025-05-07 08:53:29 -05:00
import threading
2025-03-14 07:25:48 -05:00
import time
from enum import Enum
from importlib.util import find_spec
from pathlib import Path
2025-05-13 16:27:20 +02:00
from typing import Any
2023-05-29 12:31:17 +02:00
import numpy
from onvif import ONVIFCamera, ONVIFError, ONVIFService
2024-02-05 17:52:47 -06:00
from zeep.exceptions import Fault, TransportError
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
2026-03-25 08:57:47 -05:00
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.util.builtin import find_by_key
logger = logging.getLogger(__name__)
class OnvifCommandEnum(str, Enum):
"""Holds all possible move commands"""
init = "init"
move_down = "move_down"
move_left = "move_left"
move_relative = "move_relative"
move_right = "move_right"
move_up = "move_up"
preset = "preset"
stop = "stop"
zoom_in = "zoom_in"
zoom_out = "zoom_out"
2025-06-25 16:45:36 -05:00
focus_in = "focus_in"
focus_out = "focus_out"
class OnvifController:
ptz_metrics: dict[str, PTZMetrics]
def __init__(
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
) -> None:
2025-05-07 08:53:29 -05:00
self.cams: dict[str, dict] = {}
2025-03-14 07:25:48 -05:00
self.failed_cams: dict[str, dict] = {}
self.max_retries = 5
self.reset_timeout = 900 # 15 minutes
self.config = config
2023-07-11 06:23:20 -05:00
self.ptz_metrics = ptz_metrics
2025-09-01 19:18:50 -05:00
self.status_locks: dict[str, asyncio.Lock] = {}
2025-05-07 08:53:29 -05:00
# Create a dedicated event loop and run it in a separate thread
self.loop = asyncio.new_event_loop()
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
self.loop_thread.start()
self.camera_configs = {}
for cam_name, cam in config.cameras.items():
if not cam.enabled:
continue
if cam.onvif.host:
2025-05-07 08:53:29 -05:00
self.camera_configs[cam_name] = cam
2025-09-01 19:18:50 -05:00
self.status_locks[cam_name] = asyncio.Lock()
2025-03-14 07:25:48 -05:00
2026-03-25 08:57:47 -05:00
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[CameraConfigUpdateEnum.onvif],
)
2025-05-07 08:53:29 -05:00
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
2026-03-25 08:57:47 -05:00
asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop)
2025-05-07 08:53:29 -05:00
def _run_event_loop(self) -> None:
"""Run the event loop in a separate thread."""
asyncio.set_event_loop(self.loop)
2025-03-14 07:25:48 -05:00
try:
2025-05-07 08:53:29 -05:00
self.loop.run_forever()
except Exception as e:
logger.error(f"Onvif event loop terminated unexpectedly: {e}")
async def _init_cameras(self) -> None:
"""Initialize all configured cameras."""
for cam_name in self.camera_configs:
await self._init_single_camera(cam_name)
2026-03-25 08:57:47 -05:00
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)
2025-05-07 08:53:29 -05:00
async def _init_single_camera(self, cam_name: str) -> bool:
"""Initialize a single camera by name.
Args:
cam_name: The name of the camera to initialize
Returns:
bool: True if initialization succeeded, False otherwise
"""
if cam_name not in self.camera_configs:
logger.error(f"No configuration found for camera {cam_name}")
return False
cam = self.camera_configs[cam_name]
try:
self.cams[cam_name] = {
2025-03-14 07:25:48 -05:00
"onvif": ONVIFCamera(
cam.onvif.host,
cam.onvif.port,
cam.onvif.user,
cam.onvif.password,
2025-03-14 07:25:48 -05:00
wsdl_dir=str(Path(find_spec("onvif").origin).parent / "wsdl"),
adjust_time=cam.onvif.ignore_time_mismatch,
encrypt=not cam.onvif.tls_insecure,
),
"init": False,
"active": False,
"features": [],
"presets": {},
2026-03-25 08:57:47 -05:00
"profiles": [],
2025-03-14 07:25:48 -05:00
}
2025-05-07 08:53:29 -05:00
return True
2025-05-05 21:42:24 -05:00
except (Fault, ONVIFError, TransportError, Exception) as e:
2025-03-14 07:25:48 -05:00
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
# track initial failures
self.failed_cams[cam_name] = {
"retry_attempts": 0,
"last_error": str(e),
"last_attempt": time.time(),
}
2025-05-07 08:53:29 -05:00
return False
async def _init_onvif(self, camera_name: str) -> bool:
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
2025-03-22 07:38:33 -05:00
try:
await onvif.update_xaddrs()
except Exception as e:
logger.error(f"Onvif connection failed for {camera_name}: {e}")
return False
# create init services
media: ONVIFService = await onvif.create_media_service()
logger.debug(f"Onvif media xaddr for {camera_name}: {media.xaddr}")
try:
2024-02-05 17:52:47 -06:00
# this will fire an exception if camera is not a ptz
capabilities = onvif.get_definition("ptz")
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
2025-05-05 21:42:24 -05:00
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.error(
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
)
return False
try:
profiles = await media.GetProfiles()
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
2025-05-05 21:42:24 -05:00
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.error(
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
)
return False
2026-03-25 08:57:47 -05:00
# 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
2026-03-25 08:57:47 -05:00
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,
)
2026-03-25 08:57:47 -05:00
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(
f"No appropriate Onvif profiles found for camera: {camera_name}."
)
return False
2026-03-25 08:57:47 -05:00
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
# get the PTZ config for the profile
try:
configs = profile.PTZConfiguration
logger.debug(
f"Onvif ptz config for media profile in {camera_name}: {configs}"
)
except Exception as e:
logger.error(
f"Invalid Onvif PTZ configuration for camera: {camera_name}: {e}"
)
return False
ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz
2025-11-30 06:54:42 -06:00
try:
imaging: ONVIFService = await onvif.create_imaging_service()
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
imaging = None
2025-06-25 16:45:36 -05:00
self.cams[camera_name]["imaging"] = imaging
try:
video_sources = await media.GetVideoSources()
if video_sources and len(video_sources) > 0:
self.cams[camera_name]["video_source_token"] = video_sources[0].token
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
self.cams[camera_name]["video_source_token"] = None
# setup continuous moving request
move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token
self.cams[camera_name]["move_request"] = move_request
2026-03-25 08:57:47 -05:00
# 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
2025-02-11 06:19:20 -06:00
ptz_config = await ptz.GetConfigurationOptions(request)
2026-03-25 08:57:47 -05:00
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
)
2026-07-15 19:49:05 -05:00
# 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
2026-07-15 19:49:05 -05:00
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
self.cams[camera_name]["service_capabilities_request"] = (
service_capabilities_request
)
2026-03-25 08:57:47 -05:00
# 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:
2026-03-25 08:57:47 -05:00
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:
2026-03-25 08:57:47 -05:00
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
2026-03-25 08:57:47 -05:00
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 (
2026-03-25 08:57:47 -05:00
autotracking_enabled
and autotracking_config.zooming != ZoomingModeEnum.disabled
):
zoom_space_id = next(
(
i
for i, space in enumerate(
ptz_config.Spaces.RelativeZoomTranslationSpace
)
if "TranslationGenericSpace" in space["URI"]
),
None,
)
2026-03-25 08:57:47 -05:00
try:
if zoom_space_id is not None:
2026-03-25 08:57:47 -05:00
rel_move_request.Translation.Zoom.space = ptz_config["Spaces"][
"RelativeZoomTranslationSpace"
][zoom_space_id]["URI"]
2026-03-25 08:57:47 -05:00
except Exception as e:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
)
2026-03-25 08:57:47 -05:00
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}"
)
2026-03-25 08:57:47 -05:00
if rel_move_request.Speed is None:
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
logger.debug(
2026-03-25 08:57:47 -05:00
f"{camera_name}: Relative move request after setup: {rel_move_request}"
)
2026-03-25 08:57:47 -05:00
self.cams[camera_name]["relative_move_request"] = rel_move_request
2026-03-25 08:57:47 -05:00
# 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:
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
2025-05-05 21:42:24 -05:00
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
presets = []
for preset in presets:
2025-12-09 11:08:44 -07:00
preset_name = getattr(preset, "Name") or f"preset {preset['token']}"
# Some cameras (e.g. Reolink) return UTF-8 bytes that zeep decodes
# as latin-1, producing mojibake. Detect that and repair it by
# round-tripping through latin-1 -> utf-8.
try:
preset_name = preset_name.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
# get list of supported features
supported_features = []
2024-02-05 17:52:47 -06:00
if configs.DefaultContinuousPanTiltVelocitySpace:
supported_features.append("pt")
2024-02-05 17:52:47 -06:00
if configs.DefaultContinuousZoomVelocitySpace:
supported_features.append("zoom")
2024-02-05 17:52:47 -06:00
if configs.DefaultRelativePanTiltTranslationSpace:
supported_features.append("pt-r")
2024-02-05 17:52:47 -06:00
if configs.DefaultRelativeZoomTranslationSpace:
supported_features.append("zoom-r")
2026-03-25 08:57:47 -05:00
if ptz_config is not None:
try:
self.cams[camera_name]["relative_zoom_range"] = (
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
)
2025-05-07 08:53:29 -05:00
except Exception as e:
2026-03-25 08:57:47 -05:00
if autotracking_config.zooming == ZoomingModeEnum.relative:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
2025-05-07 08:53:29 -05:00
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
)
2024-02-05 17:52:47 -06:00
if configs.DefaultAbsoluteZoomPositionSpace:
supported_features.append("zoom-a")
2026-03-25 08:57:47 -05:00
if ptz_config is not None:
try:
self.cams[camera_name]["absolute_zoom_range"] = (
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
)
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
2025-05-07 08:53:29 -05:00
except Exception as e:
2026-03-25 08:57:47 -05:00
if autotracking_config.zooming != ZoomingModeEnum.disabled:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
2025-05-07 08:53:29 -05:00
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
)
2026-03-25 08:57:47 -05:00
# 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"
)
2025-11-30 06:54:42 -06:00
if (
self.cams[camera_name]["video_source_token"] is not None
and imaging is not None
):
2025-06-25 16:45:36 -05:00
try:
imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
)
if (
hasattr(imaging_capabilities, "Focus")
and imaging_capabilities.Focus
):
supported_features.append("focus")
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.debug(f"Focus not supported for {camera_name}: {e}")
2026-03-25 08:57:47 -05:00
# detect FOV relative movement support
2024-02-05 17:52:47 -06:00
if (
2026-03-25 08:57:47 -05:00
fov_space_id is not None
2024-02-05 17:52:47 -06:00
and configs.DefaultRelativePanTiltTranslationSpace is not None
):
supported_features.append("pt-r-fov")
2024-02-29 16:10:13 -07:00
self.cams[camera_name]["relative_fov_range"] = (
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
)
self.cams[camera_name]["features"] = supported_features
self.cams[camera_name]["init"] = True
return True
2025-05-07 08:53:29 -05:00
async def _stop(self, camera_name: str) -> None:
move_request = self.cams[camera_name]["move_request"]
2025-05-07 08:53:29 -05:00
await self.cams[camera_name]["ptz"].Stop(
{
"ProfileToken": move_request.ProfileToken,
"PanTilt": True,
"Zoom": True,
}
)
2025-06-25 16:45:36 -05:00
if (
"focus" in self.cams[camera_name]["features"]
and self.cams[camera_name]["video_source_token"]
2025-11-30 06:54:42 -06:00
and self.cams[camera_name]["imaging"] is not None
2025-06-25 16:45:36 -05:00
):
try:
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
stop_request.VideoSourceToken = self.cams[camera_name][
"video_source_token"
]
await self.cams[camera_name]["imaging"].Stop(stop_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
self.cams[camera_name]["active"] = False
2025-05-07 08:53:29 -05:00
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
2025-05-07 08:53:29 -05:00
await self._stop(camera_name)
if "pt" not in self.cams[camera_name]["features"]:
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
if command == OnvifCommandEnum.move_left:
move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}}
elif command == OnvifCommandEnum.move_right:
move_request.Velocity = {"PanTilt": {"x": 0.5, "y": 0}}
elif command == OnvifCommandEnum.move_up:
move_request.Velocity = {
"PanTilt": {
"x": 0,
"y": 0.5,
}
}
elif command == OnvifCommandEnum.move_down:
move_request.Velocity = {
"PanTilt": {
"x": 0,
"y": -0.5,
}
}
try:
2025-05-07 08:53:29 -05:00
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
2025-05-05 21:42:24 -05:00
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
2025-05-07 08:53:29 -05:00
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
if "pt-r-fov" not in self.cams[camera_name]["features"]:
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
return
logger.debug(
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
)
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
2026-08-03 08:18:28 -05:00
# only track start_time for autotracking
if self.ptz_metrics[camera_name].autotracker_enabled.value:
self.ptz_metrics[camera_name].motor_stopped.clear()
logger.debug(
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
)
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
camera_name
].frame_time.value
self.ptz_metrics[camera_name].stop_time.value = 0
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.
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
pan = numpy.interp(
pan,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
],
)
tilt = numpy.interp(
tilt,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
],
)
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
}
move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt
2026-03-25 08:57:47 -05:00
# include zoom if requested and camera supports relative zoom
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
"Zoom": {"x": speed},
}
2026-03-25 08:57:47 -05:00
move_request["Translation"]["Zoom"] = {"x": zoom}
2025-05-07 08:53:29 -05:00
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
# reset after the move request
move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 0
2026-03-25 08:57:47 -05:00
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
del move_request["Translation"]["Zoom"]
self.cams[camera_name]["active"] = False
2025-05-07 08:53:29 -05:00
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
2025-12-09 11:08:44 -07:00
preset = preset.lower()
2023-05-29 12:31:17 +02:00
if preset not in self.cams[camera_name]["presets"]:
logger.error(f"{preset} is not a valid preset for {camera_name}")
return
self.cams[camera_name]["active"] = True
self.ptz_metrics[camera_name].start_time.value = 0
self.ptz_metrics[camera_name].stop_time.value = 0
move_request = self.cams[camera_name]["move_request"]
preset_token = self.cams[camera_name]["presets"][preset]
2025-05-07 08:53:29 -05:00
await self.cams[camera_name]["ptz"].GotoPreset(
{
"ProfileToken": move_request.ProfileToken,
"PresetToken": preset_token,
}
)
self.cams[camera_name]["active"] = False
2025-05-07 08:53:29 -05:00
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
2025-05-07 08:53:29 -05:00
await self._stop(camera_name)
if "zoom" not in self.cams[camera_name]["features"]:
logger.error(f"{camera_name} does not support ONVIF zooming.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
if command == OnvifCommandEnum.zoom_in:
move_request.Velocity = {"Zoom": {"x": 0.5}}
elif command == OnvifCommandEnum.zoom_out:
move_request.Velocity = {"Zoom": {"x": -0.5}}
2025-05-07 08:53:29 -05:00
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
2025-05-07 08:53:29 -05:00
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
if "zoom-a" not in self.cams[camera_name]["features"]:
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
return
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
self.ptz_metrics[camera_name].motor_stopped.clear()
logger.debug(
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
)
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
camera_name
].frame_time.value
self.ptz_metrics[camera_name].stop_time.value = 0
move_request = self.cams[camera_name]["absolute_move_request"]
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
zoom = numpy.interp(
zoom,
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
],
)
move_request.Speed = {"Zoom": speed}
move_request.Position = {"Zoom": zoom}
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
2025-05-07 08:53:29 -05:00
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
self.cams[camera_name]["active"] = False
2025-06-25 16:45:36 -05:00
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
await self._stop(camera_name)
if (
"focus" not in self.cams[camera_name]["features"]
or not self.cams[camera_name]["video_source_token"]
2025-11-30 06:54:42 -06:00
or self.cams[camera_name]["imaging"] is None
2025-06-25 16:45:36 -05:00
):
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["imaging"].create_type("Move")
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
move_request.Focus = {
"Continuous": {
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
}
}
try:
await self.cams[camera_name]["imaging"].Move(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
self.cams[camera_name]["active"] = False
2025-05-07 08:53:29 -05:00
async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None:
2025-05-07 08:53:29 -05:00
"""Handle ONVIF commands asynchronously"""
if camera_name not in self.cams.keys():
2025-03-14 07:25:48 -05:00
logger.error(f"ONVIF is not configured for {camera_name}")
return
if not self.cams[camera_name]["init"]:
2025-05-07 08:53:29 -05:00
if not await self._init_onvif(camera_name):
return
2024-12-19 09:46:14 -06:00
try:
if command == OnvifCommandEnum.init:
# already init
return
elif command == OnvifCommandEnum.stop:
2025-05-07 08:53:29 -05:00
await self._stop(camera_name)
2024-12-19 09:46:14 -06:00
elif command == OnvifCommandEnum.preset:
2025-05-07 08:53:29 -05:00
await self._move_to_preset(camera_name, param)
2024-12-19 09:46:14 -06:00
elif command == OnvifCommandEnum.move_relative:
2026-03-25 08:57:47 -05:00
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
)
2025-06-25 16:45:36 -05:00
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
2025-05-07 08:53:29 -05:00
await self._zoom(camera_name, command)
2025-06-25 16:45:36 -05:00
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
await self._focus(camera_name, command)
2024-12-19 09:46:14 -06:00
else:
2025-05-07 08:53:29 -05:00
await self._move(camera_name, command)
2025-05-05 21:42:24 -05:00
except (Fault, ONVIFError, TransportError, Exception) as e:
2024-12-19 09:46:14 -06:00
logger.error(f"Unable to handle onvif command: {e}")
2025-05-07 08:53:29 -05:00
def handle_command(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None:
"""
Handle ONVIF commands by scheduling them in the event loop.
"""
future = asyncio.run_coroutine_threadsafe(
self.handle_command_async(camera_name, command, param), self.loop
)
try:
# Wait with a timeout to prevent blocking indefinitely
future.result(timeout=10)
2026-07-06 09:28:02 -08:00
except TimeoutError:
2025-05-07 08:53:29 -05:00
logger.error(f"Command {command} timed out for camera {camera_name}")
except Exception as e:
logger.error(
f"Error executing command {command} for camera {camera_name}: {e}"
)
2025-05-13 16:27:20 +02:00
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
2025-03-14 07:25:48 -05:00
"""
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
but not initialized.
Returns camera details including features and presets if available.
"""
if not self.config.cameras[camera_name].enabled:
logger.debug(
f"Camera {camera_name} disabled, won't try to initialize ONVIF"
)
return {}
2025-05-07 08:53:29 -05:00
if camera_name not in self.cams.keys() and (
2025-03-14 07:25:48 -05:00
camera_name not in self.config.cameras
or not self.config.cameras[camera_name].onvif.host
):
logger.debug(f"ONVIF is not configured for {camera_name}")
return {}
2025-05-07 08:53:29 -05:00
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
2025-03-14 07:25:48 -05:00
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
2026-03-25 08:57:47 -05:00
"profiles": self.cams[camera_name].get("profiles", []),
2025-03-14 07:25:48 -05:00
}
2025-05-07 08:53:29 -05:00
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
success = await self._init_single_camera(camera_name)
if not success:
2025-03-14 07:25:48 -05:00
return {}
# Reset retry count after timeout
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
logger.debug(f"Resetting retry count for {camera_name} after timeout")
attempts = 0
self.failed_cams[camera_name]["retry_attempts"] = 0
# Attempt initialization/reconnection
if attempts < self.max_retries:
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
if camera_name in self.failed_cams:
del self.failed_cams[camera_name]
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
}
else:
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
)
if camera_name not in self.failed_cams:
self.failed_cams[camera_name] = {"retry_attempts": 0}
self.failed_cams[camera_name].update(
{
"retry_attempts": attempts + 1,
"last_error": str(e),
"last_attempt": time.time(),
}
)
if attempts >= self.max_retries:
remaining_time = max(
0, int((self.reset_timeout - (time.time() - last_attempt)) / 60)
)
logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
)
logger.debug(f"Could not initialize ONVIF for {camera_name}")
return {}
2025-05-07 08:53:29 -05:00
async def get_service_capabilities(self, camera_name: str) -> None:
if camera_name not in self.cams.keys():
2025-03-14 07:25:48 -05:00
logger.error(f"ONVIF is not configured for {camera_name}")
return {}
if not self.cams[camera_name]["init"]:
2025-05-07 08:53:29 -05:00
await self._init_onvif(camera_name)
service_capabilities_request = self.cams[camera_name][
"service_capabilities_request"
]
try:
2025-05-07 08:53:29 -05:00
service_capabilities = await self.cams[camera_name][
"ptz"
].GetServiceCapabilities(service_capabilities_request)
logger.debug(
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
)
# MoveStatus is required for autotracking - should return "true" if supported
return find_by_key(vars(service_capabilities), "MoveStatus")
2025-05-07 08:53:29 -05:00
except Exception as e:
logger.warning(
2025-05-07 08:53:29 -05:00
f"Camera {camera_name} does not support the ONVIF GetServiceCapabilities method. Autotracking will not function correctly and must be disabled in your config. Exception: {e}"
)
return False
2025-05-07 08:53:29 -05:00
async def get_camera_status(self, camera_name: str) -> None:
2025-09-01 19:18:50 -05:00
async with self.status_locks[camera_name]:
if camera_name not in self.cams.keys():
logger.error(f"ONVIF is not configured for {camera_name}")
2025-05-07 08:53:29 -05:00
return
2025-09-01 19:18:50 -05:00
if not self.cams[camera_name]["init"]:
if not await self._init_onvif(camera_name):
return
2025-09-01 19:18:50 -05:00
status_request = self.cams[camera_name]["status_request"]
try:
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
except Exception:
pass # We're unsupported, that'll be reported in the next check.
2025-09-01 19:18:50 -05:00
try:
pan_tilt_status = getattr(status.MoveStatus, "PanTilt", None)
zoom_status = getattr(status.MoveStatus, "Zoom", None)
2025-09-01 19:18:50 -05:00
# if it's not an attribute, see if MoveStatus even exists in the status result
if pan_tilt_status is None:
pan_tilt_status = getattr(status, "MoveStatus", None)
# we're unsupported
if pan_tilt_status is None or pan_tilt_status not in [
"IDLE",
"MOVING",
]:
raise Exception
except Exception:
logger.warning(
f"Camera {camera_name} does not support the ONVIF GetStatus method. Autotracking will not function correctly and must be disabled in your config."
)
return
logger.debug(
f"{camera_name}: Pan/tilt status: {pan_tilt_status}, Zoom status: {zoom_status}"
2024-02-02 06:23:14 -06:00
)
2025-09-01 19:18:50 -05:00
if pan_tilt_status == "IDLE" and (
zoom_status is None or zoom_status == "IDLE"
):
self.cams[camera_name]["active"] = False
if not self.ptz_metrics[camera_name].motor_stopped.is_set():
self.ptz_metrics[camera_name].motor_stopped.set()
2025-06-23 18:40:21 -05:00
2025-09-01 19:18:50 -05:00
logger.debug(
f"{camera_name}: PTZ stop time: {self.ptz_metrics[camera_name].frame_time.value}"
)
2023-07-11 06:23:20 -05:00
2025-09-01 19:18:50 -05:00
self.ptz_metrics[camera_name].stop_time.value = self.ptz_metrics[
camera_name
].frame_time.value
else:
self.cams[camera_name]["active"] = True
if self.ptz_metrics[camera_name].motor_stopped.is_set():
self.ptz_metrics[camera_name].motor_stopped.clear()
logger.debug(
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
)
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
camera_name
].frame_time.value
self.ptz_metrics[camera_name].stop_time.value = 0
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
!= ZoomingModeEnum.disabled
):
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
self.ptz_metrics[camera_name].zoom_level.value = numpy.interp(
round(status.Position.Zoom.x, 2),
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
],
[0, 1],
)
logger.debug(
2025-09-01 19:18:50 -05:00
f"{camera_name}: Camera zoom level: {self.ptz_metrics[camera_name].zoom_level.value}"
)
2023-07-11 06:23:20 -05:00
2025-09-01 19:18:50 -05:00
# some hikvision cams won't update MoveStatus, so warn if it hasn't changed
if (
not self.ptz_metrics[camera_name].motor_stopped.is_set()
and not self.ptz_metrics[camera_name].reset.is_set()
and self.ptz_metrics[camera_name].start_time.value != 0
and self.ptz_metrics[camera_name].frame_time.value
> (self.ptz_metrics[camera_name].start_time.value + 10)
and self.ptz_metrics[camera_name].stop_time.value == 0
):
logger.debug(
f"Start time: {self.ptz_metrics[camera_name].start_time.value}, Stop time: {self.ptz_metrics[camera_name].stop_time.value}, Frame time: {self.ptz_metrics[camera_name].frame_time.value}"
)
# set the stop time so we don't come back into this again and spam the logs
self.ptz_metrics[camera_name].stop_time.value = self.ptz_metrics[
camera_name
].frame_time.value
2025-09-01 19:18:50 -05:00
logger.warning(
f"Camera {camera_name} is still in ONVIF 'MOVING' status."
)
2023-07-11 06:23:20 -05:00
2025-05-07 08:53:29 -05:00
def close(self) -> None:
"""Gracefully shut down the ONVIF controller."""
if not hasattr(self, "loop") or self.loop.is_closed():
logger.debug("ONVIF controller already closed")
return
logger.info("Exiting ONVIF controller...")
2026-03-25 08:57:47 -05:00
self.config_subscriber.stop()
2025-05-07 08:53:29 -05:00
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()