Add ONVIF focus support (#18883)

* backend

* frontend and i18n
This commit is contained in:
Josh Hawkins 2025-06-25 16:45:36 -05:00 committed by GitHub
parent add68b8860
commit 71df5ad058
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 131 additions and 11 deletions

View File

@ -33,6 +33,8 @@ class OnvifCommandEnum(str, Enum):
stop = "stop" stop = "stop"
zoom_in = "zoom_in" zoom_in = "zoom_in"
zoom_out = "zoom_out" zoom_out = "zoom_out"
focus_in = "focus_in"
focus_out = "focus_out"
class OnvifController: class OnvifController:
@ -185,6 +187,16 @@ class OnvifController:
ptz: ONVIFService = await onvif.create_ptz_service() ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz 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 # setup continuous moving request
move_request = ptz.create_type("ContinuousMove") move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token move_request.ProfileToken = profile.token
@ -265,9 +277,15 @@ class OnvifController:
"RelativeZoomTranslationSpace" "RelativeZoomTranslationSpace"
][zoom_space_id]["URI"] ][zoom_space_id]["URI"]
else: 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"] 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"] del move_request["Speed"]["Zoom"]
logger.debug( logger.debug(
f"{camera_name}: Relative move request after deleting zoom: {move_request}" 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}" 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 ( if (
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
and self.config.cameras[camera_name].onvif.autotracking.enabled and self.config.cameras[camera_name].onvif.autotracking.enabled
@ -385,6 +415,18 @@ class OnvifController:
"Zoom": True, "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 self.cams[camera_name]["active"] = False
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None: async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
@ -593,6 +635,35 @@ class OnvifController:
self.cams[camera_name]["active"] = False 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( async def handle_command_async(
self, camera_name: str, command: OnvifCommandEnum, param: str = "" self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None: ) -> None:
@ -616,11 +687,10 @@ class OnvifController:
elif command == OnvifCommandEnum.move_relative: elif command == OnvifCommandEnum.move_relative:
_, pan, tilt = param.split("_") _, pan, tilt = param.split("_")
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1) await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
elif ( elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
command == OnvifCommandEnum.zoom_in
or command == OnvifCommandEnum.zoom_out
):
await self._zoom(camera_name, command) await self._zoom(camera_name, command)
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
await self._focus(camera_name, command)
else: else:
await self._move(camera_name, command) await self._move(camera_name, command)
except (Fault, ONVIFError, TransportError, Exception) as e: except (Fault, ONVIFError, TransportError, Exception) as e:
@ -631,7 +701,6 @@ class OnvifController:
) -> None: ) -> None:
""" """
Handle ONVIF commands by scheduling them in the event loop. Handle ONVIF commands by scheduling them in the event loop.
This is the synchronous interface that schedules async work.
""" """
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self.handle_command_async(camera_name, command, param), self.loop self.handle_command_async(camera_name, command, param), self.loop

View File

@ -38,6 +38,14 @@
"label": "Zoom PTZ camera out" "label": "Zoom PTZ camera out"
} }
}, },
"focus": {
"in": {
"label": "Focus PTZ camera in"
},
"out": {
"label": "Focus PTZ camera out"
}
},
"frame": { "frame": {
"center": { "center": {
"label": "Click in the frame to center the PTZ camera" "label": "Click in the frame to center the PTZ camera"

View File

@ -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 = { export type CameraPtzInfo = {
name: string; name: string;

View File

@ -92,6 +92,8 @@ import {
LuX, LuX,
} from "react-icons/lu"; } from "react-icons/lu";
import { import {
MdCenterFocusStrong,
MdCenterFocusWeak,
MdClosedCaption, MdClosedCaption,
MdClosedCaptionDisabled, MdClosedCaptionDisabled,
MdNoPhotography, MdNoPhotography,
@ -808,10 +810,10 @@ function PtzControlPanel({
sendPtz("MOVE_DOWN"); sendPtz("MOVE_DOWN");
break; break;
case "+": case "+":
sendPtz("ZOOM_IN"); sendPtz(modifiers.shift ? "FOCUS_IN" : "ZOOM_IN");
break; break;
case "-": case "-":
sendPtz("ZOOM_OUT"); sendPtz(modifiers.shift ? "FOCUS_OUT" : "ZOOM_OUT");
break; break;
} }
}, },
@ -922,6 +924,40 @@ function PtzControlPanel({
</TooltipButton> </TooltipButton>
</> </>
)} )}
{ptz?.features?.includes("focus") && (
<>
<TooltipButton
label={t("ptz.focus.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("FOCUS_IN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("FOCUS_IN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdCenterFocusStrong />
</TooltipButton>
<TooltipButton
label={t("ptz.focus.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("FOCUS_OUT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("FOCUS_OUT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdCenterFocusWeak />
</TooltipButton>
</>
)}
{ptz?.features?.includes("pt-r-fov") && ( {ptz?.features?.includes("pt-r-fov") && (
<TooltipProvider> <TooltipProvider>