diff --git a/frigate/api/camera.py b/frigate/api/camera.py index f54b859454..c78166b0f2 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -637,6 +637,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"]))], @@ -794,6 +820,7 @@ async def onvif_probe( # Check PTZ support and capabilities ptz_supported = False + pan_tilt_supported = False presets_count = 0 autotrack_supported = False @@ -827,6 +854,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 @@ -946,6 +982,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, } diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index e48b3e7877..7d4dbb2720 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -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") diff --git a/web/e2e/specs/settings/camera-wizard-ptz.spec.ts b/web/e2e/specs/settings/camera-wizard-ptz.spec.ts new file mode 100644 index 0000000000..04763e056b --- /dev/null +++ b/web/e2e/specs/settings/camera-wizard-ptz.spec.ts @@ -0,0 +1,187 @@ +/** + * Add-camera wizard — PTZ controls pane. + * + * The pane lives on Step 3 (Stream Configuration) and only appears when the + * ONVIF probe from Step 2 reported `ptz_supported`. These tests drive the + * wizard to Step 3 with a mocked probe and assert the pane's contract: + * 1. Visible + enabled when the probe reports PTZ, with the connection + * fields collapsed by default and pre-filled once expanded. + * 2. Toggling the switch off hides the disclosure and its fields. + * 3. Clearing the host shows the validation warning and blocks "Next". + * 4. Absent entirely when the probe reports no PTZ support. + * + * The save path (writing the `onvif` section to config/set) runs through + * Step 4's live-validation flow, which registers go2rtc streams and renders + * MSE previews that are unreliable in headless Chromium. Consistent with + * clone-camera.spec.ts, that assertion is deferred to manual QA; the logic is + * covered by the Step 4 -> parent handleSave wiring. + */ + +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page, Locator } from "@playwright/test"; + +const PTZ_PROBE = { + success: true, + host: "192.168.1.100", + port: 80, + manufacturer: "Acme", + model: "PTZ-1", + firmware_version: "1.0", + profiles_count: 1, + ptz_supported: true, + pan_tilt_supported: true, + presets_count: 2, + autotrack_supported: false, + rtsp_candidates: [ + { + source: "GetStreamUri", + profile_token: "profile_1", + uri: "rtsp://admin:pw@192.168.1.100:554/stream1", + }, + ], +}; + +async function mockProbe(page: Page, probe: object) { + await page.route("**/api/onvif/probe**", (route) => + route.fulfill({ json: probe }), + ); +} + +/** Open the wizard and drive Step 1 -> Step 2 -> Step 3. */ +async function gotoStep3(page: Page, host = "192.168.1.100") { + await page.getByRole("button", { name: /Add New Camera/i }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + // Step 1: name + host (probe mode is the default), then Continue. + await dialog.getByPlaceholder(/front_door/i).fill("ptz_test_camera"); + await dialog.getByPlaceholder("192.168.1.100").fill(host); + await dialog.getByRole("button", { name: /^Continue$/i }).click(); + + // Step 2: the probe auto-runs on mount; once candidates exist, Next enables. + const next = dialog.getByRole("button", { name: /^Next$/i }); + await expect(next).toBeEnabled({ timeout: 10_000 }); + await next.click(); + + // Step 3 is the stream-configuration step; key off a stable control rather + // than the description text (which has been reworded). + await expect( + dialog.getByRole("button", { name: /Add Another Stream/i }), + ).toBeVisible(); + return dialog; +} + +/** The PTZ enable switch (scoped to the PTZ card header row). */ +function ptzSwitch(dialog: Locator) { + return dialog + .locator("div.justify-between", { hasText: "Enable PTZ Controls" }) + .getByRole("switch"); +} + +/** Expand the (collapsed-by-default) ONVIF connection-detail fields. */ +async function expandOnvifDetails(dialog: Locator) { + await dialog + .getByRole("button", { name: /ONVIF connection details/i }) + .click(); +} + +test.describe("Camera wizard PTZ pane @medium @mobile", () => { + test.beforeEach(async ({ frigateApp }) => { + // not in the default mock; unmocked it 500s and trips the error collector + await frigateApp.page.route("**/api/config/raw_paths", (route) => + route.fulfill({ json: {} }), + ); + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); + }); + + test("shows an enabled PTZ pane with collapsed, pre-filled fields", async ({ + frigateApp, + }) => { + await mockProbe(frigateApp.page, PTZ_PROBE); + const dialog = await gotoStep3(frigateApp.page); + + // Card is present with the detected note and the switch defaults on. + await expect( + dialog.getByText("Enable PTZ Controls", { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText(/PTZ support has been detected via ONVIF/i), + ).toBeVisible(); + await expect(ptzSwitch(dialog)).toBeChecked(); + + // Connection fields are collapsed by default. + await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveCount(0); + + // Expanding reveals the host pre-filled from the probe. + await expandOnvifDetails(dialog); + await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveValue( + "192.168.1.100", + ); + }); + + test("toggling the switch off hides the disclosure and fields", async ({ + frigateApp, + }) => { + await mockProbe(frigateApp.page, PTZ_PROBE); + const dialog = await gotoStep3(frigateApp.page); + + await expandOnvifDetails(dialog); + await expect(dialog.getByPlaceholder("192.168.1.100")).toBeVisible(); + + await ptzSwitch(dialog).click(); + + await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveCount(0); + await expect( + dialog.getByRole("button", { name: /ONVIF connection details/i }), + ).toHaveCount(0); + }); + + test("clearing the host shows the warning and blocks Next", async ({ + frigateApp, + }) => { + await mockProbe(frigateApp.page, PTZ_PROBE); + const dialog = await gotoStep3(frigateApp.page); + + await expandOnvifDetails(dialog); + await dialog.getByPlaceholder("192.168.1.100").fill(""); + + await expect( + dialog.getByText(/An ONVIF host and port are required/i), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: /^Next$/i }), + ).toBeDisabled(); + }); + + test("shows the pane but leaves the switch off without continuous pan/tilt", async ({ + frigateApp, + }) => { + // e.g. a varifocal camera: PTZ service present, but zoom/focus only + await mockProbe(frigateApp.page, { + ...PTZ_PROBE, + pan_tilt_supported: false, + }); + const dialog = await gotoStep3(frigateApp.page); + + await expect( + dialog.getByText("Enable PTZ Controls", { exact: true }), + ).toBeVisible(); + await expect(ptzSwitch(dialog)).not.toBeChecked(); + // with the switch off, the connection fields are not shown + await expect(dialog.getByPlaceholder("192.168.1.100")).toHaveCount(0); + }); + + test("hides the PTZ pane when the probe reports no PTZ support", async ({ + frigateApp, + }) => { + await mockProbe(frigateApp.page, { ...PTZ_PROBE, ptz_supported: false }); + const dialog = await gotoStep3(frigateApp.page); + + await expect( + dialog.getByText("Enable PTZ Controls", { exact: true }), + ).toHaveCount(0); + }); +}); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index e4bd7ae517..6cfe17c63f 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -364,7 +364,7 @@ } }, "step3": { - "description": "Configure stream roles and add additional streams for your camera.", + "description": "Configure features, stream roles, and add additional streams for your camera.", "streamsTitle": "Camera Streams", "addStream": "Add Stream", "addAnotherStream": "Add Another Stream", @@ -403,6 +403,16 @@ "featuresPopover": { "title": "Stream Features", "description": "Use go2rtc restreaming to reduce connections to your camera." + }, + "ptz": { + "title": "Enable PTZ Controls", + "detectedNote": "PTZ support has been detected via ONVIF. If this is a PTZ camera, enabling this option will allow you to control the camera's pan/tilt/zoom functions from the UI.", + "connectionDetails": "ONVIF connection details", + "host": "ONVIF Host", + "port": "ONVIF Port", + "username": "ONVIF Username", + "password": "ONVIF Password", + "hostRequiredWarning": "An ONVIF host and port are required when PTZ controls are enabled." } }, "step4": { diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx index 9392c1c959..a033f3a693 100644 --- a/web/src/components/settings/CameraWizardDialog.tsx +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -115,13 +115,19 @@ export default function CameraWizardDialog({ case 1: // Step 2: Can proceed if at least one stream exists (from probe or manual test) return (state.wizardData.streams?.length ?? 0) > 0; - case 2: - // Step 3: Can proceed if at least one stream has 'detect' role - return !!( + case 2: { + // Step 3: requires a detect stream; if PTZ is enabled, also require + // ONVIF host + port (fields are pre-filled but the user may clear them) + const hasDetect = !!( state.wizardData.streams?.some((stream) => stream.roles.includes("detect"), ) ?? false ); + const onvif = state.wizardData.onvif; + const onvifOk = + !onvif?.enabled || (!!onvif.host?.trim() && !!onvif.port); + return hasDetect && onvifOk; + } case 3: // Step 4: Always can proceed from final step (save will be handled there) return true; @@ -241,6 +247,20 @@ export default function CameraWizardDialog({ }); } + // Write the ONVIF section when PTZ controls are enabled + if (wizardData.onvif?.enabled && wizardData.onvif.host.trim()) { + configData.cameras[finalCameraName].onvif = { + host: wizardData.onvif.host.trim(), + port: wizardData.onvif.port, + ...(wizardData.onvif.user?.trim() && { + user: wizardData.onvif.user.trim(), + }), + ...(wizardData.onvif.password && { + password: wizardData.onvif.password, + }), + }; + } + const requestBody: ConfigSetBody = { requires_restart: 1, config_data: configData, diff --git a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx index 1f805594d6..7990dde92f 100644 --- a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx +++ b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx @@ -204,6 +204,7 @@ export default function Step2ProbeOrSnapshot({ .map((c: { uri: string }) => c.uri); onUpdate({ probeMode: true, + probeResult: response.data, probeCandidates: candidateUris, candidateTests: {}, }); diff --git a/web/src/components/settings/wizard/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx index 170a4bc301..aaa4e145b7 100644 --- a/web/src/components/settings/wizard/Step3StreamConfig.tsx +++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx @@ -3,7 +3,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useTranslation } from "react-i18next"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { LuPlus, LuTrash2, LuX } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import axios from "axios"; @@ -32,6 +32,10 @@ import { LuExternalLink, LuCheck, LuChevronsUpDown, + LuChevronDown, + LuChevronRight, + LuEye, + LuEyeOff, } from "react-icons/lu"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; @@ -44,6 +48,11 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; type Step3StreamConfigProps = { wizardData: Partial; @@ -64,6 +73,53 @@ export default function Step3StreamConfig({ const { getLocaleDocUrl } = useDocDomain(); const [testingStreams, setTestingStreams] = useState>(new Set()); const [openCombobox, setOpenCombobox] = useState(null); + const [showOnvifPassword, setShowOnvifPassword] = useState(false); + const [onvifDetailsOpen, setOnvifDetailsOpen] = useState(false); + + const onvif = wizardData.onvif; + const ptzSupported = wizardData.probeResult?.ptz_supported === true; + const panTiltSupported = wizardData.probeResult?.pan_tilt_supported === true; + const onvifInvalid = !!onvif?.enabled && (!onvif.host?.trim() || !onvif.port); + + // Seed the PTZ pane once from the successful ONVIF probe + useEffect(() => { + // run only on first entry and never clobber a user's later toggle-off or edits + if (ptzSupported && wizardData.onvif === undefined) { + onUpdate({ + onvif: { + enabled: panTiltSupported, + host: wizardData.host ?? "", + port: wizardData.onvifPort ?? 8000, + user: wizardData.username ?? "", + password: wizardData.password ?? "", + }, + }); + } + }, [ + ptzSupported, + panTiltSupported, + wizardData.onvif, + wizardData.host, + wizardData.onvifPort, + wizardData.username, + wizardData.password, + onUpdate, + ]); + + const updateOnvif = useCallback( + (updates: Partial>) => { + onUpdate({ + onvif: { + enabled: false, + host: "", + port: 8000, + ...wizardData.onvif, + ...updates, + }, + }); + }, + [onUpdate, wizardData.onvif], + ); const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]); @@ -725,12 +781,136 @@ export default function Step3StreamConfig({ + {ptzSupported && ( + + +
+
+

+ {t("cameraWizard.step3.ptz.title")} +

+

+ {t("cameraWizard.step3.ptz.detectedNote")} +

+
+ updateOnvif({ enabled: checked })} + /> +
+ + {onvif?.enabled && ( + + + + + +
+ + updateOnvif({ host: e.target.value })} + className="h-8" + placeholder="192.168.1.100" + /> +
+ +
+ + { + const parsed = parseInt(e.target.value, 10); + updateOnvif({ port: isNaN(parsed) ? 0 : parsed }); + }} + className="h-8" + placeholder="8000" + /> +
+ +
+ + updateOnvif({ user: e.target.value })} + className="h-8" + placeholder={t("cameraWizard.step1.usernamePlaceholder")} + /> +
+ +
+ +
+ + updateOnvif({ password: e.target.value }) + } + className="h-8 pr-10" + placeholder={t( + "cameraWizard.step1.passwordPlaceholder", + )} + /> + +
+
+
+
+ )} +
+
+ )} + {!hasDetectRole && (
{t("cameraWizard.step3.detectRoleWarning")}
)} + {onvifInvalid && ( +
+ {t("cameraWizard.step3.ptz.hostRequiredWarning")} +
+ )} +
{onBack && (