From 8a8fd4ca8ec02f13db821b9bf4aa6d1aaf377f51 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:34:45 -0500 Subject: [PATCH] Add basic camera settings to UI for testing (#18690) * add basic camera add/edit pane to the UI for testing * only init model runner if transcription is enabled globally * fix role checkboxes --- frigate/events/audio.py | 7 +- web/public/locales/en/views/settings.json | 29 + .../components/settings/CameraEditForm.tsx | 439 ++++++++++ web/src/views/settings/CameraSettingsView.tsx | 782 ++++++++++-------- 4 files changed, 907 insertions(+), 350 deletions(-) create mode 100644 web/src/components/settings/CameraEditForm.tsx diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 9152428fa..791ba80e4 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -91,12 +91,7 @@ class AudioProcessor(util.Process): self.cameras = cameras self.config = config - if any( - [ - conf.audio_transcription.enabled_in_config == True - for conf in config.cameras.values() - ] - ): + if self.config.audio_transcription.enabled: self.transcription_model_runner = AudioTranscriptionModelRunner( self.config.audio_transcription.device, self.config.audio_transcription.model_size, diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 21e316cb9..f27ab51b6 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -176,6 +176,35 @@ "toast": { "success": "Review Classification configuration has been saved. Restart Frigate to apply changes." } + }, + "addCamera": "Add New Camera", + "editCamera": "Edit Camera:", + "selectCamera": "Select a Camera", + "backToSettings": "Back to Camera Settings", + "cameraConfig": { + "add": "Add Camera", + "edit": "Edit Camera", + "description": "Configure camera settings including stream inputs and roles.", + "name": "Camera Name", + "nameRequired": "Camera name is required", + "nameInvalid": "Camera name must contain only letters, numbers, underscores, or hyphens", + "namePlaceholder": "e.g., front_door", + "enabled": "Enabled", + "ffmpeg": { + "inputs": "Input Streams", + "path": "Stream Path", + "pathRequired": "Stream path is required", + "pathPlaceholder": "rtsp://...", + "roles": "Roles", + "rolesRequired": "At least one role is required", + "rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream", + "addInput": "Add Input Stream", + "removeInput": "Remove Input Stream", + "inputsRequired": "At least one input stream is required" + }, + "toast": { + "success": "Camera {{cameraName}} saved successfully" + } } }, "masksAndZones": { diff --git a/web/src/components/settings/CameraEditForm.tsx b/web/src/components/settings/CameraEditForm.tsx new file mode 100644 index 000000000..eb731b2b3 --- /dev/null +++ b/web/src/components/settings/CameraEditForm.tsx @@ -0,0 +1,439 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import Heading from "@/components/ui/heading"; +import { Separator } from "@/components/ui/separator"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useFieldArray } from "react-hook-form"; +import { z } from "zod"; +import axios from "axios"; +import { toast, Toaster } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useState, useMemo } from "react"; +import { LuTrash2, LuPlus } from "react-icons/lu"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; + +type ConfigSetBody = { + requires_restart: number; + // TODO: type this better + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config_data: any; + update_topic?: string; +}; + +const RoleEnum = z.enum(["audio", "detect", "record"]); +type Role = z.infer; + +type CameraEditFormProps = { + cameraName?: string; + onSave?: () => void; + onCancel?: () => void; +}; + +export default function CameraEditForm({ + cameraName, + onSave, + onCancel, +}: CameraEditFormProps) { + const { t } = useTranslation(["views/settings"]); + const { data: config } = useSWR("config"); + const [isLoading, setIsLoading] = useState(false); + + const formSchema = useMemo( + () => + z.object({ + cameraName: z + .string() + .min(1, { message: t("camera.cameraConfig.nameRequired") }) + .regex(/^[a-zA-Z0-9_-]+$/, { + message: t("camera.cameraConfig.nameInvalid"), + }), + enabled: z.boolean(), + ffmpeg: z.object({ + inputs: z + .array( + z.object({ + path: z.string().min(1, { + message: t("camera.cameraConfig.ffmpeg.pathRequired"), + }), + roles: z.array(RoleEnum).min(1, { + message: t("camera.cameraConfig.ffmpeg.rolesRequired"), + }), + }), + ) + .min(1, { + message: t("camera.cameraConfig.ffmpeg.inputsRequired"), + }) + .refine( + (inputs) => { + const roleOccurrences = new Map(); + inputs.forEach((input) => { + input.roles.forEach((role) => { + roleOccurrences.set( + role, + (roleOccurrences.get(role) || 0) + 1, + ); + }); + }); + return Array.from(roleOccurrences.values()).every( + (count) => count <= 1, + ); + }, + { + message: t("camera.cameraConfig.ffmpeg.rolesUnique"), + path: ["inputs"], + }, + ), + }), + }), + [t], + ); + + type FormValues = z.infer; + + // Determine available roles for default values + const usedRoles = useMemo(() => { + const roles = new Set(); + if (cameraName && config?.cameras[cameraName]) { + const camera = config.cameras[cameraName]; + camera.ffmpeg?.inputs?.forEach((input) => { + input.roles.forEach((role) => roles.add(role as Role)); + }); + } + return roles; + }, [cameraName, config]); + + const defaultValues: FormValues = { + cameraName: cameraName || "", + enabled: true, + ffmpeg: { + inputs: [ + { + path: "", + roles: usedRoles.has("detect") ? [] : ["detect"], + }, + ], + }, + }; + + // Load existing camera config if editing + if (cameraName && config?.cameras[cameraName]) { + const camera = config.cameras[cameraName]; + defaultValues.enabled = camera.enabled ?? true; + defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length + ? camera.ffmpeg.inputs.map((input) => ({ + path: input.path, + roles: input.roles as Role[], + })) + : defaultValues.ffmpeg.inputs; + } + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues, + mode: "onChange", + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "ffmpeg.inputs", + }); + + // Watch ffmpeg.inputs to track used roles + const watchedInputs = form.watch("ffmpeg.inputs"); + + const saveCameraConfig = (values: FormValues) => { + setIsLoading(true); + const configData: ConfigSetBody["config_data"] = { + cameras: { + [values.cameraName]: { + enabled: values.enabled, + ffmpeg: { + inputs: values.ffmpeg.inputs.map((input) => ({ + path: input.path, + roles: input.roles, + })), + }, + }, + }, + }; + + const requestBody: ConfigSetBody = { + requires_restart: 1, + config_data: configData, + }; + + // Add update_topic for new cameras + if (!cameraName) { + requestBody.update_topic = `config/cameras/${values.cameraName}/add`; + } + + axios + .put("config/set", requestBody) + .then((res) => { + if (res.status === 200) { + toast.success( + t("camera.cameraConfig.toast.success", { + cameraName: values.cameraName, + }), + { position: "top-center" }, + ); + if (onSave) onSave(); + } else { + throw new Error(res.statusText); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + const onSubmit = (values: FormValues) => { + if (cameraName && values.cameraName !== cameraName) { + // If camera name changed, delete old camera config + const deleteRequestBody: ConfigSetBody = { + requires_restart: 1, + config_data: { + cameras: { + [cameraName]: "", + }, + }, + update_topic: `config/cameras/${cameraName}/remove`, + }; + + axios + .put("config/set", deleteRequestBody) + .then(() => saveCameraConfig(values)) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + } else { + saveCameraConfig(values); + } + }; + + // Determine available roles for new streams + const getAvailableRoles = (): Role[] => { + const used = new Set(); + watchedInputs.forEach((input) => { + input.roles.forEach((role) => used.add(role)); + }); + return used.has("detect") ? [] : ["detect"]; + }; + + const getUsedRolesExcludingIndex = (excludeIndex: number) => { + const roles = new Set(); + watchedInputs.forEach((input, idx) => { + if (idx !== excludeIndex) { + input.roles.forEach((role) => roles.add(role)); + } + }); + return roles; + }; + + return ( + <> + + + {cameraName + ? t("camera.cameraConfig.edit") + : t("camera.cameraConfig.add")} + +
+ {t("camera.cameraConfig.description")} +
+ + +
+ + ( + + {t("camera.cameraConfig.name")} + + + + + + )} + /> + + ( + + + + + {t("camera.cameraConfig.enabled")} + + + )} + /> + +
+ {t("camera.cameraConfig.ffmpeg.inputs")} + {fields.map((field, index) => ( +
+ ( + + + {t("camera.cameraConfig.ffmpeg.path")} + + + + + + + )} + /> + + ( + + + {t("camera.cameraConfig.ffmpeg.roles")} + + +
+ {(["audio", "detect", "record"] as const).map( + (role) => ( + + ), + )} +
+
+ +
+ )} + /> + + +
+ ))} + + {form.formState.errors.ffmpeg?.inputs?.root && + form.formState.errors.ffmpeg.inputs.root.message} + + +
+ +
+ + +
+ + + + ); +} diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index 9e8605660..db2490f53 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -1,7 +1,6 @@ import Heading from "@/components/ui/heading"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Toaster } from "sonner"; -import { toast } from "sonner"; +import { Toaster, toast } from "sonner"; import { Form, FormControl, @@ -14,8 +13,8 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { Separator } from "../../components/ui/separator"; -import { Button } from "../../components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { Checkbox } from "@/components/ui/checkbox"; @@ -31,6 +30,17 @@ import { Trans, useTranslation } from "react-i18next"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws"; +import CameraEditForm from "@/components/settings/CameraEditForm"; +import { LuPlus } from "react-icons/lu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { IoMdArrowRoundBack } from "react-icons/io"; +import { isDesktop } from "react-device-detect"; type CameraSettingsViewProps = { selectedCamera: string; @@ -60,9 +70,23 @@ export default function CameraSettingsView({ const [changedValue, setChangedValue] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectDetections, setSelectDetections] = useState(false); + const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">( + "settings", + ); // Control view state + const [editCameraName, setEditCameraName] = useState( + undefined, + ); // Track camera being edited const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + // List of cameras for dropdown + const cameras = useMemo(() => { + if (config) { + return Object.keys(config.cameras).sort(); + } + return []; + }, [config]); + // zones and labels const zones = useMemo(() => { @@ -256,7 +280,14 @@ export default function CameraSettingsView({ document.title = t("documentTitle.camera"); }, [t]); - if (!cameraConfig && !selectedCamera) { + // Handle back navigation from add/edit form + const handleBack = useCallback(() => { + setViewMode("settings"); + setEditCameraName(undefined); + updateConfig(); + }, [updateConfig]); + + if (!cameraConfig && !selectedCamera && viewMode === "settings") { return ; } @@ -265,254 +296,184 @@ export default function CameraSettingsView({
- - camera.title - - - - - - camera.streams.title - - -
- { - sendEnabled(isChecked ? "ON" : "OFF"); - }} - /> -
- -
-
-
- camera.streams.desc -
- - - - camera.review.title - - -
-
- { - sendAlerts(isChecked ? "ON" : "OFF"); - }} - /> -
- + {viewMode === "settings" ? ( + <> + + {t("camera.title")} + +
+ + {cameras.length > 0 && ( +
+ + +
+ )}
-
-
+ + + + camera.streams.title + +
{ - sendDetections(isChecked ? "ON" : "OFF"); + sendEnabled(isChecked ? "ON" : "OFF"); }} />
-
- camera.review.desc + camera.streams.desc
-
-
+ - + + camera.review.title + - - camera.reviewClassification.title - +
+
+ { + sendAlerts(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+
+ { + sendDetections(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ camera.review.desc +
+
+
-
-
-

+ + + - camera.reviewClassification.desc + camera.reviewClassification.title -

-
- - - camera.reviewClassification.readTheDocumentation - {" "} - - + + +
+
+

+ + camera.reviewClassification.desc + +

+
+ + + camera.reviewClassification.readTheDocumentation + {" "} + + +
+
-
-
-
- -
0 && - "grid items-start gap-5 md:grid-cols-2", - )} - > - ( - - {zones && zones?.length > 0 ? ( - <> -
- - - camera.review.alerts - - - - - - camera.reviewClassification.selectAlertsZones - - -
-
- {zones?.map((zone) => ( - { - return ( - - - { - setChangedValue(true); - return checked - ? field.onChange([ - ...field.value, - zone.name, - ]) - : field.onChange( - field.value?.filter( - (value) => - value !== zone.name, - ), - ); - }} - /> - - - {zone.name.replaceAll("_", " ")} - - - ); - }} - /> - ))} -
- - ) : ( -
- - camera.reviewClassification.noDefinedZones - -
- )} - -
- {watchedAlertsZones && watchedAlertsZones.length > 0 - ? t( - "camera.reviewClassification.zoneObjectAlertsTips", - { - alertsLabels, - zone: watchedAlertsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), - }, - ) - : t("camera.reviewClassification.objectAlertsTips", { - alertsLabels, - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), - })} -
-
- )} - /> - - ( - - {zones && zones?.length > 0 && ( - <> -
- - - camera.review.detections - - - - {selectDetections && ( - - - camera.reviewClassification.selectDetectionsZones - - - )} -
- - {selectDetections && ( -
- {zones?.map((zone) => ( - { - return ( + + +
0 && + "grid items-start gap-5 md:grid-cols-2", + )} + > + ( + + {zones && zones?.length > 0 ? ( + <> +
+ + + camera.review.alerts + + + + + + camera.reviewClassification.selectAlertsZones + + +
+
+ {zones?.map((zone) => ( + ( { + setChangedValue(true); return checked ? field.onChange([ ...field.value, @@ -542,126 +504,258 @@ export default function CameraSettingsView({ {zone.name.replaceAll("_", " ")} - ); - }} - /> - ))} + )} + /> + ))} +
+ + ) : ( +
+ + camera.reviewClassification.noDefinedZones +
)} - -
- -
- -
+
+ {watchedAlertsZones && watchedAlertsZones.length > 0 + ? t( + "camera.reviewClassification.zoneObjectAlertsTips", + { + alertsLabels, + zone: watchedAlertsZones + .map((zone) => + capitalizeFirstLetter(zone).replaceAll( + "_", + " ", + ), + ) + .join(", "), + cameraName: capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " "), + }, + ) + : t( + "camera.reviewClassification.objectAlertsTips", + { + alertsLabels, + cameraName: capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " "), + }, + )}
- + )} + /> -
- {watchedDetectionsZones && - watchedDetectionsZones.length > 0 ? ( - !selectDetections ? ( - - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), - }} - ns="views/settings" - > - ) : ( - - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), - }} - ns="views/settings" - /> - ) - ) : ( - - )} -
- + ( + + {zones && zones?.length > 0 && ( + <> +
+ + + camera.review.detections + + + + {selectDetections && ( + + + camera.reviewClassification.selectDetectionsZones + + + )} +
+ + {selectDetections && ( +
+ {zones?.map((zone) => ( + ( + + + { + return checked + ? field.onChange([ + ...field.value, + zone.name, + ]) + : field.onChange( + field.value?.filter( + (value) => + value !== zone.name, + ), + ); + }} + /> + + + {zone.name.replaceAll("_", " ")} + + + )} + /> + ))} +
+ )} + + +
+ +
+ +
+
+ + )} + +
+ {watchedDetectionsZones && + watchedDetectionsZones.length > 0 ? ( + !selectDetections ? ( + + capitalizeFirstLetter(zone).replaceAll( + "_", + " ", + ), + ) + .join(", "), + cameraName: capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " "), + }} + ns="views/settings" + /> + ) : ( + + capitalizeFirstLetter(zone).replaceAll( + "_", + " ", + ), + ) + .join(", "), + cameraName: capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " "), + }} + ns="views/settings" + /> + ) + ) : ( + + )} +
+
+ )} + /> +
+ + +
+ + +
+ + + + ) : ( + <> +
+ +
+
+
- - -
- - -
- - + + )}