Compare commits

...
9 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub f1690eb2d7 Merge 7a22963e6d into da4037eb52 2026-07-11 20:59:33 -07:00
Josh HawkinsandGitHub da4037eb52 UI tweaks (#23679)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* lock saved GenAI provider keys and add labels/validation to config map-key fields

* fix docs
2026-07-11 16:30:40 -06:00
Josh Hawkins 7a22963e6d fix test 2026-06-09 16:37:29 -05:00
Josh Hawkins 3aadc88a29 turn on switch by default if pan and/or tilt capability is available 2026-06-09 16:37:07 -05:00
Josh Hawkins 470abbab9d tweaks 2026-06-09 16:24:16 -05:00
Josh Hawkins 50591cb2fa backend add and remove subscriber 2026-06-09 15:58:34 -05:00
Josh Hawkins b4b946c624 add e2e test 2026-06-09 15:27:48 -05:00
Josh Hawkins c79d6cf2ea i18n 2026-06-09 15:27:38 -05:00
Josh Hawkins 7b83b936ab add ptz controls to camera via wizard when onvif has already been probed 2026-06-09 15:27:31 -05:00
14 changed files with 601 additions and 33 deletions
+4 -4
View File
@@ -72,10 +72,10 @@ Variables prefixed with `FRIGATE_` can be referenced in config fields that suppo
Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables.
| Field | Description |
| --------- | --------------------------------------------------------- |
| **Key** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
| **Value** | The value for the variable |
| Field | Description |
| ----------------- | --------------------------------------------------------- |
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
| **Value** | The value for the variable |
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
+37
View File
@@ -650,6 +650,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"]))],
@@ -807,6 +833,7 @@ async def onvif_probe(
# Check PTZ support and capabilities
ptz_supported = False
pan_tilt_supported = False
presets_count = 0
autotrack_supported = False
@@ -840,6 +867,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
@@ -959,6 +995,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,
}
+27 -1
View File
@@ -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")
@@ -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);
});
});
+19 -2
View File
@@ -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": {
@@ -1490,7 +1500,14 @@
"keyLabel": "Key",
"valueLabel": "Value",
"keyPlaceholder": "New key",
"remove": "Remove"
"remove": "Remove",
"providerNameLabel": "Provider name",
"providerNamePlaceholder": "e.g., openai",
"variableNameLabel": "Variable name",
"variableNamePlaceholder": "e.g., MY_VARIABLE",
"loggerNameLabel": "Logger name",
"loggerNamePlaceholder": "e.g., frigate.record",
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
},
"knownPlates": {
"namePlaceholder": "e.g., Wife's Car",
@@ -7,7 +7,13 @@ const environmentVars: SectionConfigOverrides = {
advancedFields: [],
uiSchema: {
additionalProperties: {
"ui:options": { size: "lg" },
"ui:options": {
size: "lg",
additionalPropertyKeyLabel:
"configForm.additionalProperties.variableNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.variableNamePlaceholder",
},
},
},
},
@@ -9,7 +9,15 @@ const genai: SectionConfigOverrides = {
uiSchema: {
"ui:options": { disableNestedCard: true },
"*": {
"ui:options": { disableNestedCard: true },
"ui:options": {
disableNestedCard: true,
additionalPropertyKeyLabel:
"configForm.additionalProperties.providerNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.providerNamePlaceholder",
additionalPropertyKeyPattern: "^[a-zA-Z0-9_-]+$",
preventKeyRename: true,
},
"ui:order": [
"provider",
"api_key",
@@ -12,7 +12,13 @@ const logger: SectionConfigOverrides = {
},
logs: {
additionalProperties: {
"ui:options": { enumI18nPrefix: "logger.logLevel" },
"ui:options": {
enumI18nPrefix: "logger.logLevel",
additionalPropertyKeyLabel:
"configForm.additionalProperties.loggerNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.loggerNamePlaceholder",
},
},
},
},
@@ -6,12 +6,14 @@ import {
StrictRJSFSchema,
WrapIfAdditionalTemplateProps,
} from "@rjsf/utils";
import { useEffect, useMemo, useState, type FocusEvent } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
export function WrapIfAdditionalTemplate<
T = unknown,
@@ -30,6 +32,7 @@ export function WrapIfAdditionalTemplate<
onKeyRenameBlur,
readonly,
required,
registry,
schema,
uiSchema,
} = props;
@@ -38,6 +41,55 @@ export function WrapIfAdditionalTemplate<
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
const keyLabelKey =
typeof uiOptions.additionalPropertyKeyLabel === "string"
? uiOptions.additionalPropertyKeyLabel
: undefined;
const keyPlaceholderKey =
typeof uiOptions.additionalPropertyKeyPlaceholder === "string"
? uiOptions.additionalPropertyKeyPlaceholder
: undefined;
const keyPattern =
typeof uiOptions.additionalPropertyKeyPattern === "string"
? uiOptions.additionalPropertyKeyPattern
: undefined;
const preventKeyRename = uiOptions.preventKeyRename === true;
const formContext = registry?.formContext as ConfigFormContext | undefined;
// optionally, lock the key once it's been saved
const baseline = formContext?.baselineFormData;
const keyLocked =
preventKeyRename &&
typeof label === "string" &&
!!baseline &&
Object.prototype.hasOwnProperty.call(baseline, label);
// controlled key value so we can validate live and block invalid renames.
const [keyValue, setKeyValue] = useState<string>(label ?? "");
useEffect(() => {
setKeyValue(label ?? "");
}, [label]);
const keyRegex = useMemo(
() => (keyPattern ? new RegExp(keyPattern) : undefined),
[keyPattern],
);
const keyError = useMemo(() => {
if (!keyRegex || keyLocked) return null;
if (!keyRegex.test(keyValue)) {
return t("configForm.additionalProperties.keyPatternError", {
ns: "views/settings",
defaultValue:
"Use only letters, numbers, hyphens, and underscores (no spaces)",
});
}
return null;
}, [keyRegex, keyLocked, keyValue, t]);
if (!additional) {
return (
<div className={classNames} style={style}>
@@ -47,20 +99,26 @@ export function WrapIfAdditionalTemplate<
}
const keyId = `${id}-key`;
const keyLabel = t("configForm.additionalProperties.keyLabel", {
ns: "views/settings",
});
const keyLabel = keyLabelKey
? t(keyLabelKey, { ns: "views/settings" })
: t("configForm.additionalProperties.keyLabel", { ns: "views/settings" });
const valueLabel = t("configForm.additionalProperties.valueLabel", {
ns: "views/settings",
});
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
ns: "views/settings",
});
const keyPlaceholder = keyPlaceholderKey
? t(keyPlaceholderKey, { ns: "views/settings" })
: t("configForm.additionalProperties.keyPlaceholder", {
ns: "views/settings",
});
const removeLabel = t("configForm.additionalProperties.remove", {
ns: "views/settings",
});
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
const commitKeyRename = (e: FocusEvent<HTMLInputElement>) => {
if (readonly) return;
if (keyError) return;
onKeyRenameBlur?.(e);
};
return (
<div
@@ -70,23 +128,30 @@ export function WrapIfAdditionalTemplate<
{!keyIsReadonly && (
<div className="col-span-12 space-y-2 md:col-span-2">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
{keyIsReadonly ? (
{keyLocked ? (
<div
id={keyId}
className="flex items-center text-sm text-muted-foreground"
className="flex items-center break-all text-sm text-primary-variant"
>
{label}
</div>
) : (
<Input
id={keyId}
name={keyId}
required={required}
defaultValue={label}
placeholder={keyPlaceholder}
disabled={disabled || readonly}
onBlur={!readonly ? onKeyRenameBlur : undefined}
/>
<>
<Input
id={keyId}
name={keyId}
required={required}
value={keyValue}
placeholder={keyPlaceholder}
disabled={disabled || readonly}
onChange={(e) => setKeyValue(e.target.value)}
onBlur={!readonly ? commitKeyRename : undefined}
aria-invalid={keyError ? true : undefined}
/>
{keyError && (
<p className="text-xs text-destructive">{keyError}</p>
)}
</>
)}
</div>
)}
@@ -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,
@@ -205,6 +205,7 @@ export default function Step2ProbeOrSnapshot({
const update: Partial<WizardFormData> = {
probeMode: true,
probeResult: response.data,
probeCandidates: candidateUris,
candidateTests: {},
};
@@ -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<WizardFormData>;
@@ -64,6 +73,53 @@ export default function Step3StreamConfig({
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const [openCombobox, setOpenCombobox] = useState<string | null>(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<NonNullable<WizardFormData["onvif"]>>) => {
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({
</Button>
</div>
{ptzSupported && (
<Card className="bg-secondary text-primary">
<CardContent className="space-y-2 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<h4 className="font-medium">
{t("cameraWizard.step3.ptz.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("cameraWizard.step3.ptz.detectedNote")}
</p>
</div>
<Switch
checked={onvif?.enabled ?? false}
onCheckedChange={(checked) => updateOnvif({ enabled: checked })}
/>
</div>
{onvif?.enabled && (
<Collapsible
open={onvifDetailsOpen || onvifInvalid}
onOpenChange={setOnvifDetailsOpen}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 pl-0 hover:bg-transparent"
>
{onvifDetailsOpen || onvifInvalid ? (
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="size-4" />
)}
{t("cameraWizard.step3.ptz.connectionDetails")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-4 rounded-lg bg-background p-3">
<div className="space-y-2">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.ptz.host")}
</Label>
<Input
value={onvif.host}
onChange={(e) => updateOnvif({ host: e.target.value })}
className="h-8"
placeholder="192.168.1.100"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.ptz.port")}
</Label>
<Input
type="text"
inputMode="numeric"
value={onvif.port || ""}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
updateOnvif({ port: isNaN(parsed) ? 0 : parsed });
}}
className="h-8"
placeholder="8000"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.ptz.username")}
</Label>
<Input
value={onvif.user ?? ""}
onChange={(e) => updateOnvif({ user: e.target.value })}
className="h-8"
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.ptz.password")}
</Label>
<div className="relative">
<Input
type={showOnvifPassword ? "text" : "password"}
value={onvif.password ?? ""}
onChange={(e) =>
updateOnvif({ password: e.target.value })
}
className="h-8 pr-10"
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowOnvifPassword((s) => !s)}
>
{showOnvifPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>
)}
{!hasDetectRole && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step3.detectRoleWarning")}
</div>
)}
{onvifInvalid && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step3.ptz.hostRequiredWarning")}
</div>
)}
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
@@ -268,6 +268,7 @@ export default function Step4Validation({
customUrl: wizardData.customUrl,
streams: wizardData.streams,
hasBackchannel: wizardData.hasBackchannel,
onvif: wizardData.onvif,
};
onSave(configData);
+14
View File
@@ -119,6 +119,13 @@ export type WizardFormData = {
probeCandidates?: string[]; // candidate URLs from probe
candidateTests?: CandidateTestMap; // test results for candidates
hasBackchannel?: boolean; // true if camera supports backchannel audio
onvif?: {
enabled: boolean;
host: string;
port: number;
user?: string;
password?: string;
};
};
// API Response Types
@@ -169,6 +176,12 @@ export type CameraConfigData = {
live?: {
streams: Record<string, string>;
};
onvif?: {
host: string;
port: number;
user?: string;
password?: string;
};
};
};
go2rtc?: {
@@ -199,6 +212,7 @@ export type OnvifProbeResponse = {
firmware_version?: string;
profiles_count?: number;
ptz_supported?: boolean;
pan_tilt_supported?: boolean;
presets_count?: number;
autotrack_supported?: boolean;
move_status_supported?: boolean;