diff --git a/docs/docs/configuration/autotracking.md b/docs/docs/configuration/autotracking.md index 86179a264..6dddef458 100644 --- a/docs/docs/configuration/autotracking.md +++ b/docs/docs/configuration/autotracking.md @@ -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: diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index eed430b52..84cf318e4 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -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 diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index c6ac207aa..e5eb16138 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -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: diff --git a/frigate/config/camera/onvif.py b/frigate/config/camera/onvif.py index eb21e24bd..836dec6aa 100644 --- a/frigate/config/camera/onvif.py +++ b/frigate/config/camera/onvif.py @@ -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", diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index a55f355fb..6474edf43 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -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 diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 488dbd278..79b771cb2 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -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: diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index ebe775504..470af687e 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -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.", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 8587ec263..e653818fa 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -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.", diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index 878470187..37e6b15db 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -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": { diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 42de28d52..4109b4821 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1573,5 +1573,9 @@ "hardwareNone": "No hardware acceleration", "hardwareAuto": "Automatic hardware acceleration" } + }, + "onvif": { + "profileAuto": "Auto", + "profileLoading": "Loading profiles..." } } diff --git a/web/src/components/config-form/section-configs/onvif.ts b/web/src/components/config-form/section-configs/onvif.ts index b8be693d6..c08cd7a58 100644 --- a/web/src/components/config-form/section-configs/onvif.ts +++ b/web/src/components/config-form/section-configs/onvif.ts @@ -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", diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 5df8564f2..5497e35b7 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -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, diff --git a/web/src/components/config-form/theme/widgets/OnvifProfileWidget.tsx b/web/src/components/config-form/theme/widgets/OnvifProfileWidget.tsx new file mode 100644 index 000000000..6743b5589 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/OnvifProfileWidget.tsx @@ -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( + 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 ( +
+ + + {t("onvif.profileLoading")} + +
+ ); + } + + return ( + + ); +} diff --git a/web/src/components/overlay/PtzControlPanel.tsx b/web/src/components/overlay/PtzControlPanel.tsx index 5deb62fd3..32e2c26f1 100644 --- a/web/src/components/overlay/PtzControlPanel.tsx +++ b/web/src/components/overlay/PtzControlPanel.tsx @@ -284,7 +284,9 @@ export default function PtzControlPanel({

{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")}

diff --git a/web/src/types/ptz.ts b/web/src/types/ptz.ts index 21a300b3d..02e55ae81 100644 --- a/web/src/types/ptz.ts +++ b/web/src/types/ptz.ts @@ -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[]; }; diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 418c74068..f8a36eb7b 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -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(null); const { send: sendPtz } = usePtzCommand(camera.name); - const handleOverlayClick = useCallback( - ( - e: React.MouseEvent | React.TouchEvent, - ) => { - 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 | KonvaEventObject) => { + 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 | KonvaEventObject) => { + 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({
+ {clickOverlay && overlaySize.width > 0 && ( +
+ + + {ptzRect && ( + + )} + + +
+ )}