Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
3133e23bf7
Merge 0ae262726b into 39a3667f39 2026-05-27 09:27:47 +08:00
Josh Hawkins
39a3667f39
add motion review docs (#23307)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-05-25 07:04:45 -06:00
Josh Hawkins
2ed70bd693
Profiles fixes (#23306)
* add prop to disable id field

* disable id field when editing profile mask/zone

also, disable if the zone name already exists in required_zones or the base config is being edited and the id already exists on a profile

* add backend validation to reject profile-omly masks/zones

* add tests

* update docs

* tweak
2026-05-25 07:04:00 -06:00
Josh Hawkins
90248ef243
remove camera name badge (#23305) 2026-05-25 07:02:57 -06:00
Josh Hawkins
7e0e0635b8
UI tweaks (#23304)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
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
* restructure camera enable/disable pane

* remove obsolete camera edit form

* change terminology to off/on instead of disabled/enabled

* docs

* move menu options and add current camera name badge

* docs

* tweaks
2026-05-24 14:59:56 -06:00
dependabot[bot]
0ae262726b
Update types-peewee requirement from ==3.17.* to ==4.0.* in /docker/main
Updates the requirements on [types-peewee](https://github.com/python/typeshed) to permit the latest version.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-peewee
  dependency-version: 4.0.1.20260426
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 13:18:57 +00:00
26 changed files with 724 additions and 1094 deletions

View File

@ -1,4 +1,4 @@
ruff
# types
types-peewee == 3.17.*
types-peewee == 4.0.*

View File

@ -67,7 +67,7 @@ Additional cameras are simply added under the camera configuration section.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > Management" /> and use the add camera button to configure each additional camera.
Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and use the add camera button to configure each additional camera.
</TabItem>
<TabItem value="yaml">

View File

@ -113,7 +113,7 @@ Here are some common starter configuration examples. These can be configured thr
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> 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 <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
</TabItem>
@ -192,7 +192,7 @@ cameras:
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> 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 <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
7. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
</TabItem>
@ -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 <NavPath path="Settings > Global configuration > Recording" /> 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 <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
7. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
8. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> to add a motion mask for the camera timestamp
</TabItem>

View File

@ -257,19 +257,38 @@ cameras:
</TabItem>
</ConfigTabs>
### 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/<camera_name>/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

View File

@ -197,3 +197,7 @@ This option is handy when you want to prevent large transient changes from trigg
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
:::
## Reviewing Detected Motion
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](review.md#reviewing-motion) on the Review page.

View File

@ -33,10 +33,10 @@ The easiest way to define profiles is to use the Frigate UI. Profiles can also b
<ConfigTabs>
<TabItem value="ui">
1. **Create a profile** — Navigate to <NavPath path="Settings > Camera configuration > Profiles" />. Click the **Add Profile** button, enter a name (and optionally a profile ID).
1. **Create a profile** — Navigate to <NavPath path="Settings > Global configuration > Profiles" />. 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 <NavPath path="Settings > Camera configuration > Profiles" />, 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 <NavPath path="Settings > Camera configuration > Profiles" />, 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 <NavPath path="Settings > Global configuration > Profiles" />, 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 <NavPath path="Settings > Global configuration > Profiles" />, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
</TabItem>
<TabItem value="yaml">
@ -126,7 +126,9 @@ Only the fields you explicitly set in a profile override are applied. All other
## Activating Profiles
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
## Example: Home / Away Setup
@ -135,10 +137,10 @@ A common use case is having different detection and notification settings based
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**.
1. Navigate to <NavPath path="Settings > Global configuration > Profiles" /> 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 <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
4. Activate the desired profile from <NavPath path="Settings > Global configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
</TabItem>
<TabItem value="yaml">
@ -207,3 +209,27 @@ In this example:
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
- **No profile active**: All cameras use their base configuration values.
## FAQ
### Can I define a zone or mask in a profile but not have it in the base config?
No. Profiles are pure overrides. Every zone and mask defined under a profile must reference an entry that already exists on the base camera config. Configurations that introduce profile-only zones or masks are rejected at startup.
If you want a zone or mask to be active only under a specific profile, define it on the base config with `enabled: false`, then enable it in that profile's overrides.
### How do I revert a profile zone or mask override back to the base configuration?
Delete the override. In the Frigate UI, edit the profile and use the "Revert override" action (the trash can icon) on the zone or mask. The base entry is left untouched, and once the override is removed the profile inherits the base values for that zone or mask.
### Can multiple profiles be active at the same time?
No. Only one profile can be active at a time. Activating a new profile automatically deactivates the current one.
### What happens to my profile overrides if I delete a zone or mask from the base?
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
### Why are some settings missing when I configure a profile override?
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.

View File

@ -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"

View File

@ -130,3 +130,43 @@ By default a review item will be created if any `review -> alerts -> labels` and
Because zones don't apply to audio, audio labels will always be marked as a detection by default.
:::
## Reviewing Motion
The Review page also can show periods of motion that didn't produce a tracked object, and provides a way to search past recordings for motion in a specific region. These tools complement the alerts and detections workflow above — see [Tuning Motion Detection](motion_detection.md) for how the underlying motion detector is configured.
### Motion Previews
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
The pane provides a few controls:
- **Speed** — speeds up or slows down all of the preview clips at once.
- **Dim** — controls how strongly non-motion areas are darkened by the heatmap overlay. Higher values increase motion area visibility.
- **Filter** — opens a 16×16 grid overlaid on a snapshot of the camera. Select one or more cells to only show clips with motion in those regions. This is helpful for filtering out motion in areas like a busy street while keeping motion in your driveway.
Clicking a preview clip seeks the recording player to that timestamp so you can review the full footage.
### Motion Search
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
To start a search, click the 3-dots menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
1. Pick the camera and time range to scan.
2. Draw a polygon on the camera frame to define the region of interest.
3. Adjust the search parameters if needed:
| Field | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
| **Maximum Results** | Maximum number of matching timestamps to return. |
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.

View File

@ -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 <NavPath path="Settings > Camera configuration > Management" /> to use the camera setup wizard to get your first camera added into Frigate.
Click the **Add Camera** button in <NavPath path="Settings > Global configuration > Camera management" /> to use the camera setup wizard to get your first camera added into Frigate.
### Step 3: Configure hardware acceleration (recommended)

View File

@ -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.

View File

@ -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/<camera_name>/<object_name>`
@ -368,11 +368,11 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
### `frigate/<camera_name>/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/<camera_name>/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/<camera_name>/detect/set`

View File

@ -326,6 +326,47 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
)
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
for profile_name, profile in camera_config.profiles.items():
if profile.zones:
for zone_name in profile.zones:
if zone_name not in camera_config.zones:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"zone '{zone_name}' that does not exist on the base config"
)
if profile.motion and profile.motion.mask:
for mask_name in profile.motion.mask:
if mask_name not in camera_config.motion.mask:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"motion mask '{mask_name}' that does not exist on the base config"
)
if profile.objects:
for mask_name in profile.objects.mask or {}:
if mask_name not in (camera_config.objects.mask or {}):
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' that does not exist on the base config"
)
for label, filter_config in (profile.objects.filters or {}).items():
base_filter = (camera_config.objects.filters or {}).get(label)
profile_filter_masks = (
filter_config.mask if filter_config else None
) or {}
base_filter_masks = (base_filter.mask if base_filter else None) or {}
for mask_name in profile_filter_masks:
if mask_name not in base_filter_masks:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' for '{label}' that does not exist "
f"on the base config"
)
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
"""Verify that required_zones are specified when autotracking is enabled."""
if (
@ -952,6 +993,7 @@ class FrigateConfig(FrigateBaseModel):
verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config)
verify_required_zones_exist(camera_config)
verify_profile_overrides_match_base(camera_config)
verify_autotrack_zones(camera_config)
verify_motion_and_detect(camera_config)
verify_objects_track(camera_config, labelmap_objects)

View File

@ -178,6 +178,141 @@ class TestCameraProfileConfig(unittest.TestCase):
with self.assertRaises(ValidationError):
FrigateConfig(**config_data)
def test_profile_zone_without_base_rejected(self):
"""Profile defining a zone not present on the base camera is rejected."""
from pydantic import ValidationError
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"profiles": {
"armed": {
"zones": {
"phantom": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom", str(ctx.exception))
def test_profile_motion_mask_without_base_rejected(self):
"""Profile defining a motion mask not present on the base camera is rejected."""
from pydantic import ValidationError
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"motion": {
"mask": {
"base_mask": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"motion": {
"mask": {
"phantom_mask": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom_mask", str(ctx.exception))
def test_profile_overrides_matching_base_accepted(self):
"""Profile overrides that reference existing base zones/masks parse cleanly."""
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"zones": {
"front_yard": {
"coordinates": "0,0,50,0,50,50,0,50",
"inertia": 5,
},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,75,0,75,75,0,75",
},
},
},
},
},
},
},
}
config = FrigateConfig(**config_data)
assert "armed" in config.cameras["front"].profiles
class TestProfileInConfig(unittest.TestCase):
"""Test that profiles parse correctly in FrigateConfig."""

View File

@ -12,7 +12,7 @@
"title": "Stream Offline",
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
},
"cameraDisabled": "Camera is disabled",
"cameraOff": "Camera is off",
"stats": {
"streamType": {
"title": "Stream Type:",

View File

@ -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",

View File

@ -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.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />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.<br /><br /><strong>On</strong>: streams are processed normally.<br /><strong>Off</strong>: temporarily pauses processing. Does not persist across Frigate restarts.<br /><strong>Disabled</strong>: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.<br /><br /><em>Note: Disabling does not affect go2rtc restreams.</em><br /><br />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",

View File

@ -26,6 +26,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
placeholderName?: string;
placeholderId?: string;
idVisible?: boolean;
idDisabled?: boolean;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@ -41,6 +42,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderName,
placeholderId,
idVisible,
idDisabled,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>();
@ -59,6 +61,9 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
const effectiveProcessId = processId || defaultProcessId;
useEffect(() => {
if (idDisabled) {
return;
}
const subscription = watch((value, { name }) => {
if (name === nameField) {
hasUserTypedRef.current = true;
@ -68,7 +73,15 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
}
});
return () => subscription.unsubscribe();
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
}, [
watch,
setValue,
trigger,
nameField,
idField,
effectiveProcessId,
idDisabled,
]);
// Auto-expand if there's an error on the ID field after user has typed
useEffect(() => {
@ -123,6 +136,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
<Input
className="text-md"
placeholder={placeholderId}
disabled={idDisabled}
{...field}
/>
</FormControl>

View File

@ -320,7 +320,7 @@ export default function LiveContextMenu({
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? t("camera.disable") : t("camera.enable")}
{isEnabled ? t("camera.turnOff") : t("camera.turnOn")}
</div>
</div>
</ContextMenuItem>

View File

@ -488,7 +488,7 @@ export default function LivePlayer({
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
{t("cameraDisabled")}
{t("cameraOff")}
</p>
</div>
</div>

View File

@ -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<typeof RoleEnum>;
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<FrigateConfig>("config");
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
cameras: Record<
string,
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
>;
go2rtc: { streams: Record<string, string | string[]> };
}>(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<Role, number>();
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<typeof formSchema>;
const cameraInfo = useMemo(() => {
if (!cameraName || !config?.cameras[cameraName]) {
return {
friendly_name: undefined,
name: cameraName || "",
roles: new Set<Role>(),
go2rtcStreams: {},
};
}
const camera = config.cameras[cameraName];
const roles = new Set<Role>();
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<string, string[]> = {};
// 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<string>();
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<FormValues>({
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<string>();
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<string, string[]> = {};
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<Role>();
watchedInputs.forEach((input) => {
input.roles.forEach((role) => used.add(role));
});
return used.has("detect") ? [] : ["detect"];
};
const getUsedRolesExcludingIndex = (excludeIndex: number) => {
const roles = new Set<Role>();
watchedInputs.forEach((input, idx) => {
if (idx !== excludeIndex) {
input.roles.forEach((role) => roles.add(role));
}
});
return roles;
};
return (
<div className="scrollbar-container max-w-4xl overflow-y-auto md:mb-24">
<Toaster position="top-center" closeButton />
<Heading as="h3" className="my-2">
{cameraName
? t("cameraManagement.cameraConfig.edit")
: t("cameraManagement.cameraConfig.add")}
</Heading>
<div className="my-3 text-sm text-muted-foreground">
{t("cameraManagement.cameraConfig.description")}
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="cameraName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraManagement.cameraConfig.name")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"cameraManagement.cameraConfig.namePlaceholder",
)}
{...field}
disabled={!!cameraName} // Prevent editing name for existing cameras
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>
{t("cameraManagement.cameraConfig.enabled")}
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.inputs")}
</Label>
{fields.map((field, index) => (
<Card key={field.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">
{t("cameraWizard.step3.streamTitle", {
number: index + 1,
})}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => remove(index)}
disabled={fields.length === 1}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
</div>
<FormField
control={form.control}
name={`ffmpeg.inputs.${index}.path`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.path")}
</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder={t(
"cameraManagement.cameraConfig.ffmpeg.pathPlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.roles")}
</Label>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map(
(role) => {
const isUsedElsewhere =
getUsedRolesExcludingIndex(index).has(role);
const isChecked =
watchedInputs[index]?.roles?.includes(role) ||
false;
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">
{role}
</span>
<Switch
checked={isChecked}
onCheckedChange={(checked) => {
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}
/>
</div>
);
},
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
<FormMessage>
{form.formState.errors.ffmpeg?.inputs?.root &&
form.formState.errors.ffmpeg.inputs.root.message}
</FormMessage>
<Button
type="button"
onClick={() => append({ path: "", roles: getAvailableRoles() })}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.ffmpeg.addInput")}
</Button>
</div>
{/* go2rtc Streams Section */}
{Object.keys(watchedGo2rtcStreams).length > 0 && (
<div className="space-y-4">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.go2rtcStreams")}
</Label>
{Object.entries(watchedGo2rtcStreams).map(
([streamName, urls]) => (
<Card key={streamName} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">{streamName}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
const updatedStreams = { ...watchedGo2rtcStreams };
delete updatedStreams[streamName];
form.setValue("go2rtcStreams", updatedStreams);
}}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.streamUrls")}
</Label>
{(Array.isArray(urls) ? urls : [urls]).map(
(url, urlIndex) => (
<div
key={urlIndex}
className="flex items-center gap-2"
>
<Input
className="h-8 flex-1"
value={url}
onChange={(e) => {
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 && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const updatedStreams = {
...watchedGo2rtcStreams,
};
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls.splice(urlIndex, 1);
updatedStreams[streamName] = currentUrls;
form.setValue(
"go2rtcStreams",
updatedStreams,
);
}}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-4" />
</Button>
)}
</div>
),
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const updatedStreams = { ...watchedGo2rtcStreams };
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls.push("");
updatedStreams[streamName] = currentUrls;
form.setValue("go2rtcStreams", updatedStreams);
}}
className="w-fit"
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.addUrl")}
</Button>
</div>
</CardContent>
</Card>
),
)}
<Button
type="button"
onClick={() => {
const streamName = `${cameraName}_stream_${Object.keys(watchedGo2rtcStreams).length + 1}`;
const updatedStreams = {
...watchedGo2rtcStreams,
[streamName]: [""],
};
form.setValue("go2rtcStreams", updatedStreams);
}}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.addGo2rtcStream")}
</Button>
</div>
)}
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@ -258,8 +258,9 @@ export default function MotionMaskEditPane({
},
);
updateConfig();
// Only publish WS state for base config when mask has a name
if (!editingProfile && maskName) {
// Only publish WS state for base config when mask has a name and
// wasn't renamed (the hook is bound to the old name).
if (!editingProfile && maskName && !renamingMask) {
sendMotionMaskState(enabled ? "ON" : "OFF");
}
} else {
@ -414,6 +415,7 @@ export default function MotionMaskEditPane({
nameLabel={t("masksAndZones.motionMasks.name.title")}
nameDescription={t("masksAndZones.motionMasks.name.description")}
placeholderName={t("masksAndZones.motionMasks.name.placeholder")}
idDisabled={!!editingProfile && polygon.name.length > 0}
/>
<FormField
control={form.control}

View File

@ -263,8 +263,9 @@ export default function ObjectMaskEditPane({
},
);
updateConfig();
// Only publish WS state for base config when mask has a name
if (!editingProfile && maskName) {
// Only publish WS state for base config when mask has a name and
// wasn't renamed (the hook is bound to the old name).
if (!editingProfile && maskName && !renamingMask) {
sendObjectMaskState(enabled ? "ON" : "OFF");
}
} else {
@ -389,6 +390,7 @@ export default function ObjectMaskEditPane({
placeholderName={t(
"masksAndZones.objectMasks.name.placeholder",
)}
idDisabled={!!editingProfile && polygon.name.length > 0}
/>
<FormField
control={form.control}

View File

@ -94,6 +94,28 @@ export default function ZoneEditPane({
const zoneName = polygon?.name || "";
const { send: sendZoneState } = useZoneState(polygon?.camera || "", zoneName);
const isExistingZone = !!polygon && polygon.name.length > 0;
const idDisabled = useMemo(() => {
if (!isExistingZone || !polygon) {
return false;
}
if (editingProfile) {
return true;
}
const cam = config?.cameras[polygon.camera];
if (!cam) {
return false;
}
const inRequiredZones =
cam.review.alerts.required_zones.includes(polygon.name) ||
cam.review.detections.required_zones.includes(polygon.name);
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
(profile) => profile?.zones && polygon.name in profile.zones,
);
return inRequiredZones || hasProfileOverride;
}, [config, polygon, editingProfile, isExistingZone]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
@ -419,6 +441,7 @@ export default function ZoneEditPane({
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
@ -444,6 +467,7 @@ export default function ZoneEditPane({
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
}
@ -527,8 +551,9 @@ export default function ZoneEditPane({
},
);
updateConfig();
// Only publish WS state for base config when zone has a name
if (!editingProfile && polygon?.name) {
// Only publish WS state for base config when zone has a name and
// wasn't renamed (the hook is bound to the old name).
if (!editingProfile && polygon?.name && !renamingZone) {
sendZoneState(enabled ? "ON" : "OFF");
}
} else {
@ -650,6 +675,7 @@ export default function ZoneEditPane({
nameLabel={t("masksAndZones.zones.name.title")}
nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
idDisabled={idDisabled}
/>
<FormField
control={form.control}

View File

@ -310,6 +310,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 +333,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 },
@ -1678,7 +1678,16 @@ export default function Settings() {
onOpenChange={() => toggleGroupCollapsed(group.label)}
>
<CollapsibleTrigger className="flex min-h-10 w-full items-center justify-between rounded-md py-2 pl-2 pr-2 text-sm font-medium text-secondary-foreground">
<div>{t("menu." + group.label)}</div>
<div className="flex flex-col justify-start gap-0.5 text-left">
{t("menu." + group.label)}
{group.label === "cameras" &&
renderedExpanded &&
selectedCamera && (
<div className="max-w-full break-words text-xs text-secondary-foreground/80 smart-capitalize">
<CameraNameLabel camera={selectedCamera} />
</div>
)}
</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
@ -2015,8 +2024,33 @@ export default function Settings() {
: "text-sidebar-foreground/80",
)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div>{t("menu." + group.label)}</div>
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between",
renderedExpanded &&
group.label == "cameras" &&
"mb-2",
)}
>
<div className="flex flex-col justify-start gap-0.5 text-left">
{t("menu." + group.label)}
{group.label === "cameras" &&
renderedExpanded &&
selectedCamera && (
<div
className={cn(
"max-w-full break-words text-xs smart-capitalize",
hasActiveItem
? "text-primary/60"
: "text-sidebar-foreground/80",
)}
>
<CameraNameLabel
camera={selectedCamera}
/>
</div>
)}
</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",

View File

@ -1048,7 +1048,7 @@ function FrigateCameraFeatures({
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
enabledState == "ON" ? t("camera.turnOff") : t("camera.turnOn")
}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={debug}
@ -1489,7 +1489,7 @@ function FrigateCameraFeatures({
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.cameraEnabled")}
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")

View File

@ -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<React.SetStateAction<boolean>>;
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<FrigateConfig>("config");
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
"settings",
); // Control view state
const [editCameraName, setEditCameraName] = useState<string | undefined>(
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 (
<>
<Toaster
@ -244,174 +225,124 @@ export default function CameraManagementView({
/>
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{enabledCameras.length + disabledCameras.length > 0 && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
</Button>
)}
</div>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{enabledCameras.length + disabledCameras.length > 0 && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
</Button>
)}
</div>
{enabledCameras.length > 0 && (
<SettingsGroupCard
title={
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard
title={
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
}
>
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label>{t("cameraManagement.streams.label")}</Label>
<p className="hidden text-sm text-muted-foreground md:block">
<Trans ns="views/settings">
cameraManagement.streams.title
cameraManagement.streams.description
</Trans>
}
>
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label
className="cursor-pointer"
htmlFor={"enabled-cameras-switch"}
>
{t("cameraManagement.streams.enableLabel")}
<p className="hidden text-sm text-muted-foreground md:block">
<Trans ns="views/settings">
cameraManagement.streams.enableDesc
</Trans>
</p>
</Label>
</div>
<div className="max-w-md space-y-1.5">
</p>
</div>
<div className="max-w-md space-y-1.5">
<div className="space-y-3 rounded-lg bg-secondary p-4">
{orderedCameras.length > 0 && (
<Reorder.Group
as="div"
axis="y"
values={orderedCameras}
onReorder={setOrderedCameras}
className="space-y-2 rounded-lg bg-secondary p-4"
className="space-y-2"
>
{orderedCameras.map((camera) => (
<EnabledCameraRow
<ActiveCameraRow
key={camera}
camera={camera}
onConfigChanged={updateConfig}
onDragEnd={handleReorderDragEnd}
setRestartDialogOpen={setRestartDialogOpen}
/>
))}
</Reorder.Group>
<ReorderSaveStatusIndicator
status={reorderSaveStatus}
/>
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
cameraManagement.streams.enableDesc
</Trans>
</p>
</div>
{disabledCameras.length > 0 && (
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label
className="cursor-pointer"
htmlFor={"disabled-cameras-switch"}
>
{t("cameraManagement.streams.disableLabel")}
<RestartRequiredIndicator className="ml-1" />
</Label>
<p className="hidden text-sm text-muted-foreground md:block">
{t("cameraManagement.streams.disableDesc")}
)}
{orderedCameras.length > 0 &&
disabledCameras.length > 0 && (
<div className="border-t border-border/40" />
)}
{disabledCameras.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("cameraManagement.streams.disabledSubheading")}
</p>
{disabledCameras.map((camera) => (
<DisabledCameraRow
key={camera}
camera={camera}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
))}
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
{disabledCameras.map((camera) => (
<div
key={camera}
className="flex flex-row items-center justify-between"
>
<CameraNameLabel camera={camera} />
<CameraConfigEnableSwitch
cameraName={camera}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
</div>
))}
</div>
<p className="text-sm text-muted-foreground md:hidden">
{t("cameraManagement.streams.disableDesc")}
</p>
</div>
</div>
)}
</SettingsGroupCard>
)}
{profileState &&
profileState.allProfileNames.length > 0 &&
enabledCameras.length > 0 && (
<ProfileCameraEnableSection
profileState={profileState}
cameras={enabledCameras}
config={config}
onConfigChanged={updateConfig}
/>
)}
{config?.lpr?.enabled && allCameras.length > 0 && (
<CameraTypeSection
cameras={allCameras}
config={config}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
)}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center gap-2">
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
)}
</div>
)}
</Button>
</div>
<div className="md:max-w-5xl">
<CameraEditForm
cameraName={viewMode === "edit" ? editCameraName : undefined}
onSave={handleBack}
onCancel={handleBack}
<ReorderSaveStatusIndicator status={reorderSaveStatus} />
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
cameraManagement.streams.description
</Trans>
</p>
</div>
</SettingsGroupCard>
)}
{profileState &&
profileState.allProfileNames.length > 0 &&
enabledCameras.length > 0 && (
<ProfileCameraEnableSection
profileState={profileState}
cameras={enabledCameras}
config={config}
onConfigChanged={updateConfig}
/>
</div>
</>
)}
)}
{config?.lpr?.enabled && allCameras.length > 0 && (
<CameraTypeSection
cameras={allCameras}
config={config}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
)}
</div>
</div>
</div>
@ -468,17 +399,19 @@ function ReorderSaveStatusIndicator({
);
}
type EnabledCameraRowProps = {
type ActiveCameraRowProps = {
camera: string;
onConfigChanged: () => Promise<unknown>;
onDragEnd: () => void;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
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}
/>
</div>
<CameraEnableSwitch cameraName={camera} />
<CameraStatusSelect
cameraName={camera}
isDisabledInConfig={false}
onConfigChanged={onConfigChanged}
setRestartDialogOpen={setRestartDialogOpen}
/>
</Reorder.Item>
);
}
type CameraEnableSwitchProps = {
cameraName: string;
type DisabledCameraRowProps = {
camera: string;
onConfigChanged: () => Promise<unknown>;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const { data: config } = useSWR<FrigateConfig>("config");
const isChecked =
enabledState === "ON" || enabledState === "OFF"
? enabledState === "ON"
: (config?.cameras?.[cameraName]?.enabled ?? false);
function DisabledCameraRow({
camera,
onConfigChanged,
setRestartDialogOpen,
}: DisabledCameraRowProps) {
return (
<div className="flex flex-row items-center">
<Switch
id={`camera-enabled-${cameraName}`}
checked={isChecked}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-1">
<CameraNameLabel camera={camera} className="text-muted-foreground" />
<CameraDetailsEditor
cameraName={camera}
onConfigChanged={onConfigChanged}
/>
</div>
<CameraStatusSelect
cameraName={camera}
isDisabledInConfig={true}
onConfigChanged={onConfigChanged}
setRestartDialogOpen={setRestartDialogOpen}
/>
</div>
);
}
type CameraStatus = "on" | "off" | "disabled";
type CameraStatusSelectProps = {
cameraName: string;
isDisabledInConfig: boolean;
onConfigChanged: () => Promise<unknown>;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
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: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
} 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 (
<div className="flex h-7 w-[110px] flex-row items-center justify-end">
<ActivityIndicator className="size-4" size={16} />
</div>
);
}
return (
<Select value={currentStatus} onValueChange={handleChange}>
<SelectTrigger className="h-7 w-[110px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="on">
<div className="flex items-center gap-1.5">
{t("cameraManagement.streams.status.on")}
{isDisabledInConfig && (
<LuRefreshCcw
className="size-3 text-muted-foreground"
aria-label={restartLabel}
/>
)}
</div>
</SelectItem>
{!isDisabledInConfig && (
<SelectItem value="off">
{t("cameraManagement.streams.status.off")}
</SelectItem>
)}
<SelectItem value="disabled">
{t("cameraManagement.streams.status.disabled")}
</SelectItem>
</SelectContent>
</Select>
);
}
type CameraDetailsEditorProps = {
cameraName: string;
onConfigChanged: () => Promise<unknown>;
@ -783,97 +904,6 @@ function CameraDetailsEditor({
);
}
type CameraConfigEnableSwitchProps = {
cameraName: string;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfigChanged: () => Promise<unknown>;
};
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: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
} 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 (
<div className="flex flex-row items-center">
{isSaving ? (
<ActivityIndicator className="h-5 w-8" size={16} />
) : (
<Switch
id={`camera-enabled-${cameraName}`}
checked={false}
onCheckedChange={onCheckedChange}
/>
)}
</div>
);
}
type CameraTypeSectionProps = {
cameras: string[];
config: FrigateConfig | undefined;
@ -1231,12 +1261,12 @@ function ProfileCameraEnableSection({
})}
</SelectItem>
<SelectItem value="enabled">
{t("cameraManagement.profiles.enabled", {
{t("cameraManagement.profiles.on", {
ns: "views/settings",
})}
</SelectItem>
<SelectItem value="disabled">
{t("cameraManagement.profiles.disabled", {
{t("cameraManagement.profiles.off", {
ns: "views/settings",
})}
</SelectItem>