2023-04-26 14:08:53 +03:00
""" Configure and control camera via onvif. """
2025-01-08 17:40:37 +03:00
import asyncio
2023-04-26 14:08:53 +03:00
import logging
2025-05-07 16:53:29 +03:00
import threading
2025-03-14 15:25:48 +03:00
import time
2023-04-26 14:08:53 +03:00
from enum import Enum
2024-04-13 20:25:58 +03:00
from importlib . util import find_spec
from pathlib import Path
2025-05-13 17:27:20 +03:00
from typing import Any
2023-05-29 13:31:17 +03:00
2023-07-08 15:04:47 +03:00
import numpy
2025-01-08 17:40:37 +03:00
from onvif import ONVIFCamera , ONVIFError , ONVIFService
2024-02-06 02:52:47 +03:00
from zeep . exceptions import Fault , TransportError
2023-04-26 14:08:53 +03:00
2024-09-27 15:53:23 +03:00
from frigate . camera import PTZMetrics
2023-09-27 14:19:10 +03:00
from frigate . config import FrigateConfig , ZoomingModeEnum
from frigate . util . builtin import find_by_key
2023-04-26 14:08:53 +03:00
logger = logging . getLogger ( __name__ )
class OnvifCommandEnum ( str , Enum ) :
""" Holds all possible move commands """
init = " init "
move_down = " move_down "
move_left = " move_left "
2024-03-23 19:53:33 +03:00
move_relative = " move_relative "
2023-04-26 14:08:53 +03:00
move_right = " move_right "
move_up = " move_up "
preset = " preset "
stop = " stop "
zoom_in = " zoom_in "
zoom_out = " zoom_out "
2025-06-26 00:45:36 +03:00
focus_in = " focus_in "
focus_out = " focus_out "
2023-04-26 14:08:53 +03:00
class OnvifController :
2024-09-27 15:53:23 +03:00
ptz_metrics : dict [ str , PTZMetrics ]
2023-07-08 15:04:47 +03:00
def __init__ (
2024-09-27 15:53:23 +03:00
self , config : FrigateConfig , ptz_metrics : dict [ str , PTZMetrics ]
2023-07-08 15:04:47 +03:00
) - > None :
2025-05-07 16:53:29 +03:00
self . cams : dict [ str , dict ] = { }
2025-03-14 15:25:48 +03:00
self . failed_cams : dict [ str , dict ] = { }
self . max_retries = 5
self . reset_timeout = 900 # 15 minutes
2023-09-27 14:19:10 +03:00
self . config = config
2023-07-11 14:23:20 +03:00
self . ptz_metrics = ptz_metrics
2023-04-26 14:08:53 +03:00
2025-09-02 03:18:50 +03:00
self . status_locks : dict [ str , asyncio . Lock ] = { }
2025-05-07 16:53:29 +03: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 = { }
2023-04-26 14:08:53 +03:00
for cam_name , cam in config . cameras . items ( ) :
if not cam . enabled :
continue
if cam . onvif . host :
2025-05-07 16:53:29 +03:00
self . camera_configs [ cam_name ] = cam
2025-09-02 03:18:50 +03:00
self . status_locks [ cam_name ] = asyncio . Lock ( )
2025-05-07 16:53:29 +03:00
asyncio . run_coroutine_threadsafe ( self . _init_cameras ( ) , self . loop )
2025-03-14 15:25:48 +03:00
2025-05-07 16:53:29 +03:00
def _run_event_loop ( self ) - > None :
""" Run the event loop in a separate thread. """
asyncio . set_event_loop ( self . loop )
2025-03-14 15:25:48 +03:00
try :
2025-05-07 16:53:29 +03: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 )
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 :
2025-12-09 21:08:44 +03:00
user = cam . onvif . user
password = cam . onvif . password
if user is not None and isinstance ( user , bytes ) :
user = user . decode ( " utf-8 " )
if password is not None and isinstance ( password , bytes ) :
password = password . decode ( " utf-8 " )
2025-05-07 16:53:29 +03:00
self . cams [ cam_name ] = {
2025-03-14 15:25:48 +03:00
" onvif " : ONVIFCamera (
cam . onvif . host ,
cam . onvif . port ,
2025-12-09 21:08:44 +03:00
user ,
password ,
2025-03-14 15:25:48 +03: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 " : { } ,
}
2025-05-07 16:53:29 +03:00
return True
2025-05-06 05:42:24 +03:00
except ( Fault , ONVIFError , TransportError , Exception ) as e :
2025-03-14 15:25:48 +03: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 16:53:29 +03:00
return False
2023-04-26 14:08:53 +03:00
2025-01-08 17:40:37 +03:00
async def _init_onvif ( self , camera_name : str ) - > bool :
2023-04-26 14:08:53 +03:00
onvif : ONVIFCamera = self . cams [ camera_name ] [ " onvif " ]
2025-03-22 15:38:33 +03:00
try :
await onvif . update_xaddrs ( )
except Exception as e :
logger . error ( f " Onvif connection failed for { camera_name } : { e } " )
return False
2023-04-26 14:08:53 +03:00
# create init services
2025-01-08 17:40:37 +03:00
media : ONVIFService = await onvif . create_media_service ( )
2024-02-10 22:41:24 +03:00
logger . debug ( f " Onvif media xaddr for { camera_name } : { media . xaddr } " )
2023-04-26 14:08:53 +03:00
try :
2024-02-06 02:52:47 +03: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-06 05:42:24 +03:00
except ( Fault , ONVIFError , TransportError , Exception ) as e :
2024-02-10 22:41:24 +03:00
logger . error (
f " Unable to get Onvif capabilities for camera: { camera_name } : { e } "
)
2023-04-26 14:08:53 +03:00
return False
2024-02-10 22:41:24 +03:00
try :
2025-01-08 17:40:37 +03:00
profiles = await media . GetProfiles ( )
2024-07-14 20:12:26 +03:00
logger . debug ( f " Onvif profiles for { camera_name } : { profiles } " )
2025-05-06 05:42:24 +03:00
except ( Fault , ONVIFError , TransportError , Exception ) as e :
2024-02-10 22:41:24 +03:00
logger . error (
f " Unable to get Onvif media profiles for camera: { camera_name } : { e } "
)
return False
profile = None
2025-01-08 17:40:37 +03:00
for _ , onvif_profile in enumerate ( profiles ) :
2024-02-10 22:41:24 +03:00
if (
onvif_profile . VideoEncoderConfiguration
2024-02-24 16:49:34 +03:00
and onvif_profile . PTZConfiguration
2024-07-12 20:01:52 +03:00
and (
onvif_profile . PTZConfiguration . DefaultContinuousPanTiltVelocitySpace
is not None
or onvif_profile . PTZConfiguration . DefaultContinuousZoomVelocitySpace
is not None
)
2024-02-10 22:41:24 +03:00
) :
2024-07-14 21:29:49 +03:00
# use the first profile that has a valid ptz configuration
2024-02-10 22:41:24 +03:00
profile = onvif_profile
logger . debug ( f " Selected Onvif profile for { camera_name } : { profile } " )
break
if profile is None :
logger . error (
f " No appropriate Onvif profiles found for camera: { camera_name } . "
)
return False
2023-09-27 14:19:10 +03:00
2024-02-10 22:41:24 +03:00
# 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
2023-09-27 14:19:10 +03:00
2025-01-08 17:40:37 +03:00
ptz : ONVIFService = await onvif . create_ptz_service ( )
self . cams [ camera_name ] [ " ptz " ] = ptz
2023-09-27 14:19:10 +03:00
2025-11-30 15:54:42 +03: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-26 00:45:36 +03: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
2024-03-09 17:48:31 +03:00
# setup continuous moving request
move_request = ptz . create_type ( " ContinuousMove " )
move_request . ProfileToken = profile . token
self . cams [ camera_name ] [ " move_request " ] = move_request
2023-10-22 17:08:05 +03:00
2024-03-09 17:48:31 +03:00
# extra setup for autotracking cameras
2024-02-24 16:49:34 +03:00
if (
2024-03-09 17:48:31 +03:00
self . config . cameras [ camera_name ] . onvif . autotracking . enabled_in_config
and self . config . cameras [ camera_name ] . onvif . autotracking . enabled
2024-02-24 16:49:34 +03:00
) :
2024-03-09 17:48:31 +03:00
request = ptz . create_type ( " GetConfigurationOptions " )
request . ConfigurationToken = profile . PTZConfiguration . token
2025-02-11 15:19:20 +03:00
ptz_config = await ptz . GetConfigurationOptions ( request )
2024-03-09 17:48:31 +03:00
logger . debug ( f " Onvif config for { camera_name } : { ptz_config } " )
service_capabilities_request = ptz . create_type ( " GetServiceCapabilities " )
self . cams [ camera_name ] [ " service_capabilities_request " ] = (
service_capabilities_request
)
fov_space_id = next (
2023-09-27 14:19:10 +03:00
(
i
for i , space in enumerate (
2024-03-09 17:48:31 +03:00
ptz_config . Spaces . RelativePanTiltTranslationSpace
2023-09-27 14:19:10 +03:00
)
2024-03-09 17:48:31 +03:00
if " TranslationSpaceFov " in space [ " URI " ]
2023-09-27 14:19:10 +03:00
) ,
None ,
)
2024-03-09 17:48:31 +03:00
# 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 :
2025-02-11 15:19:20 +03:00
status = await ptz . GetStatus ( status_request )
2024-03-09 17:48:31 +03:00
logger . debug ( f " Onvif status config for { camera_name } : { status } " )
except Exception as e :
logger . warning ( f " Unable to get status from camera: { camera_name } : { e } " )
status = None
2024-05-20 16:37:56 +03:00
# autotracking relative panning/tilting needs a relative zoom value set to 0
2024-03-09 17:48:31 +03:00
# if camera supports relative movement
2023-10-07 17:17:54 +03:00
if (
self . config . cameras [ camera_name ] . onvif . autotracking . zooming
2024-02-24 16:49:34 +03:00
!= ZoomingModeEnum . disabled
2023-10-07 17:17:54 +03:00
) :
2024-03-09 17:48:31 +03:00
zoom_space_id = next (
(
i
for i , space in enumerate (
ptz_config . Spaces . RelativeZoomTranslationSpace
)
if " TranslationGenericSpace " in space [ " URI " ]
) ,
None ,
)
2023-09-01 15:07:18 +03:00
2024-03-09 17:48:31 +03:00
# 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 :
move_request . Translation . Zoom . space = ptz_config [ " Spaces " ] [
" RelativeZoomTranslationSpace "
] [ zoom_space_id ] [ " URI " ]
else :
2025-06-26 00:45:24 +03:00
if (
move_request [ " Translation " ] is not None
and " Zoom " in move_request [ " Translation " ]
) :
2024-09-23 23:34:08 +03:00
del move_request [ " Translation " ] [ " Zoom " ]
2025-06-26 00:45:24 +03:00
if (
move_request [ " Speed " ] is not None
and " Zoom " in move_request [ " Speed " ]
) :
2024-09-23 23:34:08 +03:00
del move_request [ " Speed " ] [ " Zoom " ]
logger . debug (
f " { camera_name } : Relative move request after deleting zoom: { move_request } "
)
2025-05-07 16:53:29 +03:00
except Exception as e :
2024-03-09 17:48:31 +03:00
self . config . cameras [
camera_name
] . onvif . autotracking . zooming = ZoomingModeEnum . disabled
logger . warning (
2025-05-07 16:53:29 +03:00
f " Disabling autotracking zooming for { camera_name } : Relative zoom not supported. Exception: { e } "
2024-03-09 17:48:31 +03:00
)
2023-07-08 15:04:47 +03:00
2024-03-09 17:48:31 +03:00
if move_request . Speed is None :
move_request . Speed = configs . DefaultPTZSpeed if configs else None
logger . debug (
f " { camera_name } : Relative move request after setup: { move_request } "
)
self . cams [ camera_name ] [ " relative_move_request " ] = 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
2023-07-08 15:04:47 +03:00
2023-04-26 14:08:53 +03:00
# setup existing presets
try :
2025-01-08 17:40:37 +03:00
presets : list [ dict ] = await ptz . GetPresets ( { " ProfileToken " : profile . token } )
2025-05-06 05:42:24 +03:00
except ( Fault , ONVIFError , TransportError , Exception ) as e :
2023-05-17 15:42:56 +03:00
logger . warning ( f " Unable to get presets from camera: { camera_name } : { e } " )
presets = [ ]
2023-04-26 14:08:53 +03:00
for preset in presets :
2025-12-09 21:08:44 +03:00
# Ensure preset name is a Unicode string and handle UTF-8 characters correctly
preset_name = getattr ( preset , " Name " ) or f " preset { preset [ ' token ' ] } "
if isinstance ( preset_name , bytes ) :
preset_name = preset_name . decode ( " utf-8 " )
# Convert to lowercase while preserving UTF-8 characters
preset_name_lower = preset_name . lower ( )
self . cams [ camera_name ] [ " presets " ] [ preset_name_lower ] = preset [ " token " ]
2023-04-26 14:08:53 +03:00
# get list of supported features
supported_features = [ ]
2024-02-06 02:52:47 +03:00
if configs . DefaultContinuousPanTiltVelocitySpace :
2023-04-26 14:08:53 +03:00
supported_features . append ( " pt " )
2024-02-06 02:52:47 +03:00
if configs . DefaultContinuousZoomVelocitySpace :
2023-04-26 14:08:53 +03:00
supported_features . append ( " zoom " )
2024-02-06 02:52:47 +03:00
if configs . DefaultRelativePanTiltTranslationSpace :
2023-07-08 15:04:47 +03:00
supported_features . append ( " pt-r " )
2024-02-06 02:52:47 +03:00
if configs . DefaultRelativeZoomTranslationSpace :
2023-07-08 15:04:47 +03:00
supported_features . append ( " zoom-r " )
2024-03-09 17:48:31 +03:00
if (
self . config . cameras [ camera_name ] . onvif . autotracking . enabled_in_config
and self . config . cameras [ camera_name ] . onvif . autotracking . enabled
) :
try :
# get camera's zoom limits from onvif config
self . cams [ camera_name ] [ " relative_zoom_range " ] = (
ptz_config . Spaces . RelativeZoomTranslationSpace [ 0 ]
2023-10-22 19:59:13 +03:00
)
2025-05-07 16:53:29 +03:00
except Exception as e :
2024-03-09 17:48:31 +03:00
if (
self . config . cameras [ camera_name ] . onvif . autotracking . zooming
== ZoomingModeEnum . relative
) :
self . config . cameras [
camera_name
] . onvif . autotracking . zooming = ZoomingModeEnum . disabled
logger . warning (
2025-05-07 16:53:29 +03:00
f " Disabling autotracking zooming for { camera_name } : Relative zoom not supported. Exception: { e } "
2024-03-09 17:48:31 +03:00
)
2023-07-08 15:04:47 +03:00
2024-02-06 02:52:47 +03:00
if configs . DefaultAbsoluteZoomPositionSpace :
2023-09-27 14:19:10 +03:00
supported_features . append ( " zoom-a " )
2024-03-09 17:48:31 +03:00
if (
self . config . cameras [ camera_name ] . onvif . autotracking . enabled_in_config
and self . config . cameras [ camera_name ] . onvif . autotracking . enabled
) :
try :
# get camera's zoom limits from onvif config
self . cams [ camera_name ] [ " absolute_zoom_range " ] = (
ptz_config . Spaces . AbsoluteZoomPositionSpace [ 0 ]
2023-09-27 14:19:10 +03:00
)
2024-03-09 17:48:31 +03:00
self . cams [ camera_name ] [ " zoom_limits " ] = configs . ZoomLimits
2025-05-07 16:53:29 +03:00
except Exception as e :
2024-03-09 17:48:31 +03:00
if self . config . cameras [ camera_name ] . onvif . autotracking . zooming :
self . config . cameras [
camera_name
] . onvif . autotracking . zooming = ZoomingModeEnum . disabled
logger . warning (
2025-05-07 16:53:29 +03:00
f " Disabling autotracking zooming for { camera_name } : Absolute zoom not supported. Exception: { e } "
2024-03-09 17:48:31 +03:00
)
2023-09-27 14:19:10 +03:00
2025-11-30 15:54:42 +03:00
if (
self . cams [ camera_name ] [ " video_source_token " ] is not None
and imaging is not None
) :
2025-06-26 00:45:36 +03: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 } " )
2024-02-06 02:52:47 +03:00
if (
2024-03-09 17:48:31 +03:00
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
2024-02-06 02:52:47 +03:00
and configs . DefaultRelativePanTiltTranslationSpace is not None
) :
2023-07-08 15:04:47 +03:00
supported_features . append ( " pt-r-fov " )
2024-03-01 02:10:13 +03:00
self . cams [ camera_name ] [ " relative_fov_range " ] = (
ptz_config . Spaces . RelativePanTiltTranslationSpace [ fov_space_id ]
)
2023-07-08 15:04:47 +03:00
2023-04-26 14:08:53 +03:00
self . cams [ camera_name ] [ " features " ] = supported_features
self . cams [ camera_name ] [ " init " ] = True
return True
2025-05-07 16:53:29 +03:00
async def _stop ( self , camera_name : str ) - > None :
2023-04-26 14:08:53 +03:00
move_request = self . cams [ camera_name ] [ " move_request " ]
2025-05-07 16:53:29 +03:00
await self . cams [ camera_name ] [ " ptz " ] . Stop (
{
" ProfileToken " : move_request . ProfileToken ,
" PanTilt " : True ,
" Zoom " : True ,
}
2023-04-26 14:08:53 +03:00
)
2025-06-26 00:45:36 +03:00
if (
" focus " in self . cams [ camera_name ] [ " features " ]
and self . cams [ camera_name ] [ " video_source_token " ]
2025-11-30 15:54:42 +03:00
and self . cams [ camera_name ] [ " imaging " ] is not None
2025-06-26 00:45:36 +03: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 } " )
2023-04-26 14:08:53 +03:00
self . cams [ camera_name ] [ " active " ] = False
2025-05-07 16:53:29 +03:00
async def _move ( self , camera_name : str , command : OnvifCommandEnum ) - > None :
2023-04-26 14:08:53 +03:00
if self . cams [ camera_name ] [ " active " ] :
logger . warning (
f " { camera_name } is already performing an action, stopping... "
)
2025-05-07 16:53:29 +03:00
await self . _stop ( camera_name )
2023-04-26 14:08:53 +03:00
2024-09-16 19:46:35 +03:00
if " pt " not in self . cams [ camera_name ] [ " features " ] :
logger . error ( f " { camera_name } does not support ONVIF pan/tilt movement. " )
return
2023-04-26 14:08:53 +03:00
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 ,
}
}
2024-04-01 22:19:24 +03:00
try :
2025-05-07 16:53:29 +03:00
await self . cams [ camera_name ] [ " ptz " ] . ContinuousMove ( move_request )
2025-05-06 05:42:24 +03:00
except ( Fault , ONVIFError , TransportError , Exception ) as e :
2024-04-01 22:19:24 +03:00
logger . warning ( f " Onvif sending move request to { camera_name } failed: { e } " )
2023-04-26 14:08:53 +03:00
2025-05-07 16:53:29 +03:00
async def _move_relative ( self , camera_name : str , pan , tilt , zoom , speed ) - > None :
2023-09-27 14:19:10 +03:00
if " pt-r-fov " not in self . cams [ camera_name ] [ " features " ] :
2023-07-08 15:04:47 +03:00
logger . error ( f " { camera_name } does not support ONVIF RelativeMove (FOV). " )
return
2023-10-22 19:59:13 +03:00
logger . debug (
f " { camera_name } called RelativeMove: pan: { pan } tilt: { tilt } zoom: { zoom } "
)
2023-07-08 15:04:47 +03:00
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
2024-09-27 15:53:23 +03:00
self . ptz_metrics [ camera_name ] . motor_stopped . clear ( )
2023-09-27 14:19:10 +03:00
logger . debug (
2024-09-27 15:53:23 +03:00
f " { camera_name } : PTZ start time: { self . ptz_metrics [ camera_name ] . frame_time . value } "
2023-09-27 14:19:10 +03:00
)
2024-09-27 15:53:23 +03:00
self . ptz_metrics [ camera_name ] . start_time . value = self . ptz_metrics [
2023-09-27 14:19:10 +03:00
camera_name
2024-09-27 15:53:23 +03:00
] . frame_time . value
self . ptz_metrics [ camera_name ] . stop_time . value = 0
2023-07-08 15:04:47 +03:00
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 ,
2025-01-29 16:44:13 +03:00
[ - 1 , 1 ] ,
2023-07-08 15:04:47 +03:00
[
self . cams [ camera_name ] [ " relative_fov_range " ] [ " XRange " ] [ " Min " ] ,
self . cams [ camera_name ] [ " relative_fov_range " ] [ " XRange " ] [ " Max " ] ,
] ,
)
tilt = numpy . interp (
tilt ,
2025-01-29 16:44:13 +03:00
[ - 1 , 1 ] ,
2023-07-08 15:04:47 +03:00
[
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
2023-09-27 14:19:10 +03:00
2024-02-24 16:49:34 +03:00
if (
" zoom-r " in self . cams [ camera_name ] [ " features " ]
and self . config . cameras [ camera_name ] . onvif . autotracking . zooming
== ZoomingModeEnum . relative
) :
2023-09-27 14:19:10 +03:00
move_request . Speed = {
" PanTilt " : {
" x " : speed ,
" y " : speed ,
} ,
" Zoom " : { " x " : speed } ,
}
move_request . Translation . Zoom . x = zoom
2023-07-08 15:04:47 +03:00
2025-05-07 16:53:29 +03:00
await self . cams [ camera_name ] [ " ptz " ] . RelativeMove ( move_request )
2023-07-08 15:04:47 +03:00
2023-09-27 14:19:10 +03:00
# reset after the move request
move_request . Translation . PanTilt . x = 0
move_request . Translation . PanTilt . y = 0
2024-02-24 16:49:34 +03:00
if (
" zoom-r " in self . cams [ camera_name ] [ " features " ]
and self . config . cameras [ camera_name ] . onvif . autotracking . zooming
== ZoomingModeEnum . relative
) :
2023-09-27 14:19:10 +03:00
move_request . Translation . Zoom . x = 0
2023-07-08 15:04:47 +03:00
self . cams [ camera_name ] [ " active " ] = False
2025-05-07 16:53:29 +03:00
async def _move_to_preset ( self , camera_name : str , preset : str ) - > None :
2025-12-09 21:08:44 +03:00
if isinstance ( preset , bytes ) :
preset = preset . decode ( " utf-8 " )
preset = preset . lower ( )
2023-05-29 13:31:17 +03:00
if preset not in self . cams [ camera_name ] [ " presets " ] :
2023-04-26 14:08:53 +03:00
logger . error ( f " { preset } is not a valid preset for { camera_name } " )
return
self . cams [ camera_name ] [ " active " ] = True
2024-09-27 15:53:23 +03:00
self . ptz_metrics [ camera_name ] . start_time . value = 0
self . ptz_metrics [ camera_name ] . stop_time . value = 0
2023-04-26 14:08:53 +03:00
move_request = self . cams [ camera_name ] [ " move_request " ]
preset_token = self . cams [ camera_name ] [ " presets " ] [ preset ]
2025-05-07 16:53:29 +03:00
await self . cams [ camera_name ] [ " ptz " ] . GotoPreset (
{
" ProfileToken " : move_request . ProfileToken ,
" PresetToken " : preset_token ,
}
2023-04-26 14:08:53 +03:00
)
2023-10-22 19:59:13 +03:00
2023-04-26 14:08:53 +03:00
self . cams [ camera_name ] [ " active " ] = False
2025-05-07 16:53:29 +03:00
async def _zoom ( self , camera_name : str , command : OnvifCommandEnum ) - > None :
2023-04-26 14:08:53 +03:00
if self . cams [ camera_name ] [ " active " ] :
logger . warning (
f " { camera_name } is already performing an action, stopping... "
)
2025-05-07 16:53:29 +03:00
await self . _stop ( camera_name )
2023-04-26 14:08:53 +03:00
2024-09-16 19:46:35 +03:00
if " zoom " not in self . cams [ camera_name ] [ " features " ] :
logger . error ( f " { camera_name } does not support ONVIF zooming. " )
return
2023-04-26 14:08:53 +03:00
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 16:53:29 +03:00
await self . cams [ camera_name ] [ " ptz " ] . ContinuousMove ( move_request )
2023-04-26 14:08:53 +03:00
2025-05-07 16:53:29 +03:00
async def _zoom_absolute ( self , camera_name : str , zoom , speed ) - > None :
2023-09-27 14:19:10 +03:00
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
2024-09-27 15:53:23 +03:00
self . ptz_metrics [ camera_name ] . motor_stopped . clear ( )
2023-09-27 14:19:10 +03:00
logger . debug (
2024-09-27 15:53:23 +03:00
f " { camera_name } : PTZ start time: { self . ptz_metrics [ camera_name ] . frame_time . value } "
2023-09-27 14:19:10 +03:00
)
2024-09-27 15:53:23 +03:00
self . ptz_metrics [ camera_name ] . start_time . value = self . ptz_metrics [
2023-09-27 14:19:10 +03:00
camera_name
2024-09-27 15:53:23 +03:00
] . frame_time . value
self . ptz_metrics [ camera_name ] . stop_time . value = 0
2023-09-27 14:19:10 +03:00
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 ,
2025-01-29 16:44:13 +03:00
[ 0 , 1 ] ,
2023-09-27 14:19:10 +03:00
[
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 }
2023-10-22 19:59:13 +03:00
logger . debug ( f " { camera_name } : Absolute zoom: { zoom } " )
2023-09-27 14:19:10 +03:00
2025-05-07 16:53:29 +03:00
await self . cams [ camera_name ] [ " ptz " ] . AbsoluteMove ( move_request )
2023-09-27 14:19:10 +03:00
self . cams [ camera_name ] [ " active " ] = False
2025-06-26 00:45:36 +03: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 15:54:42 +03:00
or self . cams [ camera_name ] [ " imaging " ] is None
2025-06-26 00:45:36 +03: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 16:53:29 +03:00
async def handle_command_async (
2023-04-26 14:08:53 +03:00
self , camera_name : str , command : OnvifCommandEnum , param : str = " "
) - > None :
2025-05-07 16:53:29 +03:00
""" Handle ONVIF commands asynchronously """
2023-04-26 14:08:53 +03:00
if camera_name not in self . cams . keys ( ) :
2025-03-14 15:25:48 +03:00
logger . error ( f " ONVIF is not configured for { camera_name } " )
2023-04-26 14:08:53 +03:00
return
if not self . cams [ camera_name ] [ " init " ] :
2025-05-07 16:53:29 +03:00
if not await self . _init_onvif ( camera_name ) :
2023-04-26 14:08:53 +03:00
return
2024-12-19 18:46:14 +03:00
try :
if command == OnvifCommandEnum . init :
# already init
return
elif command == OnvifCommandEnum . stop :
2025-05-07 16:53:29 +03:00
await self . _stop ( camera_name )
2024-12-19 18:46:14 +03:00
elif command == OnvifCommandEnum . preset :
2025-05-07 16:53:29 +03:00
await self . _move_to_preset ( camera_name , param )
2024-12-19 18:46:14 +03:00
elif command == OnvifCommandEnum . move_relative :
_ , pan , tilt = param . split ( " _ " )
2025-05-07 16:53:29 +03:00
await self . _move_relative ( camera_name , float ( pan ) , float ( tilt ) , 0 , 1 )
2025-06-26 00:45:36 +03:00
elif command in ( OnvifCommandEnum . zoom_in , OnvifCommandEnum . zoom_out ) :
2025-05-07 16:53:29 +03:00
await self . _zoom ( camera_name , command )
2025-06-26 00:45:36 +03:00
elif command in ( OnvifCommandEnum . focus_in , OnvifCommandEnum . focus_out ) :
await self . _focus ( camera_name , command )
2024-12-19 18:46:14 +03:00
else :
2025-05-07 16:53:29 +03:00
await self . _move ( camera_name , command )
2025-05-06 05:42:24 +03:00
except ( Fault , ONVIFError , TransportError , Exception ) as e :
2024-12-19 18:46:14 +03:00
logger . error ( f " Unable to handle onvif command: { e } " )
2023-04-26 14:08:53 +03:00
2025-05-07 16:53:29 +03: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 )
except asyncio . TimeoutError :
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 17:27:20 +03:00
async def get_camera_info ( self , camera_name : str ) - > dict [ str , Any ] :
2025-03-14 15:25:48 +03: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 "
)
2023-04-26 14:08:53 +03:00
return { }
2025-05-07 16:53:29 +03:00
if camera_name not in self . cams . keys ( ) and (
2025-03-14 15:25:48 +03: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 { }
2023-04-26 14:08:53 +03:00
2025-05-07 16:53:29 +03:00
if camera_name in self . cams . keys ( ) and self . cams [ camera_name ] [ " init " ] :
2025-03-14 15:25:48 +03:00
return {
" name " : camera_name ,
" features " : self . cams [ camera_name ] [ " features " ] ,
" presets " : list ( self . cams [ camera_name ] [ " presets " ] . keys ( ) ) ,
}
2025-05-07 16:53:29 +03: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 15:25:48 +03: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 { }
2023-07-08 15:04:47 +03:00
2025-05-07 16:53:29 +03:00
async def get_service_capabilities ( self , camera_name : str ) - > None :
2023-09-27 14:19:10 +03:00
if camera_name not in self . cams . keys ( ) :
2025-03-14 15:25:48 +03:00
logger . error ( f " ONVIF is not configured for { camera_name } " )
2023-09-27 14:19:10 +03:00
return { }
if not self . cams [ camera_name ] [ " init " ] :
2025-05-07 16:53:29 +03:00
await self . _init_onvif ( camera_name )
2023-09-27 14:19:10 +03:00
service_capabilities_request = self . cams [ camera_name ] [
" service_capabilities_request "
]
2024-03-09 17:48:31 +03:00
try :
2025-05-07 16:53:29 +03:00
service_capabilities = await self . cams [ camera_name ] [
" ptz "
] . GetServiceCapabilities ( service_capabilities_request )
2023-09-27 14:19:10 +03:00
2024-03-09 17:48:31 +03:00
logger . debug (
f " Onvif service capabilities for { camera_name } : { service_capabilities } "
)
2023-09-27 14:19:10 +03:00
2024-03-09 17:48:31 +03:00
# MoveStatus is required for autotracking - should return "true" if supported
return find_by_key ( vars ( service_capabilities ) , " MoveStatus " )
2025-05-07 16:53:29 +03:00
except Exception as e :
2024-03-09 17:48:31 +03:00
logger . warning (
2025-05-07 16:53:29 +03: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 } "
2024-03-09 17:48:31 +03:00
)
return False
2023-09-27 14:19:10 +03:00
2025-05-07 16:53:29 +03:00
async def get_camera_status ( self , camera_name : str ) - > None :
2025-09-02 03:18:50 +03: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 16:53:29 +03:00
return
2023-07-08 15:04:47 +03:00
2025-09-02 03:18:50 +03:00
if not self . cams [ camera_name ] [ " init " ] :
if not await self . _init_onvif ( camera_name ) :
return
2023-07-08 15:04:47 +03:00
2025-09-02 03:18:50 +03: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.
try :
pan_tilt_status = getattr ( status . MoveStatus , " PanTilt " , None )
zoom_status = getattr ( status . MoveStatus , " Zoom " , None )
# 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 15:23:14 +03:00
)
2023-09-27 14:19:10 +03:00
2025-09-02 03:18:50 +03: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-24 02:40:21 +03:00
2025-09-02 03:18:50 +03:00
logger . debug (
f " { camera_name } : PTZ stop time: { self . ptz_metrics [ camera_name ] . frame_time . value } "
)
2023-07-11 14:23:20 +03:00
2025-09-02 03:18:50 +03: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 ( )
2023-07-11 14:23:20 +03:00
2025-09-02 03:18:50 +03:00
logger . debug (
f " { camera_name } : PTZ start time: { self . ptz_metrics [ camera_name ] . frame_time . value } "
)
2023-07-11 14:23:20 +03:00
2025-09-02 03:18:50 +03:00
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 ] ,
)
2023-09-27 14:19:10 +03:00
logger . debug (
2025-09-02 03:18:50 +03:00
f " { camera_name } : Camera zoom level: { self . ptz_metrics [ camera_name ] . zoom_level . value } "
2023-09-27 14:19:10 +03:00
)
2023-07-11 14:23:20 +03:00
2025-09-02 03:18:50 +03: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 [
2024-09-27 15:53:23 +03:00
camera_name
] . frame_time . value
2025-09-02 03:18:50 +03:00
logger . warning (
f " Camera { camera_name } is still in ONVIF ' MOVING ' status. "
)
2025-05-07 16:53:29 +03: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... " )
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 ( )