diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index 8094c9f1c7..f21984c8b8 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -67,7 +67,7 @@ Additional cameras are simply added under the camera configuration section. -Navigate to and use the add camera button to configure each additional camera. +Navigate to and use the add camera button to configure each additional camera. diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 72f0cfd078..00a35a74ab 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -113,7 +113,7 @@ Here are some common starter configuration examples. These can be configured thr 3. Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb` 4. Navigate to and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion` 5. Navigate to and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` -6. Navigate to and add your camera with the appropriate RTSP stream URL +6. Navigate to and add your camera with the appropriate RTSP stream URL 7. Navigate to to add a motion mask for the camera timestamp @@ -192,7 +192,7 @@ cameras: 3. Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb` 4. Navigate to and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion` 5. Navigate to and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` -6. Navigate to and add your camera with the appropriate RTSP stream URL +6. Navigate to and add your camera with the appropriate RTSP stream URL 7. Navigate to to add a motion mask for the camera timestamp @@ -270,7 +270,7 @@ cameras: 4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings 5. Navigate to and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion` 6. Navigate to and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` -7. Navigate to and add your camera with the appropriate RTSP stream URL +7. Navigate to and add your camera with the appropriate RTSP stream URL 8. Navigate to to add a motion mask for the camera timestamp diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 5749379c63..bc58d50bbc 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -257,19 +257,38 @@ cameras: -### Disabling cameras +### Camera state -Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI. +Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**: -:::note +- **On** — streams are processed normally. Object detection, recording, and Live view are active. +- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart. +- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.** -Disabling a camera via the Frigate UI or MQTT is temporary and does not persist through restarts of Frigate. +#### Turning a camera on or off -::: +Turning a camera off is temporary and does not require a restart. The available controls are: -For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source). +- The power button in the single-camera Live view header +- The right-click context menu on a camera tile on the Live dashboard +- The Camera management settings pane (status set to **Off**) +- The mobile settings drawer on the single-camera Live view (admin users only) +- The [MQTT topic](/integrations/mqtt#frigatecamera_nameenabledset) `frigate//enabled/set` with payload `ON` or `OFF` +- The Home Assistant integration via the [`camera.turn_on` / `camera.turn_off` actions](/integrations/home-assistant#camera-api) -Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily. +#### Disabling a camera + +Disabling a camera saves the change to your configuration file. Navigate to **Settings → Global configuration → Camera management** and set the camera's status to **Disabled**. Runtime processing stops immediately; the change persists across restarts. + +Re-enabling a disabled camera requires a restart of Frigate so that the ffmpeg processes and other camera-scoped resources can be initialized. The UI will prompt you to restart when you switch a disabled camera back to On. + +#### Restream behavior + +For both Off and Disabled cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source). + +#### Choosing Off versus Disabled + +If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead. ### Live player error messages diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md index acb6cf4826..3954ee956f 100644 --- a/docs/docs/configuration/profiles.md +++ b/docs/docs/configuration/profiles.md @@ -33,10 +33,10 @@ The easiest way to define profiles is to use the Frigate UI. Profiles can also b -1. **Create a profile** — Navigate to . Click the **Add Profile** button, enter a name (and optionally a profile ID). +1. **Create a profile** — Navigate to . Click the **Add Profile** button, enter a name (and optionally a profile ID). 2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button to clear overrides. -3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to , then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers. -4. **Delete a profile** — Navigate to , then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it. +3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to , then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers. +4. **Delete a profile** — Navigate to , then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it. @@ -135,10 +135,10 @@ A common use case is having different detection and notification settings based -1. Navigate to and create two profiles: **Home** and **Away**. +1. Navigate to and create two profiles: **Home** and **Away**. 2. From to the Camera configuration section in Settings, choose the **front_door** camera, and select the **Away** profile from the profile dropdown. Then, enable notifications from the Notifications pane, and set alert labels to `person` and `car` from the Review pane. Then, from the profile dropdown choose **Home** profile, then navigate to Notifications to disable notifications. 3. For the **indoor_cam** camera, perform similar steps - configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy. -4. Activate the desired profile from or from the **Profiles** option in Frigate's main menu. +4. Activate the desired profile from or from the **Profiles** option in Frigate's main menu. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index e5eb161386..a006de8fd2 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -840,8 +840,8 @@ cameras: # Required: name of the camera back: # Optional: Enable/Disable the camera (default: shown below). - # If disabled: config is used but no live stream and no capture etc. - # Events/Recordings are still viewable. + # When False, ffmpeg is not started and the camera is hidden from the UI + # (except Camera Management). Re-enabling requires a Frigate restart. enabled: True # Optional: camera type used for some Frigate features (default: shown below) # Options are "generic" and "lpr" diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index f112a0de96..0c8d5d325a 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -144,7 +144,7 @@ At this point you should be able to start Frigate and a basic config will be cre ### Step 2: Add a camera -Click the **Add Camera** button in to use the camera setup wizard to get your first camera added into Frigate. +Click the **Add Camera** button in to use the camera setup wizard to get your first camera added into Frigate. ### Step 3: Configure hardware acceleration (recommended) diff --git a/docs/docs/integrations/home-assistant.md b/docs/docs/integrations/home-assistant.md index 5b9c014377..14ee795289 100644 --- a/docs/docs/integrations/home-assistant.md +++ b/docs/docs/integrations/home-assistant.md @@ -195,7 +195,7 @@ For clips to be castable to media devices, audio is required and may need to be ## Camera API -To disable a camera dynamically +To turn a camera off (pauses Frigate's processing of the stream; does not persist across Frigate restarts; see [Camera state](/configuration/live#camera-state)): ``` action: camera.turn_off @@ -204,7 +204,7 @@ target: entity_id: camera.back_deck_cam # your Frigate camera entity ID ``` -To enable a camera that has been disabled dynamically +To turn a camera back on: ``` action: camera.turn_on @@ -213,6 +213,12 @@ target: entity_id: camera.back_deck_cam # your Frigate camera entity ID ``` +:::note + +These actions toggle Frigate's runtime On/Off state. To permanently disable a camera, set its status to **Disabled** in **Settings → Camera Management** in the Frigate UI. + +::: + ## Notification API Many people do not want to expose Frigate to the web, so the integration creates some public API endpoints that can be used for notifications. diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 28c178d1f5..03f5135f4b 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -306,7 +306,7 @@ Publishes the current health status of each role that is enabled (`audio`, `dete - `online`: Stream is running and being processed - `offline`: Stream is offline and is being restarted -- `disabled`: Camera is currently disabled +- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction. ### `frigate//` @@ -368,11 +368,11 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o ### `frigate//enabled/set` -Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`. +Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it. ### `frigate//enabled/state` -Topic with current state of processing for a camera. Published values are `ON` and `OFF`. +Topic with current runtime state of processing for a camera. Published values are `ON` and `OFF`. ### `frigate//detect/set` diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index 6ceef7e0cd..011baaa1bf 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -12,7 +12,7 @@ "title": "Stream Offline", "desc": "No frames have been received on the {{cameraName}} detect stream, check error logs" }, - "cameraDisabled": "Camera is disabled", + "cameraOff": "Camera is off", "stats": { "streamType": { "title": "Stream Type:", diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index 3aa892222a..f2ea2721f1 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -57,8 +57,8 @@ "presets": "PTZ camera presets" }, "camera": { - "enable": "Enable Camera", - "disable": "Disable Camera" + "turnOn": "Turn Camera On", + "turnOff": "Turn Camera Off" }, "muteCameras": { "enable": "Mute All Cameras", @@ -153,7 +153,7 @@ }, "cameraSettings": { "title": "{{camera}} Settings", - "cameraEnabled": "Camera Enabled", + "camera": "Camera", "objectDetection": "Object Detection", "recording": "Recording", "snapshots": "Snapshots", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 11fcd92123..9731eb22dc 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -103,7 +103,7 @@ "cameraUi": "Camera UI", "cameraTimestampStyle": "Timestamp style", "cameraMqtt": "Camera MQTT", - "cameraManagement": "Management", + "cameraManagement": "Camera management", "cameraReview": "Review", "masksAndZones": "Masks / Zones", "motionTuner": "Motion tuner", @@ -457,7 +457,7 @@ }, "cameraManagement": { "title": "Manage Cameras", - "description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.", + "description": "Add, edit, and delete cameras, control the state of each camera, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.", "addCamera": "Add New Camera", "deleteCamera": "Delete Camera", "deleteCameraDialog": { @@ -475,12 +475,17 @@ "selectCamera": "Select a Camera", "backToSettings": "Back to Camera Settings", "streams": { - "title": "Enable / Disable Cameras", - "enableLabel": "Enabled cameras", - "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.

Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.", - "disableLabel": "Disabled cameras", - "disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.", - "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.", + "title": "Camera State and Details", + "label": "Camera state", + "description": "Set the operating state for each camera.

