diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index a55f355fb..dc89035e0 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" diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 51e652af6..fe92af738 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -118,6 +118,7 @@ class OnvifController: "active": False, "features": [], "presets": {}, + "profiles": [], } return True except (Fault, ONVIFError, TransportError, Exception) as e: @@ -173,7 +174,11 @@ class OnvifController: ) ] - # log available profiles with names and tokens for debugging + # 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'", @@ -838,6 +843,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: 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/settings.json b/web/public/locales/en/views/settings.json index 523d993cd..578d25c37 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1571,5 +1571,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..54944bf92 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,23 @@ const onvif: SectionConfigOverrides = { ], advancedFields: ["tls_insecure", "ignore_time_mismatch"], overrideFields: [], + restartRequired: [ + "host", + "port", + "user", + "password", + "profile", + "tls_insecure", + "ignore_time_mismatch", + "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..a072bc709 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/OnvifProfileWidget.tsx @@ -0,0 +1,81 @@ +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 { data: ptzInfo } = useSWR( + isCameraLevel && cameraName ? `${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, "sm"); + const hasProfiles = profiles.length > 0; + const waiting = isCameraLevel && !!cameraName && !hasProfiles; + + const selected = value ?? AUTO_VALUE; + + if (waiting) { + return ( +
+ + + {t("onvif.profileLoading")} + +
+ ); + } + + return ( + + ); +} 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[]; };