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..d157c6f890 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,51 @@ 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 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: true, + host: wizardData.host ?? "", + port: wizardData.onvifPort ?? 8000, + user: wizardData.username ?? "", + password: wizardData.password ?? "", + }, + }); + } + }, [ + ptzSupported, + 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 +779,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 && (