On: streams are processed normally.
Off: temporarily pauses processing. Does not persist across Frigate restarts.
Disabled: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.

Note: Disabling does not affect go2rtc restreams.

Drag the handle to reorder active cameras as they appear throughout the UI, including the Live dashboard and camera selection dropdowns.", + "disabledSubheading": "Disabled in configuration", + "status": { + "on": "On", + "off": "Off", + "disabled": "Disabled" + }, + "enableSuccess": "Enabled {{cameraName}}. Restart Frigate to apply.", + "disableSuccess": "Disabled {{cameraName}} and saved to configuration.", "reorderHandle": "Drag to reorder", "saving": "Saving…", "saved": "Saved", @@ -527,10 +532,10 @@ "profiles": { "title": "Profile Camera Overrides", "selectLabel": "Select profile", - "description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.", + "description": "Configure which cameras are turned on or off when a profile is activated. Cameras set to \"Inherit\" keep their default state.", "inherit": "Inherit", - "enabled": "Enabled", - "disabled": "Disabled" + "on": "On", + "off": "Off" }, "cameraType": { "title": "Camera Type", diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 8ed78e348c..c5c8c56c9a 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -320,7 +320,7 @@ export default function LiveContextMenu({ onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")} >
- {isEnabled ? t("camera.disable") : t("camera.enable")} + {isEnabled ? t("camera.turnOff") : t("camera.turnOn")}
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 0ab7033528..0170f9dd1e 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -488,7 +488,7 @@ export default function LivePlayer({

- {t("cameraDisabled")} + {t("cameraOff")}

diff --git a/web/src/components/settings/CameraEditForm.tsx b/web/src/components/settings/CameraEditForm.tsx deleted file mode 100644 index efae88aac5..0000000000 --- a/web/src/components/settings/CameraEditForm.tsx +++ /dev/null @@ -1,755 +0,0 @@ -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 { Card, CardContent } from "@/components/ui/card"; -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 } from "sonner"; -import { useTranslation } from "react-i18next"; -import { useState, useMemo, useEffect } 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"; -import { processCameraName } from "@/utils/cameraUtil"; -import { Label } from "@/components/ui/label"; -import { ConfigSetBody } from "@/types/cameraWizard"; -import { Toaster } from "../ui/sonner"; - -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, mutate: mutateConfig } = - useSWR("config"); - const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{ - cameras: Record< - string, - { ffmpeg: { inputs: { path: string; roles: string[] }[] } } - >; - go2rtc: { streams: Record }; - }>(cameraName ? "config/raw_paths" : null); - const [isLoading, setIsLoading] = useState(false); - - const formSchema = useMemo( - () => - z.object({ - cameraName: z - .string() - .min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }), - enabled: z.boolean(), - ffmpeg: z.object({ - inputs: z - .array( - z.object({ - path: z.string().min(1, { - message: t( - "cameraManagement.cameraConfig.ffmpeg.pathRequired", - ), - }), - roles: z.array(RoleEnum).min(1, { - message: t( - "cameraManagement.cameraConfig.ffmpeg.rolesRequired", - ), - }), - }), - ) - .min(1, { - message: t("cameraManagement.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("cameraManagement.cameraConfig.ffmpeg.rolesUnique"), - path: ["inputs"], - }, - ), - }), - go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(), - }), - [t], - ); - - type FormValues = z.infer; - - const cameraInfo = useMemo(() => { - if (!cameraName || !config?.cameras[cameraName]) { - return { - friendly_name: undefined, - name: cameraName || "", - roles: new Set(), - go2rtcStreams: {}, - }; - } - - const camera = config.cameras[cameraName]; - const roles = new Set(); - - camera.ffmpeg?.inputs?.forEach((input) => { - input.roles.forEach((role) => roles.add(role as Role)); - }); - - // Load existing go2rtc streams - const go2rtcStreams = config.go2rtc?.streams || {}; - - return { - friendly_name: camera?.friendly_name || cameraName, - name: cameraName, - roles, - go2rtcStreams, - }; - }, [cameraName, config]); - - const defaultValues: FormValues = { - cameraName: cameraInfo?.friendly_name || cameraName || "", - enabled: true, - ffmpeg: { - inputs: [ - { - path: "", - roles: cameraInfo.roles.has("detect") ? [] : ["detect"], - }, - ], - }, - go2rtcStreams: {}, - }; - - // Load existing camera config if editing - if (cameraName && config?.cameras[cameraName]) { - const camera = config.cameras[cameraName]; - defaultValues.enabled = camera.enabled ?? true; - - // Use raw paths from the admin endpoint if available, otherwise fall back to masked paths - const rawCameraData = rawPaths?.cameras?.[cameraName]; - defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length - ? rawCameraData.ffmpeg.inputs.map((input) => ({ - path: input.path, - roles: input.roles as Role[], - })) - : camera.ffmpeg?.inputs?.length - ? camera.ffmpeg.inputs.map((input) => ({ - path: input.path, - roles: input.roles as Role[], - })) - : defaultValues.ffmpeg.inputs; - - const go2rtcStreams = - rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {}; - const cameraStreams: Record = {}; - - // get candidate stream names for this camera. could be the camera's own name, - // any restream names referenced by this camera, or any keys under live --> streams - const validNames = new Set(); - validNames.add(cameraName); - - // deduce go2rtc stream names from rtsp restream inputs - camera.ffmpeg?.inputs?.forEach((input) => { - // exclude any query strings or trailing slashes from the stream name - const restreamMatch = input.path.match( - /^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/, - ); - if (restreamMatch) { - const streamName = restreamMatch[1]; - validNames.add(streamName); - } - }); - - // Include live --> streams keys - const liveStreams = camera?.live?.streams; - if (liveStreams) { - Object.keys(liveStreams).forEach((key) => { - validNames.add(key); - }); - } - - // Map only go2rtc entries that match the collected names - Object.entries(go2rtcStreams).forEach(([name, urls]) => { - if (validNames.has(name)) { - cameraStreams[name] = Array.isArray(urls) ? urls : [urls]; - } - }); - - defaultValues.go2rtcStreams = cameraStreams; - } - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues, - mode: "onChange", - }); - - // Update form values when rawPaths loads - useEffect(() => { - if ( - cameraName && - config?.cameras[cameraName] && - rawPaths?.cameras?.[cameraName] - ) { - const camera = config.cameras[cameraName]; - const rawCameraData = rawPaths.cameras[cameraName]; - - // Update ffmpeg inputs with raw paths - if (rawCameraData.ffmpeg?.inputs?.length) { - form.setValue( - "ffmpeg.inputs", - rawCameraData.ffmpeg.inputs.map((input) => ({ - path: input.path, - roles: input.roles as Role[], - })), - ); - } - - // Update go2rtc streams with raw URLs - if (rawPaths.go2rtc?.streams) { - const validNames = new Set(); - validNames.add(cameraName); - - camera.ffmpeg?.inputs?.forEach((input) => { - const restreamMatch = input.path.match( - /^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/, - ); - if (restreamMatch) { - validNames.add(restreamMatch[1]); - } - }); - - const liveStreams = camera?.live?.streams; - if (liveStreams) { - Object.keys(liveStreams).forEach((key) => validNames.add(key)); - } - - const cameraStreams: Record = {}; - Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => { - if (validNames.has(name)) { - cameraStreams[name] = Array.isArray(urls) ? urls : [urls]; - } - }); - - if (Object.keys(cameraStreams).length > 0) { - form.setValue("go2rtcStreams", cameraStreams); - } - } - } - }, [cameraName, config, rawPaths, form]); - - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "ffmpeg.inputs", - }); - - // Watch ffmpeg.inputs to track used roles - const watchedInputs = form.watch("ffmpeg.inputs"); - - // Watch go2rtc streams - const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {}; - - const saveCameraConfig = (values: FormValues) => { - setIsLoading(true); - const { finalCameraName, friendlyName } = processCameraName( - values.cameraName, - ); - - const configData: ConfigSetBody["config_data"] = { - cameras: { - [finalCameraName]: { - enabled: values.enabled, - ...(friendlyName && { friendly_name: friendlyName }), - ffmpeg: { - inputs: values.ffmpeg.inputs.map((input) => ({ - path: input.path, - roles: input.roles, - })), - }, - }, - }, - }; - - // Add go2rtc streams if provided - if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) { - configData.go2rtc = { - streams: values.go2rtcStreams, - }; - } - - const requestBody: ConfigSetBody = { - requires_restart: 1, - config_data: configData, - }; - - // Add update_topic for new cameras - if (!cameraName) { - requestBody.update_topic = `config/cameras/${finalCameraName}/add`; - } - - axios - .put("config/set", requestBody) - .then((res) => { - if (res.status === 200) { - // Update running go2rtc instance if streams were configured - if ( - values.go2rtcStreams && - Object.keys(values.go2rtcStreams).length > 0 - ) { - const updatePromises = Object.entries(values.go2rtcStreams).map( - ([streamName, urls]) => - axios.put( - `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, - ), - ); - - Promise.allSettled(updatePromises).then(() => { - toast.success( - t("cameraManagement.cameraConfig.toast.success", { - cameraName: values.cameraName, - }), - { position: "top-center" }, - ); - mutateConfig(); - mutateRawPaths(); - if (onSave) onSave(); - }); - } else { - toast.success( - t("cameraManagement.cameraConfig.toast.success", { - cameraName: values.cameraName, - }), - { position: "top-center" }, - ); - mutateConfig(); - mutateRawPaths(); - 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 && - values.cameraName !== cameraInfo?.friendly_name - ) { - // If camera name changed, delete old camera config - const deleteRequestBody = { - requires_restart: 1, - config_data: { - cameras: { - [cameraName]: null, - }, - }, - 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("cameraManagement.cameraConfig.edit") - : t("cameraManagement.cameraConfig.add")} - -
- {t("cameraManagement.cameraConfig.description")} -
- - -
- - ( - - {t("cameraManagement.cameraConfig.name")} - - - - - - )} - /> - - ( - - - - - - {t("cameraManagement.cameraConfig.enabled")} - - - - )} - /> - -
- - {fields.map((field, index) => ( - - -
-

- {t("cameraWizard.step3.streamTitle", { - number: index + 1, - })} -

- -
- - ( - - - {t("cameraManagement.cameraConfig.ffmpeg.path")} - - - - - - - )} - /> - -
- -
-
- {(["detect", "record", "audio"] as const).map( - (role) => { - const isUsedElsewhere = - getUsedRolesExcludingIndex(index).has(role); - const isChecked = - watchedInputs[index]?.roles?.includes(role) || - false; - return ( -
- - {role} - - { - const currentRoles = - watchedInputs[index]?.roles || []; - const updatedRoles = checked - ? [...currentRoles, role] - : currentRoles.filter((r) => r !== role); - form.setValue( - `ffmpeg.inputs.${index}.roles`, - updatedRoles, - ); - }} - disabled={!isChecked && isUsedElsewhere} - /> -
- ); - }, - )} -
-
-
-
-
- ))} - - {form.formState.errors.ffmpeg?.inputs?.root && - form.formState.errors.ffmpeg.inputs.root.message} - - -
- - {/* go2rtc Streams Section */} - {Object.keys(watchedGo2rtcStreams).length > 0 && ( -
- - {Object.entries(watchedGo2rtcStreams).map( - ([streamName, urls]) => ( - - -
-

{streamName}

- -
- -
- - {(Array.isArray(urls) ? urls : [urls]).map( - (url, urlIndex) => ( -
- { - const updatedStreams = { - ...watchedGo2rtcStreams, - }; - const currentUrls = Array.isArray( - updatedStreams[streamName], - ) - ? updatedStreams[streamName] - : [updatedStreams[streamName]]; - currentUrls[urlIndex] = e.target.value; - updatedStreams[streamName] = currentUrls; - form.setValue( - "go2rtcStreams", - updatedStreams, - ); - }} - placeholder="rtsp://username:password@host:port/path" - /> - {(Array.isArray(urls) ? urls : [urls]).length > - 1 && ( - - )} -
- ), - )} - -
-
-
- ), - )} - -
- )} - -
- - -
- - -
- ); -} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index be4a036c0b..8f02826285 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -15,6 +15,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Collapsible, @@ -310,6 +311,8 @@ const settingsGroups = [ { label: "globalConfig", items: [ + { key: "profiles", component: ProfilesView }, + { key: "cameraManagement", component: CameraManagementView }, { key: "globalDetect", component: GlobalDetectSettingsPage }, { key: "globalObjects", component: GlobalObjectsSettingsPage }, { key: "globalMotion", component: GlobalMotionSettingsPage }, @@ -331,8 +334,6 @@ const settingsGroups = [ { label: "cameras", items: [ - { key: "profiles", component: ProfilesView }, - { key: "cameraManagement", component: CameraManagementView }, { key: "cameraDetect", component: CameraDetectSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage }, { key: "cameraMotion", component: CameraMotionSettingsPage }, @@ -1651,6 +1652,8 @@ export default function Settings() { const isMultiItem = filteredItems.length > 1; const renderedExpanded = !isMultiItem || !collapsedGroups.has(group.label); + const showCameraBadge = + group.label === "cameras" && !!selectedCamera; const items = filteredItems.map((item) => ( - {items} + + {showCameraBadge && ( +
+ + + +
+ )} + {items} +
) : ( items @@ -2027,6 +2042,22 @@ export default function Settings() { + {group.label === "cameras" && + selectedCamera && ( + + )} {filteredItems.map((item) => ( sendEnabled(enabledState == "ON" ? "OFF" : "ON")} disabled={debug} @@ -1489,7 +1489,7 @@ function FrigateCameraFeatures({ {isAdmin && ( <> sendEnabled(enabledState == "ON" ? "OFF" : "ON") diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 212b32389a..f18b8f94b4 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -11,7 +11,6 @@ import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useTranslation } from "react-i18next"; -import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; import { @@ -20,15 +19,13 @@ import { LuGripVertical, LuPencil, LuPlus, + LuRefreshCcw, LuTrash2, } from "react-icons/lu"; import { Reorder, useDragControls } from "framer-motion"; -import { IoMdArrowRoundBack } from "react-icons/io"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; -import { Switch } from "@/components/ui/switch"; import { Trans } from "react-i18next"; import { useEnabledState, useRestart } from "@/api/ws"; import { Label } from "@/components/ui/label"; @@ -78,12 +75,10 @@ const REORDER_SAVED_INDICATOR_MS = 1500; type ReorderSaveStatus = "idle" | "saving" | "saved"; type CameraManagementViewProps = { - setUnsavedChanges: React.Dispatch>; profileState?: ProfileState; }; export default function CameraManagementView({ - setUnsavedChanges, profileState, }: CameraManagementViewProps) { const { t } = useTranslation(["views/settings", "common"]); @@ -91,12 +86,6 @@ export default function CameraManagementView({ const { data: config, mutate: updateConfig } = useSWR("config"); - const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">( - "settings", - ); // Control view state - const [editCameraName, setEditCameraName] = useState( - undefined, - ); // Track camera being edited const [showWizard, setShowWizard] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -226,14 +215,6 @@ export default function CameraManagementView({ document.title = t("documentTitle.cameraManagement"); }, [t]); - // Handle back navigation from add/edit form - const handleBack = useCallback(() => { - setViewMode("settings"); - setEditCameraName(undefined); - setUnsavedChanges(false); - updateConfig(); - }, [updateConfig, setUnsavedChanges]); - return ( <>
- {viewMode === "settings" ? ( - <> - - {t("cameraManagement.title")} - -

- {t("cameraManagement.description")} -

+ + {t("cameraManagement.title")} + +

+ {t("cameraManagement.description")} +

-
-
- - {enabledCameras.length + disabledCameras.length > 0 && ( - - )} -
+
+
+ + {enabledCameras.length + disabledCameras.length > 0 && ( + + )} +
- {enabledCameras.length > 0 && ( - 0 || disabledCameras.length > 0) && ( + + cameraManagement.streams.title + + } + > +
+
+ +

- cameraManagement.streams.title + cameraManagement.streams.description - } - > -

-
- -
-
+

+
+
+
+ {orderedCameras.length > 0 && ( {orderedCameras.map((camera) => ( - ))} - -
-

- - cameraManagement.streams.enableDesc - -

-
- {disabledCameras.length > 0 && ( -
-
- -

- {t("cameraManagement.streams.disableDesc")} + )} + {orderedCameras.length > 0 && + disabledCameras.length > 0 && ( +

+ )} + {disabledCameras.length > 0 && ( +
+

+ {t("cameraManagement.streams.disabledSubheading")}

+ {disabledCameras.map((camera) => ( + + ))}
-
-
- {disabledCameras.map((camera) => ( -
- - -
- ))} -
-

- {t("cameraManagement.streams.disableDesc")} -

-
-
- )} - - )} - - {profileState && - profileState.allProfileNames.length > 0 && - enabledCameras.length > 0 && ( - - )} - - {config?.lpr?.enabled && allCameras.length > 0 && ( - - )} -
- - ) : ( - <> -
- -
-
- +
+

+ + cameraManagement.streams.description + +

+
+ + )} + + {profileState && + profileState.allProfileNames.length > 0 && + enabledCameras.length > 0 && ( + -
- - )} + )} + + {config?.lpr?.enabled && allCameras.length > 0 && ( + + )} +
@@ -468,17 +399,19 @@ function ReorderSaveStatusIndicator({ ); } -type EnabledCameraRowProps = { +type ActiveCameraRowProps = { camera: string; onConfigChanged: () => Promise; onDragEnd: () => void; + setRestartDialogOpen: React.Dispatch>; }; -function EnabledCameraRow({ +function ActiveCameraRow({ camera, onConfigChanged, onDragEnd, -}: EnabledCameraRowProps) { + setRestartDialogOpen, +}: ActiveCameraRowProps) { const { t } = useTranslation(["views/settings"]); const controls = useDragControls(); @@ -506,38 +439,226 @@ function EnabledCameraRow({ onConfigChanged={onConfigChanged} />
- + ); } -type CameraEnableSwitchProps = { - cameraName: string; +type DisabledCameraRowProps = { + camera: string; + onConfigChanged: () => Promise; + setRestartDialogOpen: React.Dispatch>; }; -function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { - const { payload: enabledState, send: sendEnabled } = - useEnabledState(cameraName); - const { data: config } = useSWR("config"); - - const isChecked = - enabledState === "ON" || enabledState === "OFF" - ? enabledState === "ON" - : (config?.cameras?.[cameraName]?.enabled ?? false); - +function DisabledCameraRow({ + camera, + onConfigChanged, + setRestartDialogOpen, +}: DisabledCameraRowProps) { return ( -
- { - sendEnabled(isChecked ? "ON" : "OFF"); - }} +
+
+ + +
+
); } +type CameraStatus = "on" | "off" | "disabled"; + +type CameraStatusSelectProps = { + cameraName: string; + isDisabledInConfig: boolean; + onConfigChanged: () => Promise; + setRestartDialogOpen: React.Dispatch>; +}; + +function CameraStatusSelect({ + cameraName, + isDisabledInConfig, + onConfigChanged, + setRestartDialogOpen, +}: CameraStatusSelectProps) { + const { t } = useTranslation([ + "views/settings", + "components/dialog", + "common", + ]); + const { payload: enabledState, send: sendEnabled } = + useEnabledState(cameraName); + const [isSaving, setIsSaving] = useState(false); + + const currentStatus: CameraStatus = isDisabledInConfig + ? "disabled" + : enabledState === "OFF" + ? "off" + : "on"; + + const restartLabel = t("configForm.restartRequiredField", { + ns: "views/settings", + defaultValue: "Restart required", + }); + + const handleChange = useCallback( + async (newStatus: string) => { + if (newStatus === currentStatus || isSaving) { + return; + } + + if (newStatus === "on" && !isDisabledInConfig) { + sendEnabled("ON"); + return; + } + + if (newStatus === "off" && !isDisabledInConfig) { + sendEnabled("OFF"); + return; + } + + if (newStatus === "on" && isDisabledInConfig) { + setIsSaving(true); + try { + await axios.put("config/set", { + requires_restart: 1, + config_data: { + cameras: { [cameraName]: { enabled: true } }, + }, + }); + await onConfigChanged(); + toast.success( + t("cameraManagement.streams.enableSuccess", { + ns: "views/settings", + cameraName, + }), + { + position: "top-center", + action: ( + setRestartDialogOpen(true)}> + + + ), + }, + ); + } catch (error) { + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + } finally { + setIsSaving(false); + } + return; + } + + if (newStatus === "disabled" && !isDisabledInConfig) { + setIsSaving(true); + try { + // Stop runtime processing immediately before persisting the + // disable so the camera stops working without waiting for + // a restart. The config write below makes the change durable. + sendEnabled("OFF"); + await axios.put("config/set", { + requires_restart: 0, + config_data: { + cameras: { [cameraName]: { enabled: false } }, + }, + }); + await onConfigChanged(); + toast.success( + t("cameraManagement.streams.disableSuccess", { + ns: "views/settings", + cameraName, + }), + { position: "top-center" }, + ); + } catch (error) { + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + } finally { + setIsSaving(false); + } + return; + } + }, + [ + cameraName, + currentStatus, + isDisabledInConfig, + isSaving, + onConfigChanged, + sendEnabled, + setRestartDialogOpen, + t, + ], + ); + + if (isSaving) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + type CameraDetailsEditorProps = { cameraName: string; onConfigChanged: () => Promise; @@ -783,97 +904,6 @@ function CameraDetailsEditor({ ); } -type CameraConfigEnableSwitchProps = { - cameraName: string; - setRestartDialogOpen: React.Dispatch>; - onConfigChanged: () => Promise; -}; - -function CameraConfigEnableSwitch({ - cameraName, - onConfigChanged, - setRestartDialogOpen, -}: CameraConfigEnableSwitchProps) { - const { t } = useTranslation([ - "common", - "views/settings", - "components/dialog", - ]); - const [isSaving, setIsSaving] = useState(false); - - const onCheckedChange = useCallback( - async (isChecked: boolean) => { - if (!isChecked || isSaving) { - return; - } - - setIsSaving(true); - - try { - await axios.put("config/set", { - requires_restart: 1, - config_data: { - cameras: { - [cameraName]: { - enabled: true, - }, - }, - }, - }); - - await onConfigChanged(); - - toast.success( - t("cameraManagement.streams.enableSuccess", { - ns: "views/settings", - cameraName, - }), - { - position: "top-center", - action: ( - setRestartDialogOpen(true)}> - - - ), - }, - ); - } catch (error) { - const errorMessage = - axios.isAxiosError(error) && - (error.response?.data?.message || error.response?.data?.detail) - ? error.response?.data?.message || error.response?.data?.detail - : t("toast.save.error.noMessage", { ns: "common" }); - - toast.error( - t("toast.save.error.title", { errorMessage, ns: "common" }), - { - position: "top-center", - }, - ); - } finally { - setIsSaving(false); - } - }, - [cameraName, isSaving, onConfigChanged, setRestartDialogOpen, t], - ); - - return ( -
- {isSaving ? ( - - ) : ( - - )} -
- ); -} - type CameraTypeSectionProps = { cameras: string[]; config: FrigateConfig | undefined; @@ -1231,12 +1261,12 @@ function ProfileCameraEnableSection({ })} - {t("cameraManagement.profiles.enabled", { + {t("cameraManagement.profiles.on", { ns: "views/settings", })} - {t("cameraManagement.profiles.disabled", { + {t("cameraManagement.profiles.off", { ns: "views/settings", })}