ONVIF refactor (#22629)

* add profile support and decouple relative move from autotracking

* add drag to zoom

* docs

* add profile selection to UI

* dynamically update onvif config

* ui tweak

* docs

* docs tweak
This commit is contained in:
Josh Hawkins 2026-03-25 08:57:47 -05:00 committed by GitHub
parent f5937d8370
commit 3f6d5bcf22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 519 additions and 168 deletions

View File

@ -52,6 +52,10 @@ cameras:
password: admin
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
tls_insecure: False
# Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below)
# If not set, the first profile with valid PTZ configuration is selected automatically.
# Use this when your camera has multiple ONVIF profiles and you need to select a specific one.
profile: None
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.
autotracking:

View File

@ -91,6 +91,8 @@ If your ONVIF camera does not require authentication credentials, you may still
:::
If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera.
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
## ONVIF PTZ camera recommendations

View File

@ -951,7 +951,7 @@ cameras:
onvif:
# Required: host of the camera being connected to.
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
# NOTE: ONVIF user, and password can be specified with environment variables or docker secrets
# NOTE: ONVIF host, user, and password can be specified with environment variables or docker secrets
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
host: 0.0.0.0
# Optional: ONVIF port for device (default: shown below).
@ -966,6 +966,10 @@ cameras:
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
ignore_time_mismatch: False
# Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below)
# If not set, the first profile with valid PTZ configuration is selected automatically.
# Use this when your camera has multiple ONVIF profiles and you need to select a specific one.
profile: None
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.
autotracking:

View File

@ -117,6 +117,11 @@ class OnvifConfig(FrigateBaseModel):
title="Disable TLS verify",
description="Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).",
)
profile: Optional[str] = Field(
default=None,
title="ONVIF profile",
description="Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.",
)
autotracking: PtzAutotrackConfig = Field(
default_factory=PtzAutotrackConfig,
title="Autotracking",

View File

@ -23,6 +23,7 @@ class CameraConfigUpdateEnum(str, Enum):
notifications = "notifications"
objects = "objects"
object_genai = "object_genai"
onvif = "onvif"
record = "record"
remove = "remove" # for removing a camera
review = "review"
@ -130,6 +131,8 @@ class CameraConfigUpdateSubscriber:
config.lpr = updated_config
elif update_type == CameraConfigUpdateEnum.snapshots:
config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.onvif:
config.onvif = updated_config
elif update_type == CameraConfigUpdateEnum.zones:
config.zones = updated_config

View File

@ -15,6 +15,10 @@ from zeep.exceptions import Fault, TransportError
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.util.builtin import find_by_key
logger = logging.getLogger(__name__)
@ -65,7 +69,14 @@ class OnvifController:
self.camera_configs[cam_name] = cam
self.status_locks[cam_name] = asyncio.Lock()
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[CameraConfigUpdateEnum.onvif],
)
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop)
def _run_event_loop(self) -> None:
"""Run the event loop in a separate thread."""
@ -80,6 +91,52 @@ class OnvifController:
for cam_name in self.camera_configs:
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."""
while True:
await asyncio.sleep(1)
try:
updates = self.config_subscriber.check_for_updates()
for update_type, cameras in updates.items():
if update_type == CameraConfigUpdateEnum.onvif.name:
for cam_name in cameras:
await self._reinit_camera(cam_name)
except Exception:
logger.error("Error checking for ONVIF config updates")
async def _close_camera(self, cam_name: str) -> None:
"""Close the ONVIF client session for a camera."""
cam_state = self.cams.get(cam_name)
if cam_state and "onvif" in cam_state:
try:
await cam_state["onvif"].close()
except Exception:
logger.debug(f"Error closing ONVIF session for {cam_name}")
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")
# close existing session 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)
async def _init_single_camera(self, cam_name: str) -> bool:
"""Initialize a single camera by name.
@ -118,6 +175,7 @@ class OnvifController:
"active": False,
"features": [],
"presets": {},
"profiles": [],
}
return True
except (Fault, ONVIFError, TransportError, Exception) as e:
@ -161,22 +219,60 @@ class OnvifController:
)
return False
# build list of valid PTZ profiles
valid_profiles = [
p
for p in profiles
if p.VideoEncoderConfiguration
and p.PTZConfiguration
and (
p.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace is not None
or p.PTZConfiguration.DefaultContinuousZoomVelocitySpace is not None
)
]
# store available profiles for API response and log for debugging
self.cams[camera_name]["profiles"] = [
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
for p in valid_profiles
]
for p in valid_profiles:
logger.debug(
"Onvif profile for %s: name='%s', token='%s'",
camera_name,
getattr(p, "Name", None),
p.token,
)
configured_profile = self.config.cameras[camera_name].onvif.profile
profile = None
for _, onvif_profile in enumerate(profiles):
if (
onvif_profile.VideoEncoderConfiguration
and onvif_profile.PTZConfiguration
and (
onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace
is not None
or onvif_profile.PTZConfiguration.DefaultContinuousZoomVelocitySpace
is not 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
if profile is None:
available = [
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
for p in valid_profiles
]
logger.error(
"Onvif profile '%s' not found for camera %s. Available profiles: %s",
configured_profile,
camera_name,
available,
)
):
# use the first profile that has a valid ptz configuration
profile = onvif_profile
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
break
return False
else:
# use the first profile that has a valid ptz configuration
profile = valid_profiles[0] if valid_profiles else None
if profile is None:
logger.error(
@ -184,6 +280,8 @@ class OnvifController:
)
return False
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
# get the PTZ config for the profile
try:
configs = profile.PTZConfiguration
@ -218,48 +316,92 @@ class OnvifController:
move_request.ProfileToken = profile.token
self.cams[camera_name]["move_request"] = move_request
# extra setup for autotracking cameras
if (
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
and self.config.cameras[camera_name].onvif.autotracking.enabled
):
# get PTZ configuration options for feature detection and relative movement
ptz_config = None
fov_space_id = None
try:
request = ptz.create_type("GetConfigurationOptions")
request.ConfigurationToken = profile.PTZConfiguration.token
ptz_config = await ptz.GetConfigurationOptions(request)
logger.debug(f"Onvif config for {camera_name}: {ptz_config}")
logger.debug(
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
)
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.debug(
f"Unable to get PTZ configuration options for {camera_name}: {e}"
)
# detect FOV translation space for relative movement
if ptz_config is not None:
try:
fov_space_id = next(
(
i
for i, space in enumerate(
ptz_config.Spaces.RelativePanTiltTranslationSpace
)
if "TranslationSpaceFov" in space["URI"]
),
None,
)
except (AttributeError, TypeError):
fov_space_id = None
autotracking_config = self.config.cameras[camera_name].onvif.autotracking
autotracking_enabled = (
autotracking_config.enabled_in_config and autotracking_config.enabled
)
# autotracking-only: status request and service capabilities
if autotracking_enabled:
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
)
fov_space_id = next(
(
i
for i, space in enumerate(
ptz_config.Spaces.RelativePanTiltTranslationSpace
)
if "TranslationSpaceFov" in space["URI"]
),
None,
)
# 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
# setup relative move request when FOV relative movement is supported
if (
fov_space_id is not None
and configs.DefaultRelativePanTiltTranslationSpace is not None
):
# one-off GetStatus to seed Translation field
status = None
try:
status = await ptz.GetStatus(status_request)
logger.debug(f"Onvif status config for {camera_name}: {status}")
one_off_status_request = ptz.create_type("GetStatus")
one_off_status_request.ProfileToken = profile.token
status = await ptz.GetStatus(one_off_status_request)
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}")
status = None
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
# autotracking relative panning/tilting needs a relative zoom value set to 0
# if camera supports relative movement
rel_move_request = ptz.create_type("RelativeMove")
rel_move_request.ProfileToken = profile.token
logger.debug(f"{camera_name}: Relative move request: {rel_move_request}")
fov_uri = ptz_config["Spaces"]["RelativePanTiltTranslationSpace"][
fov_space_id
]["URI"]
if rel_move_request.Translation is None:
if status is not None:
# seed from current position
rel_move_request.Translation = status.Position
rel_move_request.Translation.PanTilt.space = fov_uri
else:
# fallback: construct Translation explicitly
rel_move_request.Translation = {
"PanTilt": {"x": 0, "y": 0, "space": fov_uri}
}
# configure zoom on relative move request
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
!= ZoomingModeEnum.disabled
autotracking_enabled
and autotracking_config.zooming != ZoomingModeEnum.disabled
):
zoom_space_id = next(
(
@ -271,60 +413,43 @@ class OnvifController:
),
None,
)
# 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
):
try:
if zoom_space_id is not None:
move_request.Translation.Zoom.space = ptz_config["Spaces"][
rel_move_request.Translation.Zoom.space = ptz_config["Spaces"][
"RelativeZoomTranslationSpace"
][zoom_space_id]["URI"]
else:
if (
move_request["Translation"] is not None
and "Zoom" in move_request["Translation"]
):
del move_request["Translation"]["Zoom"]
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}"
except Exception as e:
autotracking_config.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
)
except Exception as e:
self.config.cameras[
camera_name
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
else:
# remove zoom fields from relative move request
if (
rel_move_request["Translation"] is not None
and "Zoom" in rel_move_request["Translation"]
):
del rel_move_request["Translation"]["Zoom"]
if (
rel_move_request["Speed"] is not None
and "Zoom" in rel_move_request["Speed"]
):
del rel_move_request["Speed"]["Zoom"]
logger.debug(
f"{camera_name}: Relative move request after deleting zoom: {rel_move_request}"
)
if move_request.Speed is None:
move_request.Speed = configs.DefaultPTZSpeed if configs else None
if rel_move_request.Speed is None:
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
logger.debug(
f"{camera_name}: Relative move request after setup: {move_request}"
f"{camera_name}: Relative move request after setup: {rel_move_request}"
)
self.cams[camera_name]["relative_move_request"] = move_request
self.cams[camera_name]["relative_move_request"] = rel_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
# 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
# setup existing presets
try:
@ -358,48 +483,48 @@ class OnvifController:
if configs.DefaultRelativeZoomTranslationSpace:
supported_features.append("zoom-r")
if (
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
and self.config.cameras[camera_name].onvif.autotracking.enabled
):
if ptz_config is not None:
try:
# get camera's zoom limits from onvif config
self.cams[camera_name]["relative_zoom_range"] = (
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
)
except Exception as e:
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
self.config.cameras[
camera_name
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
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 configs.DefaultAbsoluteZoomPositionSpace:
supported_features.append("zoom-a")
if (
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
and self.config.cameras[camera_name].onvif.autotracking.enabled
):
if ptz_config is not None:
try:
# get camera's zoom limits from onvif config
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 self.config.cameras[camera_name].onvif.autotracking.zooming:
self.config.cameras[
camera_name
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
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}"
)
# 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
@ -416,10 +541,9 @@ class OnvifController:
except (Fault, ONVIFError, TransportError, Exception) as e:
logger.debug(f"Focus not supported for {camera_name}: {e}")
# detect FOV relative movement support
if (
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
fov_space_id is not None
and configs.DefaultRelativePanTiltTranslationSpace is not None
):
supported_features.append("pt-r-fov")
@ -548,11 +672,8 @@ class OnvifController:
move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt
if (
"zoom-r" in self.cams[camera_name]["features"]
and self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
# 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,
@ -560,7 +681,7 @@ class OnvifController:
},
"Zoom": {"x": speed},
}
move_request.Translation.Zoom.x = zoom
move_request["Translation"]["Zoom"] = {"x": zoom}
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
@ -568,12 +689,8 @@ class OnvifController:
move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 0
if (
"zoom-r" in self.cams[camera_name]["features"]
and self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
move_request.Translation.Zoom.x = 0
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
del move_request["Translation"]["Zoom"]
self.cams[camera_name]["active"] = False
@ -717,8 +834,18 @@ class OnvifController:
elif command == OnvifCommandEnum.preset:
await self._move_to_preset(camera_name, param)
elif command == OnvifCommandEnum.move_relative:
_, pan, tilt = param.split("_")
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
parts = param.split("_")
if len(parts) == 3:
_, pan, tilt = parts
zoom = 0.0
elif len(parts) == 4:
_, pan, tilt, zoom = parts
else:
logger.error(f"Invalid move_relative params: {param}")
return
await self._move_relative(
camera_name, float(pan), float(tilt), float(zoom), 1
)
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
await self._zoom(camera_name, command)
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
@ -773,6 +900,7 @@ class OnvifController:
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
"profiles": self.cams[camera_name].get("profiles", []),
}
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
@ -970,6 +1098,7 @@ class OnvifController:
return
logger.info("Exiting ONVIF controller...")
self.config_subscriber.stop()
def stop_and_cleanup():
try:

