From 71df5ad058f868d09646bdfa4f63c9379a227ee3 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Wed, 25 Jun 2025 16:45:36 -0500
Subject: [PATCH] Add ONVIF focus support (#18883)
* backend
* frontend and i18n
---
frigate/ptz/onvif.py | 85 ++++++++++++++++++++++++---
web/public/locales/en/views/live.json | 8 +++
web/src/types/ptz.ts | 9 ++-
web/src/views/live/LiveCameraView.tsx | 40 ++++++++++++-
4 files changed, 131 insertions(+), 11 deletions(-)
diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py
index 0277fa083..aa924ed70 100644
--- a/frigate/ptz/onvif.py
+++ b/frigate/ptz/onvif.py
@@ -33,6 +33,8 @@ class OnvifCommandEnum(str, Enum):
stop = "stop"
zoom_in = "zoom_in"
zoom_out = "zoom_out"
+ focus_in = "focus_in"
+ focus_out = "focus_out"
class OnvifController:
@@ -185,6 +187,16 @@ class OnvifController:
ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz
+ imaging: ONVIFService = await onvif.create_imaging_service()
+ 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
@@ -265,9 +277,15 @@ class OnvifController:
"RelativeZoomTranslationSpace"
][zoom_space_id]["URI"]
else:
- if "Zoom" in move_request["Translation"]:
+ if (
+ move_request["Translation"] is not None
+ and "Zoom" in move_request["Translation"]
+ ):
del move_request["Translation"]["Zoom"]
- if "Zoom" in move_request["Speed"]:
+ if (
+ move_request["Speed"] is not None
+ and "Zoom" in move_request["Speed"]
+ ):
del move_request["Speed"]["Zoom"]
logger.debug(
f"{camera_name}: Relative move request after deleting zoom: {move_request}"
@@ -360,7 +378,19 @@ class OnvifController:
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
)
- # set relative pan/tilt space for autotracker
+ if self.cams[camera_name]["video_source_token"] is not None:
+ 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}")
+
if (
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
and self.config.cameras[camera_name].onvif.autotracking.enabled
@@ -385,6 +415,18 @@ class OnvifController:
"Zoom": True,
}
)
+ if (
+ "focus" in self.cams[camera_name]["features"]
+ and self.cams[camera_name]["video_source_token"]
+ ):
+ 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
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
@@ -593,6 +635,35 @@ class OnvifController:
self.cams[camera_name]["active"] = False
+ 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"]
+ ):
+ 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
+
async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None:
@@ -616,11 +687,10 @@ class OnvifController:
elif command == OnvifCommandEnum.move_relative:
_, pan, tilt = param.split("_")
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
- elif (
- command == OnvifCommandEnum.zoom_in
- or command == OnvifCommandEnum.zoom_out
- ):
+ elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
await self._zoom(camera_name, command)
+ elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
+ await self._focus(camera_name, command)
else:
await self._move(camera_name, command)
except (Fault, ONVIFError, TransportError, Exception) as e:
@@ -631,7 +701,6 @@ class OnvifController:
) -> None:
"""
Handle ONVIF commands by scheduling them in the event loop.
- This is the synchronous interface that schedules async work.
"""
future = asyncio.run_coroutine_threadsafe(
self.handle_command_async(camera_name, command, param), self.loop
diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json
index fea120601..2af399296 100644
--- a/web/public/locales/en/views/live.json
+++ b/web/public/locales/en/views/live.json
@@ -38,6 +38,14 @@
"label": "Zoom PTZ camera out"
}
},
+ "focus": {
+ "in": {
+ "label": "Focus PTZ camera in"
+ },
+ "out": {
+ "label": "Focus PTZ camera out"
+ }
+ },
"frame": {
"center": {
"label": "Click in the frame to center the PTZ camera"
diff --git a/web/src/types/ptz.ts b/web/src/types/ptz.ts
index 1a626972e..21a300b3d 100644
--- a/web/src/types/ptz.ts
+++ b/web/src/types/ptz.ts
@@ -1,4 +1,11 @@
-type PtzFeature = "pt" | "zoom" | "pt-r" | "zoom-r" | "zoom-a" | "pt-r-fov";
+type PtzFeature =
+ | "pt"
+ | "zoom"
+ | "pt-r"
+ | "zoom-r"
+ | "zoom-a"
+ | "pt-r-fov"
+ | "focus";
export type CameraPtzInfo = {
name: string;
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx
index 039265f65..949c642b0 100644
--- a/web/src/views/live/LiveCameraView.tsx
+++ b/web/src/views/live/LiveCameraView.tsx
@@ -92,6 +92,8 @@ import {
LuX,
} from "react-icons/lu";
import {
+ MdCenterFocusStrong,
+ MdCenterFocusWeak,
MdClosedCaption,
MdClosedCaptionDisabled,
MdNoPhotography,
@@ -808,10 +810,10 @@ function PtzControlPanel({
sendPtz("MOVE_DOWN");
break;
case "+":
- sendPtz("ZOOM_IN");
+ sendPtz(modifiers.shift ? "FOCUS_IN" : "ZOOM_IN");
break;
case "-":
- sendPtz("ZOOM_OUT");
+ sendPtz(modifiers.shift ? "FOCUS_OUT" : "ZOOM_OUT");
break;
}
},
@@ -922,6 +924,40 @@ function PtzControlPanel({
>
)}
+ {ptz?.features?.includes("focus") && (
+ <>
+ {
+ e.preventDefault();
+ sendPtz("FOCUS_IN");
+ }}
+ onTouchStart={(e) => {
+ e.preventDefault();
+ sendPtz("FOCUS_IN");
+ }}
+ onMouseUp={onStop}
+ onTouchEnd={onStop}
+ >
+
+
+ {
+ e.preventDefault();
+ sendPtz("FOCUS_OUT");
+ }}
+ onTouchStart={(e) => {
+ e.preventDefault();
+ sendPtz("FOCUS_OUT");
+ }}
+ onMouseUp={onStop}
+ onTouchEnd={onStop}
+ >
+
+
+ >
+ )}
{ptz?.features?.includes("pt-r-fov") && (