Enable PTZ control setup in the Add Camera Wizard (#23444)

* add ptz controls to camera via wizard when onvif has already been probed

* i18n

* add e2e test

* backend add and remove subscriber

* tweaks

* turn on switch by default if pan and/or tilt capability is available

* fix test
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 1498231eb9
commit 50e7b76eb5
9 changed files with 482 additions and 6 deletions
+37
View File
@@ -651,6 +651,32 @@ async def _connect_onvif_camera(
raise first_error
def _supports_continuous_pan_tilt(nodes) -> bool:
"""Whether any PTZ node advertises continuous pan/tilt velocity.
The web UI's directional controls issue ContinuousMove with a PanTilt
velocity, so continuous pan/tilt is what makes those controls usable. This
is intentionally narrower than ptz_supported, which is true for any device
exposing the ONVIF PTZ service - including zoom/focus-only varifocal lenses.
"""
for node in nodes or []:
spaces = getattr(node, "SupportedPTZSpaces", None) or (
node.get("SupportedPTZSpaces") if isinstance(node, dict) else None
)
if spaces is None:
continue
continuous = getattr(spaces, "ContinuousPanTiltVelocitySpace", None) or (
spaces.get("ContinuousPanTiltVelocitySpace")
if isinstance(spaces, dict)
else None
)
if continuous:
return True
return False
@router.get(
"/onvif/probe",
dependencies=[Depends(require_role(["admin"]))],
@@ -808,6 +834,7 @@ async def onvif_probe(
# Check PTZ support and capabilities
ptz_supported = False
pan_tilt_supported = False
presets_count = 0
autotrack_supported = False
@@ -841,6 +868,15 @@ async def onvif_probe(
logger.debug(f"Failed to get presets: {e}")
presets_count = 0
# Check for real (continuous) pan/tilt, which the UI controls need
if ptz_supported:
try:
nodes = await ptz_service.GetNodes()
pan_tilt_supported = _supports_continuous_pan_tilt(nodes)
logger.debug(f"Continuous pan/tilt supported: {pan_tilt_supported}")
except Exception as e:
logger.debug(f"Failed to read PTZ nodes for pan/tilt support: {e}")
# Check for autotracking support - requires both FOV relative movement and MoveStatus
if ptz_supported and first_profile_token and ptz_config_token:
# First check for FOV relative movement support
@@ -960,6 +996,7 @@ async def onvif_probe(
"firmware_version": device_info["firmware_version"],
"profiles_count": profiles_count,
"ptz_supported": ptz_supported,
"pan_tilt_supported": pan_tilt_supported,
"presets_count": presets_count,
"autotrack_supported": autotrack_supported,
}
+27 -1
View File
@@ -72,7 +72,11 @@ class OnvifController:
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[CameraConfigUpdateEnum.onvif],
[
CameraConfigUpdateEnum.onvif,
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
],
)
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
@@ -101,6 +105,16 @@ class OnvifController:
if update_type == CameraConfigUpdateEnum.onvif.name:
for cam_name in cameras:
await self._reinit_camera(cam_name)
elif update_type == CameraConfigUpdateEnum.add.name:
# a camera added at runtime only needs ONVIF set up if
# it actually has an onvif host configured
for cam_name in cameras:
cam = self.config.cameras.get(cam_name)
if cam and cam.onvif.host:
await self._reinit_camera(cam_name)
elif update_type == CameraConfigUpdateEnum.remove.name:
for cam_name in cameras:
await self._remove_camera(cam_name)
except Exception:
logger.error("Error checking for ONVIF config updates")
@@ -113,6 +127,18 @@ class OnvifController:
except Exception:
logger.debug(f"Error closing ONVIF session for {cam_name}")
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)
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")