Clean up onvif and autotracking (#24559)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* simplify onvif and autotracking code

Removes about 230 lines from the ONVIF controller and autotracker without changing how PTZ moves are calculated. `OnvifController` no longer keeps its own `camera_configs` copy of the camera config, the cached `GetStatus`, `GetServiceCapabilities`, and `AbsoluteMove` request objects are gone in favor of plain dicts at the call site, and `PtzAutoTrackerThread` is merged into `PtzAutoTracker`. Repeated blocks in the autotracker (waiting for the motor to stop, disabling autotracking on a failed setup step) are now single helpers.

* use camera config for autotracking enabled state

Camera processes read autotracking state from a shared `autotracker_enabled` value that the dispatcher and autotracker had to keep in sync with the config by hand. Camera processes now subscribe to the `autotracking` and `onvif` config updates and read `onvif.autotracking.enabled` directly, so the shared value and the autotracker's mirroring method are gone. `_disable` now publishes its change so the camera process hears about it. Also removes `tracking_active`, which was set and cleared but never read.

* compute max target box from live zoom factor

* fix autotracking debug overlay max target box lookup

* make max target box a function of zoom factor
This commit is contained in:
Josh Hawkins
2026-10-05 11:25:24 -06:00
committed by GitHub
parent dab0d85830
commit 9ab52c2be6
14 changed files with 479 additions and 752 deletions
+205 -304
View File
@@ -10,8 +10,7 @@ from pathlib import Path
from typing import Any
import numpy
from onvif import ONVIFCamera, ONVIFError, ONVIFService
from zeep.exceptions import Fault, TransportError
from onvif import ONVIFCamera, ONVIFService
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
@@ -41,6 +40,14 @@ class OnvifCommandEnum(str, Enum):
focus_out = "focus_out"
PAN_TILT_VELOCITY = {
OnvifCommandEnum.move_left: (-0.5, 0),
OnvifCommandEnum.move_right: (0.5, 0),
OnvifCommandEnum.move_up: (0, 0.5),
OnvifCommandEnum.move_down: (0, -0.5),
}
class OnvifController:
ptz_metrics: dict[str, PTZMetrics]
@@ -61,14 +68,6 @@ class OnvifController:
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:
self.camera_configs[cam_name] = cam
self.status_locks[cam_name] = asyncio.Lock()
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
@@ -92,8 +91,9 @@ class OnvifController:
async def _init_cameras(self) -> None:
"""Initialize all configured cameras."""
for cam_name in self.camera_configs:
await self._init_single_camera(cam_name)
for cam_name, cam in list(self.config.cameras.items()):
if cam.enabled and cam.onvif.host:
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."""
@@ -129,13 +129,9 @@ class OnvifController:
async def _remove_camera(self, cam_name: str) -> None:
"""Tear down the ONVIF session for a camera removed at runtime."""
if cam_name not in self.cams and cam_name not in self.camera_configs:
return
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
await self._close_camera(cam_name)
self.cams.pop(cam_name, None)
self.camera_configs.pop(cam_name, None)
self.failed_cams.pop(cam_name, None)
self.status_locks.pop(cam_name, None)
@@ -143,25 +139,14 @@ class OnvifController:
"""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
# close existing session and reset state 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)
cam = self.config.cameras.get(cam_name)
if cam and cam.onvif.host:
await self._init_single_camera(cam_name)
async def _init_single_camera(self, cam_name: str) -> bool:
"""Initialize a single camera by name.
@@ -172,11 +157,12 @@ class OnvifController:
Returns:
bool: True if initialization succeeded, False otherwise
"""
if cam_name not in self.camera_configs:
cam = self.config.cameras.get(cam_name)
if cam is None:
logger.error(f"No configuration found for camera {cam_name}")
return False
cam = self.camera_configs[cam_name]
self.status_locks.setdefault(cam_name, asyncio.Lock())
try:
self.cams[cam_name] = {
"onvif": ONVIFCamera(
@@ -195,12 +181,11 @@ class OnvifController:
"profiles": [],
}
return True
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
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(),
}
return False
@@ -211,7 +196,8 @@ class OnvifController:
if camera_config is None:
return False
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
cam = self.cams[camera_name]
onvif: ONVIFCamera = cam["onvif"]
try:
await onvif.update_xaddrs()
except Exception as e:
@@ -226,7 +212,7 @@ class OnvifController:
# 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}")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
)
@@ -235,7 +221,7 @@ class OnvifController:
try:
profiles = await media.GetProfiles()
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
)
@@ -254,7 +240,7 @@ class OnvifController:
]
# store available profiles for API response and log for debugging
self.cams[camera_name]["profiles"] = [
cam["profiles"] = [
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
for p in valid_profiles
]
@@ -267,19 +253,18 @@ class OnvifController:
)
configured_profile = camera_config.onvif.profile
profile = None
if configured_profile is not None:
# match by exact token first, then by name
for p in valid_profiles:
if p.token == configured_profile:
profile = p
break
if profile is None:
for p in valid_profiles:
if getattr(p, "Name", None) == configured_profile:
profile = p
break
profile = next(
(
p
for key in ("token", "Name")
for p in valid_profiles
if getattr(p, key, None) == configured_profile
),
None,
)
if profile is None:
available = [
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
@@ -304,39 +289,30 @@ class OnvifController:
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
configs = profile.PTZConfiguration
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz
cam["ptz"] = ptz
try:
imaging: ONVIFService = await onvif.create_imaging_service()
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
imaging = None
self.cams[camera_name]["imaging"] = imaging
cam["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:
cam["video_source_token"] = video_sources[0].token
except Exception as e:
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
self.cams[camera_name]["video_source_token"] = None
cam["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
cam["move_request"] = move_request
# get PTZ configuration options for feature detection and relative movement
ptz_config = None
@@ -349,7 +325,7 @@ class OnvifController:
logger.debug(
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
)
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(
f"Unable to get PTZ configuration options for {camera_name}: {e}"
)
@@ -375,18 +351,6 @@ class OnvifController:
autotracking_config.enabled_in_config and autotracking_config.enabled
)
# these are local and cost nothing to build, and autotracking can be enabled
# after a camera is initialized, so always create them rather than baking the
# current config value into init state
status_request = ptz.create_type("GetStatus")
status_request.ProfileToken = profile.token
self.cams[camera_name]["status_request"] = status_request
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
self.cams[camera_name]["service_capabilities_request"] = (
service_capabilities_request
)
# setup relative move request when FOV relative movement is supported
if (
fov_space_id is not None
@@ -395,9 +359,7 @@ class OnvifController:
# one-off GetStatus to seed Translation field
status = None
try:
one_off_status_request = ptz.create_type("GetStatus")
one_off_status_request.ProfileToken = profile.token
status = await ptz.GetStatus(one_off_status_request)
status = await ptz.GetStatus({"ProfileToken": profile.token})
logger.debug(f"Onvif status for {camera_name}: {status}")
except Exception as e:
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
@@ -424,7 +386,7 @@ class OnvifController:
# configure zoom on relative move request
if (
autotracking_enabled
and autotracking_config.zooming != ZoomingModeEnum.disabled
and autotracking_config.zooming == ZoomingModeEnum.relative
):
zoom_space_id = next(
(
@@ -463,21 +425,16 @@ class OnvifController:
)
if rel_move_request.Speed is None:
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
rel_move_request.Speed = configs.DefaultPTZSpeed
logger.debug(
f"{camera_name}: Relative move request after setup: {rel_move_request}"
)
self.cams[camera_name]["relative_move_request"] = rel_move_request
# setup absolute move request
abs_move_request = ptz.create_type("AbsoluteMove")
abs_move_request.ProfileToken = profile.token
self.cams[camera_name]["absolute_move_request"] = abs_move_request
cam["relative_move_request"] = rel_move_request
# setup existing presets
try:
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
presets = []
@@ -490,7 +447,7 @@ class OnvifController:
preset_name = preset_name.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
cam["presets"][preset_name.lower()] = preset["token"]
# get list of supported features
supported_features = []
@@ -504,64 +461,40 @@ class OnvifController:
if configs.DefaultRelativePanTiltTranslationSpace:
supported_features.append("pt-r")
spaces = getattr(ptz_config, "Spaces", None)
if configs.DefaultRelativeZoomTranslationSpace:
supported_features.append("zoom-r")
if ptz_config is not None:
try:
self.cams[camera_name]["relative_zoom_range"] = (
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
)
except Exception as e:
if autotracking_config.zooming == ZoomingModeEnum.relative:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
)
if getattr(spaces, "RelativeZoomTranslationSpace", None):
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
if configs.DefaultAbsoluteZoomPositionSpace:
supported_features.append("zoom-a")
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
except Exception as e:
if autotracking_config.zooming != ZoomingModeEnum.disabled:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
)
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
# disable autotracking zoom if required ranges are unavailable
if autotracking_config.zooming != ZoomingModeEnum.disabled:
if autotracking_config.zooming == ZoomingModeEnum.relative:
if "relative_zoom_range" not in self.cams[camera_name]:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
)
if autotracking_config.zooming == ZoomingModeEnum.absolute:
if "absolute_zoom_range" not in self.cams[camera_name]:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
)
if (
self.cams[camera_name]["video_source_token"] is not None
and imaging is not None
# autotracking zoom needs the range for its mode, and get_camera_status
# reads the absolute range in both modes
zooming = autotracking_config.zooming
if zooming != ZoomingModeEnum.disabled and (
"absolute_zoom_range" not in cam or f"{zooming.value}_zoom_range" not in cam
):
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: {zooming.value} zoom range unavailable"
)
if cam["video_source_token"] is not None and imaging is not None:
try:
imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
{"VideoSourceToken": cam["video_source_token"]}
)
if (
hasattr(imaging_capabilities, "Focus")
and imaging_capabilities.Focus
):
supported_features.append("focus")
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.debug(f"Focus not supported for {camera_name}: {e}")
# detect FOV relative movement support
@@ -570,17 +503,18 @@ class OnvifController:
and configs.DefaultRelativePanTiltTranslationSpace is not None
):
supported_features.append("pt-r-fov")
self.cams[camera_name]["relative_fov_range"] = (
cam["relative_fov_range"] = (
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
)
self.cams[camera_name]["features"] = supported_features
self.cams[camera_name]["init"] = True
cam["features"] = supported_features
cam["init"] = True
return True
async def _stop(self, camera_name: str) -> None:
move_request = self.cams[camera_name]["move_request"]
await self.cams[camera_name]["ptz"].Stop(
cam = self.cams[camera_name]
move_request = cam["move_request"]
await cam["ptz"].Stop(
{
"ProfileToken": move_request.ProfileToken,
"PanTilt": True,
@@ -588,88 +522,75 @@ class OnvifController:
}
)
if (
"focus" in self.cams[camera_name]["features"]
and self.cams[camera_name]["video_source_token"]
and self.cams[camera_name]["imaging"] is not None
"focus" in cam["features"]
and cam["video_source_token"]
and cam["imaging"] is not None
):
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:
stop_request = cam["imaging"].create_type("Stop")
stop_request.VideoSourceToken = cam["video_source_token"]
await cam["imaging"].Stop(stop_request)
except Exception as e:
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
await self._stop(camera_name)
if "pt" not in self.cams[camera_name]["features"]:
if "pt" not in cam["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"]
cam["active"] = True
move_request = cam["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,
}
}
x, y = PAN_TILT_VELOCITY[command]
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
try:
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
await cam["ptz"].ContinuousMove(move_request)
except Exception as e:
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
if "pt-r-fov" not in self.cams[camera_name]["features"]:
cam = self.cams[camera_name]
if "pt-r-fov" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
return
metrics = self.ptz_metrics.get(camera_name)
camera_config = self.config.cameras.get(camera_name)
if metrics is None:
if metrics is None or camera_config is None:
return
logger.debug(
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
)
if self.cams[camera_name]["active"]:
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
cam["active"] = True
# only track start_time for autotracking
if metrics.autotracker_enabled.value:
if camera_config.onvif.autotracking.enabled:
metrics.motor_stopped.clear()
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
metrics.start_time.value = metrics.frame_time.value
metrics.stop_time.value = 0
move_request = self.cams[camera_name]["relative_move_request"]
move_request = cam["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
@@ -677,55 +598,49 @@ class OnvifController:
pan,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
cam["relative_fov_range"]["XRange"]["Min"],
cam["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"],
cam["relative_fov_range"]["YRange"]["Min"],
cam["relative_fov_range"]["YRange"]["Max"],
],
)
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
}
move_speed = {"PanTilt": {"x": speed, "y": speed}}
move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt
# 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},
}
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
if include_zoom:
move_speed["Zoom"] = {"x": speed}
move_request["Translation"]["Zoom"] = {"x": zoom}
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
move_request.Speed = move_speed
await cam["ptz"].RelativeMove(move_request)
# reset after the move request
move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 0
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
if include_zoom:
del move_request["Translation"]["Zoom"]
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
cam = self.cams[camera_name]
preset = preset.lower()
if preset not in self.cams[camera_name]["presets"]:
if preset not in cam["presets"]:
logger.error(f"{preset} is not a valid preset for {camera_name}")
return
@@ -734,44 +649,48 @@ class OnvifController:
if metrics is None:
return
self.cams[camera_name]["active"] = True
cam["active"] = True
metrics.start_time.value = 0
metrics.stop_time.value = 0
move_request = self.cams[camera_name]["move_request"]
preset_token = self.cams[camera_name]["presets"][preset]
move_request = cam["move_request"]
preset_token = cam["presets"][preset]
await self.cams[camera_name]["ptz"].GotoPreset(
await cam["ptz"].GotoPreset(
{
"ProfileToken": move_request.ProfileToken,
"PresetToken": preset_token,
}
)
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
await self._stop(camera_name)
if "zoom" not in self.cams[camera_name]["features"]:
if "zoom" not in cam["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"]
cam["active"] = True
move_request = cam["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}}
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
await cam["ptz"].ContinuousMove(move_request)
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
if "zoom-a" not in self.cams[camera_name]["features"]:
cam = self.cams[camera_name]
if "zoom-a" not in cam["features"]:
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
return
@@ -782,56 +701,59 @@ class OnvifController:
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
if self.cams[camera_name]["active"]:
if cam["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
cam["active"] = True
metrics.motor_stopped.clear()
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
metrics.start_time.value = metrics.frame_time.value
metrics.stop_time.value = 0
move_request = self.cams[camera_name]["absolute_move_request"]
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
zoom = numpy.interp(
zoom,
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
cam["absolute_zoom_range"]["XRange"]["Min"],
cam["absolute_zoom_range"]["XRange"]["Max"],
],
)
move_request.Speed = {"Zoom": speed}
move_request.Position = {"Zoom": zoom}
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
await cam["ptz"].AbsoluteMove(
{
"ProfileToken": cam["move_request"].ProfileToken,
"Position": {"Zoom": zoom},
"Speed": {"Zoom": speed},
}
)
self.cams[camera_name]["active"] = False
cam["active"] = False
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
cam = self.cams[camera_name]
if cam["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"]
or self.cams[camera_name]["imaging"] is None
"focus" not in cam["features"]
or not cam["video_source_token"]
or cam["imaging"] is None
):
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"]
cam["active"] = True
move_request = cam["imaging"].create_type("Move")
move_request.VideoSourceToken = cam["video_source_token"]
move_request.Focus = {
"Continuous": {
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
@@ -839,10 +761,10 @@ class OnvifController:
}
try:
await self.cams[camera_name]["imaging"].Move(move_request)
except (Fault, ONVIFError, TransportError, Exception) as e:
await cam["imaging"].Move(move_request)
except Exception as e:
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
self.cams[camera_name]["active"] = False
cam["active"] = False
async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
@@ -883,7 +805,7 @@ class OnvifController:
await self._focus(camera_name, command)
else:
await self._move(camera_name, command)
except (Fault, ONVIFError, TransportError, Exception) as e:
except Exception as e:
logger.error(f"Unable to handle onvif command: {e}")
def handle_command(
@@ -906,6 +828,15 @@ class OnvifController:
f"Error executing command {command} for camera {camera_name}: {e}"
)
def _camera_info(self, camera_name: str) -> dict[str, Any]:
cam = self.cams[camera_name]
return {
"name": camera_name,
"features": cam["features"],
"presets": list(cam["presets"]),
"profiles": cam["profiles"],
}
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
"""
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
@@ -929,60 +860,21 @@ class OnvifController:
return {}
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
"profiles": self.cams[camera_name].get("profiles", []),
}
return self._camera_info(camera_name)
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:
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)
failed = self.failed_cams.get(camera_name, {})
attempts = failed.get("retry_attempts", 0)
last_attempt = failed.get("last_attempt", 0)
# Reset retry count after timeout
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}")
self.failed_cams[camera_name] = {
"retry_attempts": attempts + 1,
"last_attempt": time.time(),
}
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
)
if camera_name not in self.failed_cams:
self.failed_cams[camera_name] = {"retry_attempts": 0}
self.failed_cams[camera_name].update(
{
"retry_attempts": attempts + 1,
"last_error": str(e),
"last_attempt": time.time(),
}
)
if attempts >= self.max_retries:
remaining_time = max(
@@ -991,8 +883,24 @@ class OnvifController:
logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
)
return {}
logger.debug(f"Could not initialize ONVIF for {camera_name}")
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
self.failed_cams.pop(camera_name, None)
return self._camera_info(camera_name)
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(f"Error during ONVIF initialization for {camera_name}: {e}")
self.failed_cams[camera_name] = {
"retry_attempts": attempts + 1,
"last_attempt": time.time(),
}
return {}
async def get_service_capabilities(self, camera_name: str) -> None:
@@ -1000,16 +908,13 @@ class OnvifController:
logger.error(f"ONVIF is not configured for {camera_name}")
return {}
if not self.cams[camera_name]["init"]:
cam = self.cams[camera_name]
if not cam["init"]:
await self._init_onvif(camera_name)
service_capabilities_request = self.cams[camera_name][
"service_capabilities_request"
]
try:
service_capabilities = await self.cams[camera_name][
"ptz"
].GetServiceCapabilities(service_capabilities_request)
service_capabilities = await cam["ptz"].GetServiceCapabilities()
logger.debug(
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
@@ -1035,13 +940,16 @@ class OnvifController:
if metrics is None or camera_config is None:
return
if not self.cams[camera_name]["init"]:
cam = self.cams[camera_name]
if not cam["init"]:
if not await self._init_onvif(camera_name):
return
status_request = self.cams[camera_name]["status_request"]
try:
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
status = await cam["ptz"].GetStatus(
{"ProfileToken": cam["move_request"].ProfileToken}
)
except Exception:
pass # We're unsupported, that'll be reported in the next check.
@@ -1072,7 +980,7 @@ class OnvifController:
if pan_tilt_status == "IDLE" and (
zoom_status is None or zoom_status == "IDLE"
):
self.cams[camera_name]["active"] = False
cam["active"] = False
if not metrics.motor_stopped.is_set():
metrics.motor_stopped.set()
@@ -1082,7 +990,7 @@ class OnvifController:
metrics.stop_time.value = metrics.frame_time.value
else:
self.cams[camera_name]["active"] = True
cam["active"] = True
if metrics.motor_stopped.is_set():
metrics.motor_stopped.clear()
@@ -1098,8 +1006,8 @@ class OnvifController:
metrics.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"],
cam["absolute_zoom_range"]["XRange"]["Min"],
cam["absolute_zoom_range"]["XRange"]["Max"],
],
[0, 1],
)
@@ -1138,7 +1046,7 @@ class OnvifController:
def close(self) -> None:
"""Gracefully shut down the ONVIF controller."""
if not hasattr(self, "loop") or self.loop.is_closed():
if self.loop.is_closed():
logger.debug("ONVIF controller already closed")
return
@@ -1155,13 +1063,6 @@ class OnvifController:
self.config_subscriber.stop()
def stop_and_cleanup():
try:
self.loop.stop()
except Exception as e:
logger.error(f"Error during loop cleanup: {e}")
# Schedule stop and cleanup in the loop thread
self.loop.call_soon_threadsafe(stop_and_cleanup)
self.loop.call_soon_threadsafe(self.loop.stop)
self.loop_thread.join()