View File

@ -787,6 +787,10 @@
"label": "Disable TLS verify",
"description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only)."
},
"profile": {
"label": "ONVIF profile",
"description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically."
},
"autotracking": {
"label": "Autotracking",
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",

View File

@ -1536,6 +1536,10 @@
"label": "Disable TLS verify",
"description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only)."
},
"profile": {
"label": "ONVIF profile",
"description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically."
},
"autotracking": {
"label": "Autotracking",
"description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",

View File

@ -17,6 +17,7 @@
"clickMove": {
"label": "Click in the frame to center the camera",
"enable": "Enable click to move",
"enableWithZoom": "Enable click to move / drag to zoom",
"disable": "Disable click to move"
},
"left": {

View File

@ -1573,5 +1573,9 @@
"hardwareNone": "No hardware acceleration",
"hardwareAuto": "Automatic hardware acceleration"
}
},
"onvif": {
"profileAuto": "Auto",
"profileLoading": "Loading profiles..."
}
}

View File

@ -3,20 +3,12 @@ import type { SectionConfigOverrides } from "./types";
const onvif: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
restartRequired: [
"host",
"port",
"user",
"password",
"tls_insecure",
"ignore_time_mismatch",
"autotracking.calibrate_on_startup",
],
fieldOrder: [
"host",
"port",
"user",
"password",
"profile",
"tls_insecure",
"ignore_time_mismatch",
"autotracking",
@ -27,10 +19,14 @@ const onvif: SectionConfigOverrides = {
],
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
restartRequired: ["autotracking.calibrate_on_startup"],
uiSchema: {
host: {
"ui:options": { size: "sm" },
},
profile: {
"ui:widget": "onvifProfile",
},
autotracking: {
required_zones: {
"ui:widget": "zoneNames",

View File

@ -29,6 +29,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -79,6 +80,7 @@ export const frigateTheme: FrigateTheme = {
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
semanticSearchModel: SemanticSearchModelWidget,
onvifProfile: OnvifProfileWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,

View File

@ -0,0 +1,84 @@
import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ConfigFormContext } from "@/types/configForm";
import type { CameraPtzInfo } from "@/types/ptz";
import { getSizedFieldClassName } from "../utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
const AUTO_VALUE = "__auto__";
export function OnvifProfileWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const { t } = useTranslation(["views/settings"]);
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const cameraName = formContext?.cameraName;
const isCameraLevel = formContext?.level === "camera";
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
isCameraLevel && cameraName && hasOnvifHost
? `${cameraName}/ptz/info`
: null,
{
// ONVIF may not be initialized yet when the settings page loads,
// so retry until profiles become available
refreshInterval: (data) =>
data?.profiles && data.profiles.length > 0 ? 0 : 5000,
},
);
const profiles = ptzInfo?.profiles ?? [];
const fieldClassName = getSizedFieldClassName(options, "md");
const hasProfiles = profiles.length > 0;
const waiting = isCameraLevel && !!cameraName && hasOnvifHost && !hasProfiles;
const selected = value ?? AUTO_VALUE;
if (waiting) {
return (
<div className={cn("flex items-center gap-2", fieldClassName)}>
<ActivityIndicator className="size-4" />
<span className="text-sm text-muted-foreground">
{t("onvif.profileLoading")}
</span>
</div>
);
}
return (
<Select
value={String(selected)}
onValueChange={(val) => {
onChange(val === AUTO_VALUE ? null : val);
}}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={cn("text-left", fieldClassName)}>
<SelectValue placeholder={schema.title || "Select..."} />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_VALUE}>{t("onvif.profileAuto")}</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.token} value={p.token}>
{p.name !== p.token ? `${p.name} (${p.token})` : p.token}
</SelectItem>
))}
{!hasProfiles && value && value !== AUTO_VALUE && (
<SelectItem value={String(value)}>{String(value)}</SelectItem>
)}
</SelectContent>
</Select>
);
}

View File

@ -284,7 +284,9 @@ export default function PtzControlPanel({
<p>
{clickOverlay
? t("ptz.move.clickMove.disable")
: t("ptz.move.clickMove.enable")}
: ptz?.features?.includes("zoom-r")
? t("ptz.move.clickMove.enableWithZoom")
: t("ptz.move.clickMove.enable")}
</p>
</TooltipContent>
</Tooltip>

View File

@ -7,8 +7,14 @@ type PtzFeature =
| "pt-r-fov"
| "focus";
export type OnvifProfile = {
name: string;
token: string;
};
export type CameraPtzInfo = {
name: string;
features: PtzFeature[];
presets: string[];
profiles: OnvifProfile[];
};

View File

@ -122,6 +122,11 @@ import {
SnapshotResult,
} from "@/utils/snapshotUtil";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Stage, Layer, Rect } from "react-konva";
import type { KonvaEventObject } from "konva/lib/Node";
/** Pixel threshold to distinguish drag from click. */
const DRAG_MIN_PX = 15;
type LiveCameraViewProps = {
config?: FrigateConfig;
@ -213,45 +218,112 @@ export default function LiveCameraView({
};
}, [audioTranscriptionState, sendTranscription]);
// click overlay for ptzs
// click-to-move / drag-to-zoom overlay for PTZ cameras
const [clickOverlay, setClickOverlay] = useState(false);
const clickOverlayRef = useRef<HTMLDivElement>(null);
const { send: sendPtz } = usePtzCommand(camera.name);
const handleOverlayClick = useCallback(
(
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
if (!clickOverlay) {
return;
}
// drag rectangle state in stage-local coordinates
const [ptzRect, setPtzRect] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const [isPtzDrawing, setIsPtzDrawing] = useState(false);
// raw origin to determine drag direction (not min/max corrected)
const ptzOriginRef = useRef<{ x: number; y: number } | null>(null);
let clientX;
let clientY;
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
clientX = e.nativeEvent.touches[0].clientX;
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientX = e.nativeEvent.clientX;
clientY = e.nativeEvent.clientY;
}
const [overlaySize] = useResizeObserver(clickOverlayRef);
if (clickOverlayRef.current && clientX && clientY) {
const rect = clickOverlayRef.current.getBoundingClientRect();
const normalizedX = (clientX - rect.left) / rect.width;
const normalizedY = (clientY - rect.top) / rect.height;
const pan = (normalizedX - 0.5) * 2;
const tilt = (0.5 - normalizedY) * 2;
sendPtz(`move_relative_${pan}_${tilt}`);
const onPtzStageDown = useCallback(
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
const pos = e.target.getStage()?.getPointerPosition();
if (pos) {
setIsPtzDrawing(true);
ptzOriginRef.current = { x: pos.x, y: pos.y };
setPtzRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
}
},
[clickOverlayRef, clickOverlay, sendPtz],
[],
);
const onPtzStageMove = useCallback(
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
if (!isPtzDrawing || !ptzRect) return;
const pos = e.target.getStage()?.getPointerPosition();
if (pos) {
setPtzRect({
...ptzRect,
width: pos.x - ptzRect.x,
height: pos.y - ptzRect.y,
});
}
},
[isPtzDrawing, ptzRect],
);
const onPtzStageUp = useCallback(() => {
setIsPtzDrawing(false);
if (!ptzRect || !ptzOriginRef.current || overlaySize.width === 0) {
setPtzRect(null);
ptzOriginRef.current = null;
return;
}
const endX = ptzRect.x + ptzRect.width;
const endY = ptzRect.y + ptzRect.height;
const distX = Math.abs(ptzRect.width);
const distY = Math.abs(ptzRect.height);
if (distX < DRAG_MIN_PX && distY < DRAG_MIN_PX) {
// click — pan/tilt to point without zoom
const normX = endX / overlaySize.width;
const normY = endY / overlaySize.height;
const pan = (normX - 0.5) * 2;
const tilt = (0.5 - normY) * 2;
sendPtz(`move_relative_${pan}_${tilt}`);
} else {
// drag — pan/tilt to box center, zoom based on box size
const origin = ptzOriginRef.current;
const n0x = Math.min(origin.x, endX) / overlaySize.width;
const n0y = Math.min(origin.y, endY) / overlaySize.height;
const n1x = Math.max(origin.x, endX) / overlaySize.width;
const n1y = Math.max(origin.y, endY) / overlaySize.height;
let boxW = n1x - n0x;
let boxH = n1y - n0y;
// correct box to match camera aspect ratio so zoom is uniform
const frameAR = overlaySize.width / overlaySize.height;
const boxAR = boxW / boxH;
if (boxAR > frameAR) {
boxH = boxW / frameAR;
} else {
boxW = boxH * frameAR;
}
const centerX = (n0x + n1x) / 2;
const centerY = (n0y + n1y) / 2;
const pan = (centerX - 0.5) * 2;
const tilt = (0.5 - centerY) * 2;
// zoom magnitude from box size (small box = more zoom)
let zoom = Math.max(0.01, Math.min(1, Math.max(boxW, boxH)));
// drag direction: top-left → bottom-right = zoom in, reverse = zoom out
const zoomIn = endX > origin.x && endY > origin.y;
if (!zoomIn) zoom = -zoom;
sendPtz(`move_relative_${pan}_${tilt}_${zoom}`);
}
setPtzRect(null);
ptzOriginRef.current = null;
}, [ptzRect, overlaySize, sendPtz]);
// pip state
useEffect(() => {
@ -440,7 +512,8 @@ export default function LiveCameraView({
<TransformWrapper
minScale={1.0}
wheel={{ smoothStep: 0.005 }}
disabled={debug}
disabled={debug || clickOverlay}
panning={{ disabled: clickOverlay }}
>
<Toaster position="top-center" closeButton={true} />
<div
@ -634,13 +707,41 @@ export default function LiveCameraView({
}}
>
<div
className={`flex flex-col items-center justify-center ${growClassName}`}
className={`relative flex flex-col items-center justify-center ${growClassName}`}
ref={clickOverlayRef}
onClick={handleOverlayClick}
style={{
aspectRatio: constrainedAspectRatio,
}}
>
{clickOverlay && overlaySize.width > 0 && (
<div className="absolute inset-0 z-40 cursor-crosshair">
<Stage
width={overlaySize.width}
height={overlaySize.height}
onMouseDown={onPtzStageDown}
onMouseMove={onPtzStageMove}
onMouseUp={onPtzStageUp}
onTouchStart={onPtzStageDown}
onTouchMove={onPtzStageMove}
onTouchEnd={onPtzStageUp}
>
<Layer>
{ptzRect && (
<Rect
x={ptzRect.x}
y={ptzRect.y}
width={ptzRect.width}
height={ptzRect.height}
stroke="white"
strokeWidth={2}
dash={[6, 4]}
opacity={0.8}
/>
)}
</Layer>
</Stage>
</div>
)}
<LivePlayer
key={camera.name}
className={`${fullscreen ? "*:rounded-none" : ""}`}